diff --git a/.github/docker/Dockerfile.web b/.github/docker/Dockerfile.web index fb19bd50fb..d2a25ab574 100644 --- a/.github/docker/Dockerfile.web +++ b/.github/docker/Dockerfile.web @@ -63,7 +63,9 @@ RUN if [ -n "$GOOGLE_PLACES_API_KEY" ]; then \ >> compass.yaml; \ fi -RUN cd apps/calendar-web && bun run build.ts +# Measure boot gzip for logs; deploy must not fail on absolute bundle size. +ENV COMPASS_BOOT_SIZE_ENFORCE=0 +RUN .github/perf/run-boot-size-check.sh FROM oven/bun:1.3.14-slim AS runtime diff --git a/.github/perf/compass.boot-size.yaml b/.github/perf/compass.boot-size.yaml index 90c00701f1..9408e05049 100644 --- a/.github/perf/compass.boot-size.yaml +++ b/.github/perf/compass.boot-size.yaml @@ -1,4 +1,4 @@ -# Build-time config for the required boot-size check (test-unit.yml). +# Build-time config for boot gzip measurement (boot-bundle.yml, Dockerfile.web). # Mirrors .github/docker/Dockerfile.web for staging-cloud deploy: same fields # baked into compass.yaml and the same placeholder lengths for values that # end up in the bundle (OAuth client IDs, PostHog, BUILD_VERSION via diff --git a/.github/perf/compass.perf.yaml b/.github/perf/compass.perf.yaml index bdf73a5755..a7120a0592 100644 --- a/.github/perf/compass.perf.yaml +++ b/.github/perf/compass.perf.yaml @@ -1,6 +1,6 @@ # Build-time config for the Lighthouse performance budget (perf-budget.yml). -# The required boot-size check in test-unit.yml uses compass.boot-size.yaml -# and .github/perf/run-boot-size-check.sh so it matches +# Boot gzip CI and Dockerfile.web use compass.boot-size.yaml and +# .github/perf/run-boot-size-check.sh so they match # .github/docker/Dockerfile.web for cloud deploy. # Only the values baked into the web bundle matter (nodeEnv, apiUrl, # PostHog placeholders sized like production deploy); the rest diff --git a/.github/perf/pr-boot-size-delta.ts b/.github/perf/pr-boot-size-delta.ts new file mode 100644 index 0000000000..8748129ccb --- /dev/null +++ b/.github/perf/pr-boot-size-delta.ts @@ -0,0 +1,188 @@ +/** + * Compare deploy-shaped boot gzip on the PR head vs merge base, post a sticky + * PR comment, and exit non-zero when growth exceeds the per-PR budget unless + * the PR has the bundle-size-ok label. + */ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const repoRoot = path.resolve(import.meta.dir, "../.."); +const measureScript = path.join( + repoRoot, + ".github/perf/run-boot-size-check.sh", +); +const maxDeltaBytes = 5 * 1024; +const commentMarker = ""; +const overrideLabel = "bundle-size-ok"; + +interface BootMetrics { + totalGzipBytes: number; +} + +function formatKb(bytes: number): string { + return `${(bytes / 1024).toFixed(1)} KB`; +} + +function formatSignedKb(deltaBytes: number): string { + const kb = deltaBytes / 1024; + const sign = kb >= 0 ? "+" : ""; + return `${sign}${kb.toFixed(1)} KB`; +} + +function parseMetricsFile(filePath: string): BootMetrics { + const raw = JSON.parse(readFileSync(filePath, "utf8")) as unknown; + if ( + !raw || + typeof raw !== "object" || + typeof (raw as BootMetrics).totalGzipBytes !== "number" + ) { + throw new Error(`Invalid boot metrics at ${filePath}`); + } + return raw as BootMetrics; +} + +function measureBootGzip(cwd: string, metricsPath: string): BootMetrics { + execFileSync("bash", [measureScript], { + cwd, + env: { + ...process.env, + COMPASS_BOOT_SIZE_ENFORCE: "0", + COMPASS_BOOT_SIZE_METRICS_FILE: metricsPath, + }, + stdio: "inherit", + }); + return parseMetricsFile(metricsPath); +} + +function ghJson(args: string[]): unknown { + const out = execFileSync("gh", args, { + encoding: "utf8", + cwd: repoRoot, + }).trim(); + return out ? JSON.parse(out) : null; +} + +function hasOverrideLabel(prNumber: string): boolean { + const out = execFileSync( + "gh", + ["pr", "view", prNumber, "--json", "labels", "--jq", ".labels[].name"], + { encoding: "utf8", cwd: repoRoot }, + ).trim(); + return out.split("\n").filter(Boolean).includes(overrideLabel); +} + +function upsertStickyComment(prNumber: string, body: string): void { + const marker = commentMarker; + const fullBody = `${marker}\n${body}`; + const comments = ghJson([ + "api", + `repos/${process.env.GITHUB_REPOSITORY}/issues/${prNumber}/comments`, + "--paginate", + ]) as Array<{ id: number; body?: string }> | null; + + const existing = (comments ?? []).find((comment) => + (comment.body ?? "").includes(marker), + ); + if (existing) { + execFileSync( + "gh", + [ + "api", + "--method", + "PATCH", + `repos/${process.env.GITHUB_REPOSITORY}/issues/comments/${existing.id}`, + "-f", + `body=${fullBody}`, + ], + { cwd: repoRoot, stdio: "inherit" }, + ); + return; + } + + const commentFile = path.join( + tmpdir(), + `compass-boot-comment-${process.pid}.md`, + ); + writeFileSync(commentFile, fullBody, "utf8"); + try { + execFileSync( + "gh", + ["pr", "comment", prNumber, "--body-file", commentFile], + { cwd: repoRoot, stdio: "inherit" }, + ); + } finally { + rmSync(commentFile, { force: true }); + } +} + +function main(): void { + const baseSha = process.env.BASE_SHA; + const headSha = process.env.HEAD_SHA ?? "HEAD"; + const prNumber = process.env.PR_NUMBER; + const repository = process.env.GITHUB_REPOSITORY; + + if (!baseSha) { + throw new Error("BASE_SHA is required"); + } + if (!repository) { + throw new Error("GITHUB_REPOSITORY is required"); + } + + const headMetricsPath = path.join( + tmpdir(), + `compass-boot-head-${process.pid}.json`, + ); + const headMetrics = measureBootGzip(repoRoot, headMetricsPath); + + const baseWorktree = mkdtempSync(path.join(tmpdir(), "compass-boot-base-")); + try { + execFileSync( + "git", + ["worktree", "add", "--detach", baseWorktree, baseSha], + { + cwd: repoRoot, + stdio: "inherit", + }, + ); + execFileSync("bun", ["install", "--frozen-lockfile"], { + cwd: baseWorktree, + stdio: "inherit", + }); + const baseMetricsPath = path.join(baseWorktree, "boot-base-metrics.json"); + const baseMetrics = measureBootGzip(baseWorktree, baseMetricsPath); + + const deltaBytes = headMetrics.totalGzipBytes - baseMetrics.totalGzipBytes; + const summary = `Boot bundle: ${formatKb(headMetrics.totalGzipBytes)} gzip (${formatSignedKb(deltaBytes)} vs main)`; + + console.log(summary); + console.log( + ` head ${headSha}: ${headMetrics.totalGzipBytes} B gzip, base ${baseSha}: ${baseMetrics.totalGzipBytes} B gzip`, + ); + + if (prNumber) { + const limitNote = + deltaBytes > maxDeltaBytes + ? `\n\nDelta exceeds +${maxDeltaBytes / 1024} KB per PR. Add the \`${overrideLabel}\` label to allow intentional growth.` + : ""; + upsertStickyComment(prNumber, `${summary}${limitNote}`); + } + + const overridden = prNumber ? hasOverrideLabel(prNumber) : false; + if (deltaBytes > maxDeltaBytes && !overridden) { + console.error( + `Boot gzip grew ${deltaBytes} B (> ${maxDeltaBytes} B) vs ${baseSha}.`, + ); + process.exit(1); + } + } finally { + rmSync(headMetricsPath, { force: true }); + execFileSync("git", ["worktree", "remove", "--force", baseWorktree], { + cwd: repoRoot, + stdio: "inherit", + }); + } +} + +main(); diff --git a/.github/scripts/agent-loop-merge-guard.sh b/.github/scripts/agent-loop-merge-guard.sh index 5e4d22674c..16503e0d5f 100755 --- a/.github/scripts/agent-loop-merge-guard.sh +++ b/.github/scripts/agent-loop-merge-guard.sh @@ -111,8 +111,8 @@ pr_has_automerge() { # closed: an unreadable result counts as red. # # release-on-main.yml counts too: green tests do not mean a shippable image -# (#3814-#3816). perf-budget.yml is left out because the boot-size budget it -# exercises is already a required PR check in test-unit.yml. +# (#3814-#3816). perf-budget.yml is left out because boot gzip is gated in +# boot-bundle.yml (+5 KB delta vs main). main_is_red() { local workflow conclusion for workflow in test-unit.yml test-e2e.yml release-on-main.yml; do diff --git a/.github/scripts/detect-code-changes.sh b/.github/scripts/detect-code-changes.sh index 507cc37175..17421c8bc4 100755 --- a/.github/scripts/detect-code-changes.sh +++ b/.github/scripts/detect-code-changes.sh @@ -16,7 +16,7 @@ pull_request_number=${3:-} # # code: TS/CI-relevant changes (excludes docs and apps/calendar-macos/**). # e2e: Playwright-reachable changes (excludes backend, sync, scripts, macos, booking-web). -# static_web: web boot-size budget and other web-blast-radius static work. +# static_web: boot gzip delta check and other web-blast-radius static work. # contracts: bun cli contracts:swift --check (core schemas or swift-contracts emitter). # core/web/backend/sync/scripts: unit-leg filters. packages/core, root package.json, # bun.lock, or root tsconfig* turn every leg on. merge_group and push always run @@ -99,7 +99,7 @@ backend_files=$(printf '%s\n' "$ts_code_files" | grep -E '^packages/backend(/|$) sync_files=$(printf '%s\n' "$ts_code_files" | grep -E '^packages/sync(/|$)' || true) scripts_files=$(printf '%s\n' "$ts_code_files" | grep -E '^packages/scripts(/|$)' || true) static_web_files=$(printf '%s\n' "$ts_code_files" | - grep -E '^(apps/calendar-web(/|$)|\.github/perf/)' || true) + grep -E '^(apps/calendar-web(/|$)|\.github/perf/|\.github/workflows/boot-bundle\.yml)' || true) swift_contract_files=$(printf '%s\n' "$ts_code_files" | grep -E '^packages/scripts/src/swift-contracts/' || true) diff --git a/.github/workflows/boot-bundle.yml b/.github/workflows/boot-bundle.yml new file mode 100644 index 0000000000..b95a1d9d87 --- /dev/null +++ b/.github/workflows/boot-bundle.yml @@ -0,0 +1,120 @@ +name: Boot bundle + +on: + pull_request: + branches: + - main + merge_group: + push: + branches: + - main + paths: + - "apps/calendar-web/**" + - "packages/core/**" + - "bun.lock" + - ".github/perf/**" + - ".github/docker/Dockerfile.web" + - ".github/workflows/boot-bundle.yml" + +permissions: + contents: read + pull-requests: write + +concurrency: + group: boot-bundle-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + changes: + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + static_web: ${{ steps.filter.outputs.static_web }} + steps: + - name: Check out Git repository + uses: actions/checkout@v7 + + - name: Detect web blast-radius changes + id: filter + env: + GH_TOKEN: ${{ github.token }} + run: | + bash .github/scripts/detect-code-changes.sh \ + "${{ github.event_name }}" \ + "${{ github.repository }}" \ + "${{ github.event.pull_request.number }}" + + delta: + needs: changes + if: >- + github.event_name != 'pull_request' || + needs.changes.outputs.static_web == 'true' + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Check out pull request head + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Cache Bun dependencies + uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install Dependencies + run: | + bun install --frozen-lockfile + + - name: Compare boot gzip vs merge base + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + env: + GH_TOKEN: ${{ github.token }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + bun .github/perf/pr-boot-size-delta.ts + + - name: Measure boot gzip on main + if: github.event_name == 'push' + env: + COMPASS_BOOT_SIZE_ENFORCE: "0" + COMPASS_BOOT_SIZE_METRICS_FILE: boot-main-metrics.json + run: | + .github/perf/run-boot-size-check.sh + cat boot-main-metrics.json + + boot-bundle: + needs: [changes, delta] + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Require boot bundle delta when web blast radius changed + env: + EVENT: ${{ github.event_name }} + STATIC_WEB: ${{ needs.changes.outputs.static_web }} + DELTA_RESULT: ${{ needs.delta.result }} + run: | + if [ "$EVENT" = "pull_request" ] && [ "$STATIC_WEB" != "true" ]; then + echo "Skipped: no static web blast radius on this PR." + exit 0 + fi + case "$DELTA_RESULT" in + success) ;; + skipped) + echo "Boot bundle delta job was skipped unexpectedly." >&2 + exit 1 + ;; + *) exit 1 ;; + esac diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 80cd65bacb..983b936c13 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -118,14 +118,6 @@ jobs: run: | bun cli desktop:export --check - # build.ts enforces the boot-size budget. run-boot-size-check.sh matches - # .github/docker/Dockerfile.web (staging-cloud deploy) so PRs fail here - # instead of on Release → staging-cloud (#3814-#3816, #4200-#4201). - - name: Build web bundle (boot-size budget) - if: needs.changes.outputs.static_web == 'true' - run: | - .github/perf/run-boot-size-check.sh - # Workflow files auto-merge under the same size rails as other paths, # so they are linted here. Pinned; the image bundles shellcheck for # run: steps. diff --git a/apps/calendar-web/boot-size-budget.json b/apps/calendar-web/boot-size-budget.json index 34ab6b36e8..fb73667876 100644 --- a/apps/calendar-web/boot-size-budget.json +++ b/apps/calendar-web/boot-size-budget.json @@ -1,7 +1,6 @@ { "chunkCount": 88, "totalGzipBytes": 519500, - "totalGzipSlackBytes": 1024, "packages": { "@phosphor-icons/react": 17995, "@react-oauth/google": 2043, diff --git a/apps/calendar-web/boot-size-report.ts b/apps/calendar-web/boot-size-report.ts index c53fb210c1..d83f4a4b57 100644 --- a/apps/calendar-web/boot-size-report.ts +++ b/apps/calendar-web/boot-size-report.ts @@ -8,9 +8,8 @@ import { gzipSync } from "node:zlib"; export interface BootSizeBudget { chunkCount: number; + /** Informational baseline from main; not enforced on deploy (PR delta gate). */ totalGzipBytes: number; - /** Optional slack on total gzip only (cross-platform gzip variance). */ - totalGzipSlackBytes?: number; packages: Record; } @@ -182,22 +181,9 @@ export function parseBootSizeBudget(raw: unknown): BootSizeBudget { } packages[name] = ceiling; } - const totalGzipSlackBytes = record["totalGzipSlackBytes"]; - if ( - totalGzipSlackBytes !== undefined && - (typeof totalGzipSlackBytes !== "number" || - !Number.isFinite(totalGzipSlackBytes) || - totalGzipSlackBytes < 0) - ) { - throw new Error( - "boot-size-budget.json totalGzipSlackBytes must be a non-negative number when set", - ); - } - return { chunkCount: record["chunkCount"], totalGzipBytes: record["totalGzipBytes"], - totalGzipSlackBytes, packages, }; } @@ -221,19 +207,6 @@ export function bootSizeBudgetViolations( } } - const gzipSlack = budget.totalGzipSlackBytes ?? 0; - const gzipCeiling = budget.totalGzipBytes + gzipSlack; - if (report.totalGzipBytes > gzipCeiling) { - const over = report.totalGzipBytes - gzipCeiling; - const slackNote = - gzipSlack > 0 - ? ` (${budget.totalGzipBytes} B budget + ${gzipSlack} B slack)` - : ""; - violations.push( - `gzip: ${report.totalGzipBytes} B exceeds ceiling ${gzipCeiling} B${slackNote} by ${over} B`, - ); - } - if (report.chunkCount > budget.chunkCount) { violations.push( `chunks: ${report.chunkCount} exceeds ceiling ${budget.chunkCount} by ${report.chunkCount - budget.chunkCount}`, @@ -253,6 +226,12 @@ export async function loadBootSizeBudget( return parseBootSizeBudget(await Bun.file(budgetPath).json()); } +function bootSizeEnforcementEnabled(): boolean { + const raw = process.env.COMPASS_BOOT_SIZE_ENFORCE; + if (raw === undefined || raw === "") return false; + return raw !== "0" && raw.toLowerCase() !== "false"; +} + export async function reportBootSize( outdir: string, metafile: string | object | undefined, @@ -261,6 +240,31 @@ export async function reportBootSize( const report = await computeBootSizeReport(outdir, metafile); // biome-ignore lint/suspicious/noConsole: Preserve build progress output. console.log(formatBootSizeReport(report)); + // biome-ignore lint/suspicious/noConsole: Deploy and PR delta CI scrape this line. + console.log(`boot gzip total: ${report.totalGzipBytes} B`); + + const metricsFile = process.env.COMPASS_BOOT_SIZE_METRICS_FILE; + if (metricsFile) { + await Bun.write( + metricsFile, + JSON.stringify({ + totalGzipBytes: report.totalGzipBytes, + chunkCount: report.chunkCount, + }), + ); + } + + if (!bootSizeEnforcementEnabled()) { + const baseline = budget ?? (await loadBootSizeBudget()); + const delta = report.totalGzipBytes - baseline.totalGzipBytes; + const deltaKb = (delta / 1024).toFixed(1); + const sign = delta >= 0 ? "+" : ""; + // biome-ignore lint/suspicious/noConsole: Informational vs boot-size-budget.json baseline. + console.log( + `boot gzip vs baseline.json: ${sign}${deltaKb} KB (${report.totalGzipBytes} B vs informational ${baseline.totalGzipBytes} B)`, + ); + return []; + } const resolved = budget ?? (await loadBootSizeBudget()); const violations = bootSizeBudgetViolations(report, resolved); diff --git a/apps/calendar-web/src/__tests__/boot-size-report.test.ts b/apps/calendar-web/src/__tests__/boot-size-report.test.ts index f85dfa6e37..d875a907ef 100644 --- a/apps/calendar-web/src/__tests__/boot-size-report.test.ts +++ b/apps/calendar-web/src/__tests__/boot-size-report.test.ts @@ -107,18 +107,17 @@ describe("bootSizeBudgetViolations", () => { expect(violations).toEqual(["zod: 700 B exceeds ceiling 100 B by 600 B"]); }); - it("allows total gzip within declared slack", () => { + it("does not enforce total gzip against the informational baseline", () => { const violations = bootSizeBudgetViolations( { chunkCount: 3, totalRawBytes: 1000, - totalGzipBytes: 516_545, + totalGzipBytes: 999_999, packages: [], }, { chunkCount: 3, totalGzipBytes: 516_500, - totalGzipSlackBytes: 1024, packages: {}, }, ); @@ -126,27 +125,6 @@ describe("bootSizeBudgetViolations", () => { expect(violations).toEqual([]); }); - it("fails total gzip beyond budget plus slack", () => { - const violations = bootSizeBudgetViolations( - { - chunkCount: 3, - totalRawBytes: 1000, - totalGzipBytes: 517_600, - packages: [], - }, - { - chunkCount: 3, - totalGzipBytes: 516_500, - totalGzipSlackBytes: 1024, - packages: {}, - }, - ); - - expect(violations).toEqual([ - "gzip: 517600 B exceeds ceiling 517524 B (516500 B budget + 1024 B slack) by 76 B", - ]); - }); - it("fails packages that have no declared ceiling", () => { const violations = bootSizeBudgetViolations( { diff --git a/docs/CI-CD/workflows.md b/docs/CI-CD/workflows.md index bf09d9eb44..64b0af4b0c 100644 --- a/docs/CI-CD/workflows.md +++ b/docs/CI-CD/workflows.md @@ -103,10 +103,10 @@ Local `bun test:web` still runs every shard sequentially. the second half. On pull requests, `detect-code-changes.sh` also emits per-package outputs -(`core`, `web`, `backend`, `sync`, `scripts`), plus `static_web` (web -boot-size budget) and `contracts` (generated Swift contract drift check). -A backend-only PR still runs `static` and the backend leg, and skips the -web runners, boot-size, and contracts check. Changes under +(`core`, `web`, `backend`, `sync`, `scripts`), plus `static_web` (boot gzip delta vs `main`) and `contracts` (generated +Swift contract drift check). A backend-only PR still runs `static` and the +backend leg, and skips the web runners, boot bundle check, and contracts +check. Changes under `packages/core`, or to root `package.json`, `bun.lock`, or `tsconfig*.json`, turn every leg on. PRs limited to `apps/calendar-macos/**` set every output false so Unit and E2E roll up as skipped Success while diff --git a/docs/development/performance-baselines.md b/docs/development/performance-baselines.md index 7e909f0295..609af8b0dd 100644 --- a/docs/development/performance-baselines.md +++ b/docs/development/performance-baselines.md @@ -65,23 +65,33 @@ Read the report as: `node_modules/` path segment (Bun's isolated linker layout). First-party sources are omitted. Zod's `v4/locales` share is noted when present. -Ceilings live in `apps/calendar-web/boot-size-budget.json`: per-package minified -bytes, total gzip, optional `totalGzipSlackBytes` (cross-platform gzip variance -only), and chunk count. The build exits non-zero when any of those is exceeded, -and the message names the package (or gzip/chunks) and the delta. Required CI -runs `.github/perf/run-boot-size-check.sh`, which matches -`.github/docker/Dockerfile.web` for cloud deploy (not `compass.perf.yaml`, -which is for Lighthouse). Headroom is 5% above the WP-13 measurement so later -boot-weight work can land without the gate failing first. - -**Lower a ceiling when you remove that weight from the boot path** (the -acceptance for WP-14, WP-15, and WP-16). Run `bun run build:web`, copy the -new actual into the JSON, and keep a little headroom only if the number -still jitters across machines. Do not raise a ceiling to make a -regression pass; fix the import or the split instead. When a new npm -package appears on the boot path, add a ceiling for it in the same change. -When a package leaves the boot path, delete its key so the file only -lists what boot still loads. +Reference ceilings live in `apps/calendar-web/boot-size-budget.json`: +per-package minified bytes, an informational `totalGzipBytes` baseline from +`main`, and chunk count. Local `bun run build:web` prints the report and +logs gzip vs that baseline; it does not fail on absolute gzip size. + +Cloud deploy builds run `.github/perf/run-boot-size-check.sh` from +`.github/docker/Dockerfile.web` with `COMPASS_BOOT_SIZE_ENFORCE=0`, so +staging and production image builds log boot gzip and never fail on an +absolute ceiling. + +Required PR CI is [`.github/workflows/boot-bundle.yml`](../../.github/workflows/boot-bundle.yml): +it builds merge base and the PR with the same deploy-shaped config as +`.github/perf/run-boot-size-check.sh`, posts one sticky comment (for example +`Boot bundle: 519.8 KB gzip (+0.4 KB vs main)`), and fails only when gzip +grows more than **+5 KB** vs `main` in a single PR. Add the `bundle-size-ok` +label to allow intentional growth above that delta. + +When `COMPASS_BOOT_SIZE_ENFORCE=1`, the build still enforces per-package +minified ceilings and chunk count from the JSON (gzip baseline is never +enforced). Use that only when tightening package budgets locally. + +**Lower a per-package ceiling when you remove that weight from the boot path** +(WP-14 through WP-16). Run `bun run build:web`, copy the new actual into +the JSON. When a new npm package appears on the boot path, add a ceiling in +the same change. When a package leaves the boot path, delete its key. +Refresh the informational `totalGzipBytes` on `main` when you land a +deliberate boot-weight change so the local baseline line stays honest. The CI `perf-budget` Lighthouse job remains the transfer gate on ubuntu and is calibrated from CI, never from a laptop. This report is the local, diff --git a/packages/scripts/src/testing/detect-code-changes.test.ts b/packages/scripts/src/testing/detect-code-changes.test.ts index d4491dbd8a..5b69f81e8e 100644 --- a/packages/scripts/src/testing/detect-code-changes.test.ts +++ b/packages/scripts/src/testing/detect-code-changes.test.ts @@ -88,10 +88,11 @@ function flags(values: Partial>) { } describe("detect-code-changes", () => { - it("is the single detector used by both required-check workflows", () => { + it("is the single detector used by required-check workflows", () => { for (const workflowPath of [ ".github/workflows/test-unit.yml", ".github/workflows/test-e2e.yml", + ".github/workflows/boot-bundle.yml", ]) { const workflow = readFileSync(workflowPath, "utf8"); @@ -199,14 +200,18 @@ describe("detect-code-changes", () => { ); }); - it("gates boot-size and contracts checks on static_web and contracts outputs", () => { + it("gates boot bundle delta and contracts checks on static_web and contracts outputs", () => { + const bootBundle = readFileSync( + ".github/workflows/boot-bundle.yml", + "utf8", + ); const unit = readFileSync(".github/workflows/test-unit.yml", "utf8"); - expect(unit).toContain( + expect(bootBundle).toContain( "static_web: ${{ steps.filter.outputs.static_web }}", ); + expect(bootBundle).toContain("needs.changes.outputs.static_web == 'true'"); expect(unit).toContain("contracts: ${{ steps.filter.outputs.contracts }}"); - expect(unit).toContain("if: needs.changes.outputs.static_web == 'true'"); expect(unit).toContain("if: needs.changes.outputs.contracts == 'true'"); }); diff --git a/self-host/docker-compose.test.ts b/self-host/docker-compose.test.ts index c1ac0914bb..267c77703f 100644 --- a/self-host/docker-compose.test.ts +++ b/self-host/docker-compose.test.ts @@ -696,15 +696,17 @@ describe("staging deploy workflow", () => { }); it("runs the boot-size check with the same deploy-shaped config as Dockerfile.web", () => { - const unit = readRepoFile(".github/workflows/test-unit.yml"); + const bootBundle = readRepoFile(".github/workflows/boot-bundle.yml"); const bootConfig = readRepoFile(".github/perf/compass.boot-size.yaml"); const dockerfile = readRepoFile(".github/docker/Dockerfile.web"); - expect(unit).toContain(".github/perf/run-boot-size-check.sh"); + expect(bootBundle).toContain(".github/perf/run-boot-size-check.sh"); expect(bootConfig).toContain("nodeEnv: staging"); expect(bootConfig).toContain("posthog:"); expect(dockerfile).toContain("nodeEnv: ${NODE_ENV}"); expect(dockerfile).toContain("'posthog:'"); + expect(dockerfile).toContain(".github/perf/run-boot-size-check.sh"); + expect(dockerfile).toContain("COMPASS_BOOT_SIZE_ENFORCE=0"); }); it("builds cloud deploy web images from a GitHub-only Dockerfile with PostHog config", () => {