From e1e8b23b46f0c3a0f3fd464d6c02d1bcf4ad54ce Mon Sep 17 00:00:00 2001 From: Sam Fitzgerald Date: Mon, 15 Jun 2026 13:34:30 -0400 Subject: [PATCH 1/2] ci(build): track build performance with Next.js cache + metrics Add a CI build job that restores the Next.js incremental cache (.next/cache) keyed on lockfile + source hash, then runs pnpm build:metrics. The new scripts/build-metrics.mjs times the production build, writes reports/build-metrics.json, appends a job summary, and uploads it as an artifact so build-duration regressions are visible across runs. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .github/workflows/code-quality.yml | 46 ++++++++++++++++++++ AGENTS.md | 3 ++ package.json | 1 + scripts/build-metrics.mjs | 69 ++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 scripts/build-metrics.mjs diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 6079a4a..69c52ee 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -59,3 +59,49 @@ jobs: - name: Validate docs (AGENTS.md/README commands + links) run: pnpm validate-docs + + build: + runs-on: ubuntu-latest + env: + # Public, non-secret placeholders so the production build is deterministic + # and does not depend on repository secrets (the app reads Supabase at runtime). + NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321 + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: build-placeholder-key + NEXT_PUBLIC_SITE_URL: http://localhost:3000 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11.5.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Restore Next.js build cache + id: next-cache + uses: actions/cache@v4 + with: + path: .next/cache + # Cache the Next.js compiler/incremental cache so rebuilds reuse work. + key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.[jt]s', 'src/**/*.[jt]sx', '*.ts', '*.mjs') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}- + + - name: Build with metrics (incremental via restored .next/cache) + env: + NEXT_BUILD_CACHE_HIT: ${{ steps.next-cache.outputs.cache-hit }} + run: pnpm build:metrics + + - name: Upload build metrics (duration + cache hit) + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: build-metrics + path: reports/build-metrics.json + if-no-files-found: warn diff --git a/AGENTS.md b/AGENTS.md index d7e0c1b..0b3ffb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ Requires Node 22+, pnpm 11.5.0, the Supabase CLI, and Docker. | --------------- | ---------------------------------------------------- | | Dev server | `pnpm dev` | | Build | `pnpm build` | +| Build + metrics | `pnpm build:metrics` | | Lint | `pnpm lint` | | Type-check | `pnpm typecheck` | | Format | `pnpm format` (write) / `pnpm format:check` (verify) | @@ -33,6 +34,8 @@ Requires Node 22+, pnpm 11.5.0, the Supabase CLI, and Docker. **Before committing or finishing a task, run:** `pnpm lint && pnpm typecheck && pnpm test`. The Husky `pre-commit` hook enforces `lint-staged` (Prettier check + ESLint with `--max-warnings=0`), `pnpm typecheck`, and `pnpm tech-debt`, so commits fail on any lint warning, format drift, type error, or untracked TODO marker. The `Code Quality` GitHub Actions workflow (`.github/workflows/code-quality.yml`) additionally runs `knip`, `jscpd`, and the tech-debt scan on every push and PR. +**Build performance:** the same workflow has a separate `build` job that restores the Next.js incremental cache (`.next/cache`, keyed on the lockfile and source hash via `actions/cache`) so rebuilds reuse compiler work, then runs `pnpm build:metrics`. `scripts/build-metrics.mjs` times the production build, writes `reports/build-metrics.json` (duration, cache-hit, Next/Node versions, commit), appends a summary to the GitHub job page, and uploads the JSON as a `build-metrics` artifact so build-duration regressions are visible across runs. + ## Conventions - **Language:** TypeScript in strict mode. Do not introduce `any`; prefer precise types. JS files are not allowed (`allowJs: false`). diff --git a/package.json b/package.json index 33dfe73..399b9f2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "next dev", "build": "next build", + "build:metrics": "node scripts/build-metrics.mjs", "start": "next start", "lint": "eslint .", "format": "prettier . --write", diff --git a/scripts/build-metrics.mjs b/scripts/build-metrics.mjs new file mode 100644 index 0000000..47008fe --- /dev/null +++ b/scripts/build-metrics.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// Runs the production build, measures wall-clock duration, and exports a +// machine-readable build-metrics report. Used in CI to track build performance +// over time and surface regressions; appends a summary to the GitHub job page +// when GITHUB_STEP_SUMMARY is available. Forwards the build's exit code. +import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const reportPath = resolve(repoRoot, "reports", "build-metrics.json"); + +function readNextVersion() { + try { + const pkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")); + return pkg.dependencies?.next ?? pkg.devDependencies?.next ?? "unknown"; + } catch { + return "unknown"; + } +} + +function runBuild() { + return new Promise((resolveRun) => { + const start = process.hrtime.bigint(); + const child = spawn("pnpm", ["build"], { stdio: "inherit", cwd: repoRoot, env: process.env }); + child.on("close", (code) => { + const durationMs = Number((process.hrtime.bigint() - start) / 1_000_000n); + resolveRun({ code: code ?? 1, durationMs }); + }); + }); +} + +const { code, durationMs } = await runBuild(); +const durationSec = Math.round(durationMs / 100) / 10; + +const metrics = { + status: code === 0 ? "success" : "failure", + durationMs, + durationSec, + timestamp: new Date().toISOString(), + nodeVersion: process.version, + nextVersion: readNextVersion(), + cacheRestored: process.env.NEXT_BUILD_CACHE_HIT === "true", + commit: process.env.GITHUB_SHA ?? null +}; + +mkdirSync(dirname(reportPath), { recursive: true }); +writeFileSync(reportPath, `${JSON.stringify(metrics, null, 2)}\n`); + +const summary = + `### Build metrics\n\n` + + `- Status: ${metrics.status}\n` + + `- Duration: ${durationSec}s\n` + + `- Cache restored: ${metrics.cacheRestored}\n` + + `- Next.js: ${metrics.nextVersion}\n` + + `- Node: ${metrics.nodeVersion}\n`; + +console.log(`\nBuild ${metrics.status} in ${durationSec}s (metrics -> ${resolve("reports/build-metrics.json")})`); + +if (process.env.GITHUB_STEP_SUMMARY) { + try { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } catch { + // Non-fatal: summary is best-effort. + } +} + +process.exit(code); From daa51bb5635d903b550b68dfa4d3c58bf7816ae2 Mon Sep 17 00:00:00 2001 From: Sam Fitzgerald Date: Mon, 15 Jun 2026 15:27:57 -0400 Subject: [PATCH 2/2] fix(build-metrics): handle spawn errors and correct docs field name Address code-review findings on PR #7: - Add a child 'error' handler (with a settle guard) so a failed pnpm spawn still resolves and forwards a non-zero exit code instead of crashing. - AGENTS.md referenced a 'cache-hit' field; the report writes cacheRestored. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- AGENTS.md | 2 +- scripts/build-metrics.mjs | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e73bb90..a2acf7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Requires Node 22+, pnpm 11.5.0, the Supabase CLI, and Docker. **Before committing or finishing a task, run:** `pnpm lint && pnpm typecheck && pnpm test`. The Husky `pre-commit` hook enforces `lint-staged` (Prettier check + ESLint with `--max-warnings=0`), `pnpm typecheck`, and `pnpm tech-debt`, so commits fail on any lint warning, format drift, type error, or untracked TODO marker. The `Code Quality` GitHub Actions workflow (`.github/workflows/code-quality.yml`) additionally runs `knip`, `jscpd`, and the tech-debt scan on every push and PR. -**Build performance:** the same workflow has a separate `build` job that restores the Next.js incremental cache (`.next/cache`, keyed on the lockfile and source hash via `actions/cache`) so rebuilds reuse compiler work, then runs `pnpm build:metrics`. `scripts/build-metrics.mjs` times the production build, writes `reports/build-metrics.json` (duration, cache-hit, Next/Node versions, commit), appends a summary to the GitHub job page, and uploads the JSON as a `build-metrics` artifact so build-duration regressions are visible across runs. +**Build performance:** the same workflow has a separate `build` job that restores the Next.js incremental cache (`.next/cache`, keyed on the lockfile and source hash via `actions/cache`) so rebuilds reuse compiler work, then runs `pnpm build:metrics`. `scripts/build-metrics.mjs` times the production build, writes `reports/build-metrics.json` (duration, `cacheRestored` flag, Next/Node versions, commit), appends a summary to the GitHub job page, and uploads the JSON as a `build-metrics` artifact so build-duration regressions are visible across runs. ## Conventions diff --git a/scripts/build-metrics.mjs b/scripts/build-metrics.mjs index 47008fe..e2340fa 100644 --- a/scripts/build-metrics.mjs +++ b/scripts/build-metrics.mjs @@ -24,10 +24,20 @@ function runBuild() { return new Promise((resolveRun) => { const start = process.hrtime.bigint(); const child = spawn("pnpm", ["build"], { stdio: "inherit", cwd: repoRoot, env: process.env }); - child.on("close", (code) => { + + let settled = false; + const settle = (code) => { + if (settled) return; + settled = true; const durationMs = Number((process.hrtime.bigint() - start) / 1_000_000n); resolveRun({ code: code ?? 1, durationMs }); + }; + + child.on("error", (err) => { + console.error("Failed to run pnpm build", err); + settle(1); }); + child.on("close", (code) => settle(code)); }); }