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 6fa6630..a2acf7b 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, `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 - **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 29c191d..a143287 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..e2340fa --- /dev/null +++ b/scripts/build-metrics.mjs @@ -0,0 +1,79 @@ +#!/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 }); + + 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)); + }); +} + +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);