Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/actions/setup-lighthouse-chromium/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Setup pinned Chromium for Lighthouse
description: >
Install Playwright's managed Chromium and export PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, so every
Lighthouse job in this repository measures with the same browser build for a given commit.

# The Lighthouse budget used to drive whatever Chrome ships in the ubuntu-24.04 runner image
# (matching live-web-vitals.yml, which measures a live domain and isn't graded against a
# committed baseline). That ambient browser is NOT pinned per commit — the runner fleet was
# observed serving HeadlessChrome/150 and /151 to jobs minutes apart on 2026-08-07 — so the
# baseline comparator treated the version drift as "evidence incomplete" and failed closed
# (by design: a browser bump is otherwise indistinguishable from a real regression).
# Refreshing the baseline for whichever version showed up bought a few hours before the next
# mismatched runner failed it again (#1690).
#
# Pin Playwright's own managed Chromium instead, the same binary the Production UI / Visual
# baselines jobs already use, cached by lockfile hash so it is identical across every runner
# for a given commit. run-lighthouse-budget.mjs already honours
# CHROME_PATH/PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH.
#
# This lives in a composite action rather than being duplicated per job for one reason: the
# measuring job and the baseline-refresh job MUST resolve the same executable. If they drift,
# the refreshed baseline records a browser other than the one that will grade against it, and
# the gate goes permanently red again — exactly the failure this action exists to end.

runs:
using: composite
steps:
- name: Restore Chromium browser cache
id: pw-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-chromium-${{ runner.os }}-

- name: Install Chromium
shell: bash
run: |
if [ "${{ steps.pw-cache.outputs.cache-hit }}" = "true" ]; then
npx playwright install-deps chromium
npx playwright install chromium
else
npx playwright install --with-deps chromium
fi

- name: Pin the Chromium executable path
shell: bash
run: echo "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$(node -e "console.log(require('playwright').chromium.executablePath())")" >> "$GITHUB_ENV"
163 changes: 128 additions & 35 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
workflow_dispatch:
inputs:
refresh_lighthouse_baseline:
description: >
Re-measure the budgeted routes on this runner's PINNED Chromium and upload a refreshed
lighthouse-budget.json as an artifact for a human to review and commit. The baseline is a
browser-specific artefact, so it can only be produced on a runner — never from a developer
machine. Nothing is committed or pushed by this run.
type: boolean
default: false
schedule:
- cron: "0 18 * * 0"

Expand Down Expand Up @@ -39,6 +48,7 @@ jobs:
static_heavy_changed: ${{ steps.scope.outputs.static_heavy_changed }}
coverage_changed: ${{ steps.scope.outputs.coverage_changed }}
ui_changed: ${{ steps.scope.outputs.ui_changed }}
perf_changed: ${{ steps.scope.outputs.perf_changed }}
advisory_ui_changed: ${{ steps.scope.outputs.advisory_ui_changed }}
db_changed: ${{ steps.scope.outputs.db_changed }}
container_changed: ${{ steps.scope.outputs.container_changed }}
Expand Down Expand Up @@ -706,7 +716,62 @@ jobs:
lighthouse-budget:
name: Lighthouse budget (advisory)
needs: changes
if: needs.changes.outputs.ui_changed == 'true' || needs.changes.outputs.build_changed == 'true'
# Runs when the diff can plausibly move LCP/TBT/CLS on the five budgeted routes,
# and when the run is one whose result someone will act on. It used to key off
# `ui_changed || build_changed`, which put every dependabot lockfile bump (#1668)
# and every worker/** ingestion change through a ~7 minute isolated production
# build plus ten Lighthouse runs, with zero render-path relevance.
#
# pull_request – perf scope, non-draft. Drafts churn and this costs ~7 min;
# `types:` already includes ready_for_review, so undrafting
# re-triggers it without a push.
# merge_group – SKIPPED. The job is continue-on-error and is not in
# pr-required, so in the queue it can only add ~7 minutes of
# merge latency and can never change the outcome. If it is
# ever promoted (#118), merge_group MUST come back —
# tests/ci-cache-safety.test.ts enforces that pairing.
# push (main/…) – perf scope, PLUS lockfile_changed. The lockfile arm is the
# backstop for a runtime dependency bump: perf scope excludes
# package.json / package-lock.json because ci-change-scope.mjs
# sees paths, not lockfile contents, so without this arm a
# lockfile-only push would skip Lighthouse on both the PR and
# the subsequent push to main and leave only the weekly schedule.
# schedule – weekly; resolves to the full-run sentinel, which trips perf
# scope (asserted in ci-change-scope.mjs's self-tests).
# workflow_dispatch – always, except a run dispatched to refresh the baseline
# (that belongs to lighthouse-baseline-refresh below).
#
# Labels mirror the codex-review / skip-codex-review convention; an explicit skip
# always wins. `on.pull_request.types` has no `labeled`, so the opt-in label takes
# effect on the next synchronize or re-run — adding `labeled` would re-run all of
# CI on every label change, so the escape hatch is workflow_dispatch instead.
#
# Two expression traps, both load-bearing:
# - `github.event.inputs.X` is a STRING ('true'/'false') and is safely null on
# non-dispatch events; the `inputs` context is a real boolean but only exists
# for workflow_dispatch/workflow_call. Use the github.event.inputs form.
# - `github.event.pull_request` is null on push/schedule/merge_group, so
# `draft != true` is true there. Do NOT write `== false`: that is false on
# every non-PR event and would silently kill the push and schedule arms.
if: >
(
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
contains(github.event.pull_request.labels.*.name, 'lighthouse-budget') ||
(
(
needs.changes.outputs.perf_changed == 'true' ||
(
github.event_name == 'push' &&
needs.changes.outputs.lockfile_changed == 'true'
)
) &&
github.event_name != 'merge_group' &&
github.event.pull_request.draft != true
)
) &&
!contains(github.event.pull_request.labels.*.name, 'skip-lighthouse-budget') &&
github.event.inputs.refresh_lighthouse_baseline != 'true'
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 45
Expand All @@ -719,40 +784,8 @@ jobs:
- name: Setup Node and dependencies
uses: ./.github/actions/setup-node-cached

# This job used to drive whatever Chrome ships in the ubuntu-24.04 runner image
# (matching live-web-vitals.yml, which measures a live domain and isn't graded
# against a committed baseline). That ambient browser is NOT pinned per commit —
# the runner fleet was observed serving HeadlessChrome/150 and /151 to jobs
# minutes apart on 2026-08-07 — so every grading run this job's baseline
# comparator treats a version drift as "evidence incomplete" and fails closed
# (by design: a browser bump is otherwise indistinguishable from a real
# regression). Refreshing the baseline for whichever version showed up bought a
# few hours before the next mismatched runner failed it again (#1690).
#
# Pin Playwright's own managed Chromium instead, the same binary the Production
# UI / Visual baselines jobs already use, cached by lockfile hash so it is
# identical across every runner for a given commit. run-lighthouse-budget.mjs
# already honours CHROME_PATH/PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH.
- name: Restore Chromium browser cache
id: pw-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-chromium-${{ runner.os }}-

- name: Install Chromium
run: |
if [ "${{ steps.pw-cache.outputs.cache-hit }}" = "true" ]; then
npx playwright install-deps chromium
npx playwright install chromium
else
npx playwright install --with-deps chromium
fi

- name: Pin the Chromium executable path
run: echo "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=$(node -e "console.log(require('playwright').chromium.executablePath())")" >> "$GITHUB_ENV"
- name: Setup pinned Chromium
uses: ./.github/actions/setup-lighthouse-chromium

- name: Measure routes and grade against the baseline
run: npm run verify:lighthouse -- --keep --dir lighthouse
Expand All @@ -765,6 +798,66 @@ jobs:
path: lighthouse/
if-no-files-found: ignore

# Refresh the committed Lighthouse baseline FROM A CI RUNNER. Dispatch-only.
#
# The baseline is a browser-specific artefact: check-lighthouse-budget.mjs fails
# closed when a baseline row's chromeVersion differs from the measuring run's,
# because a browser bump is otherwise indistinguishable from an application
# regression. So the numbers must come from the same pinned Chromium the grading job
# uses, which a developer machine cannot supply (#118: never commit baselines from a
# developer machine). This job produces the file; a human reviews the diff and
# commits it. That division is the point — a workflow that can rewrite a gate's own
# baseline is a gate that can green itself.
#
# Deliberately NOT continue-on-error: a refresh that measured nothing must go red.
lighthouse-baseline-refresh:
name: Refresh Lighthouse baseline (dispatch only)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.refresh_lighthouse_baseline == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup Node and dependencies
uses: ./.github/actions/setup-node-cached

- name: Setup pinned Chromium
uses: ./.github/actions/setup-lighthouse-chromium

# --update refuses on a measurement gap (incompleteBudgetEvidence with
# ignoreBaseline:true still blocks a missing or unusable report) and accepts
# browser drift, which is the reason to refresh. --keep retains the raw reports
# so the artifact carries evidence, not just a conclusion.
- name: Measure and rewrite the baseline
run: npm run verify:lighthouse -- --update --keep --dir lighthouse

# --update returns before rendering the table, so without this nothing describes
# what was recorded. Re-grading the same reports against the just-written
# baseline yields zero deltas by construction: this RENDERS the recorded numbers
# into the step summary, it does not verify them.
- name: Render the recorded baseline into the run summary
run: npm run check:lighthouse-budget -- --dir lighthouse

# The acceptance check for this job: exactly one distinct browser, and it is the
# pinned one. More than one line here means the refresh is not usable.
- name: Show the baseline diff and the browser it was measured on
run: |
git --no-pager diff --stat -- lighthouse-budget.json
node -e "const b=require('./lighthouse-budget.json');console.log([...new Set(Object.values(b.baseline??{}).map(r=>r.chromeVersion))].join('\n'))"
Comment thread
BigSimmo marked this conversation as resolved.

- name: Upload the refreshed baseline
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: lighthouse-baseline-refresh-${{ github.run_id }}
path: |
lighthouse-budget.json
lighthouse/
if-no-files-found: error

db-reset-verify:
name: Migration replay
needs: changes
Expand Down
26 changes: 23 additions & 3 deletions docs/process-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,30 @@ Three gates added for the "mature repo" verification pass. Full usage is in
- **The performance budget is relative, not absolute.** `lighthouse-budget.json` holds a committed
per-route baseline and a per-metric tolerance, following `check:bundle-budget`. Absolute
web-vitals thresholds are meaningless against localhost with no network latency. `enforce` starts
`false` with no baseline; incomplete evidence fails regardless of `enforce`.
`false`; incomplete evidence fails regardless of `enforce`.
- **The baseline is browser-specific, and the browser is now pinned (2026-08-08).** Each baseline row
records the `chromeVersion` that produced it, and a mismatch fails closed — a browser bump is
otherwise indistinguishable from an application regression. The ambient runner-image Chrome is not
pinned per commit (the fleet served HeadlessChrome/150 and /151 to jobs minutes apart on
2026-08-07), so both Lighthouse jobs resolve Playwright's managed Chromium through one shared
composite action, `./.github/actions/setup-lighthouse-chromium`. Refreshing is a `workflow_dispatch`
input (`refresh_lighthouse_baseline`) that uploads a rewritten `lighthouse-budget.json` for a human
to review and commit; it deliberately cannot push, because a workflow that rewrites a gate's own
baseline is a gate that can green itself.
- **The budget runs on perf scope, not the ui/build union (2026-08-08).** `perf_changed` in
`scripts/ci-change-scope.mjs` excludes `worker/**` and container surfaces, dependency manifests and
the lockfile, Playwright/test surfaces, most of `src/app/api/**` (except the initial-load handlers
`/api/setup-status` and `/api/local-project-id`), and `src/app/mockups/**`. `src/proxy.ts` stays in
scope because it runs before every budgeted navigation. The lockfile exclusion is the one
deliberate hole on the PR arm: the classifier sees paths, never the lockfile diff, so it cannot
tell a devDependency bump from a React/Next bump. The push-to-`main` arm re-runs when
`lockfile_changed` is true, and `check:bundle-budget` still covers the same PRs. If a runtime dep
bump ever lands an unnoticed LCP regression despite that, teach the classifier to read the lockfile
diff — do not put the whole lockfile back into perf scope.
- **Verification debt remaining.** (1) No pixel baselines are committed, so `visual-baseline` reports
rather than gates until an operator adopts them from the CI artifact. (2) No Lighthouse baseline is
recorded, so the budget warns rather than grades until `--update` runs against a known-good build.
rather than gates until an operator adopts them from the CI artifact. (2) A Lighthouse baseline is
committed, but `enforce` stays `false` until #147 (`/dsm` mobile CLS 0.363) and #117 are resolved —
enforcing today would ratify those breaches.
(3) 37 of the 38 unlayered visual classes carry exemptions rather than effect contracts — the debt
is enumerated in `STYLE_CONTRACT_EXEMPTIONS`, and ledger #094 stays open until the load-bearing
ones have contracts. (4) `scripts/run-lighthouse-budget.mjs` duplicates roughly 50 lines of the
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts-index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Scripts index

Curated map of `scripts/` (218 files) and the `package.json` script surface (231 entries),
Curated map of `scripts/` (219 files) and the `package.json` script surface (231 entries),
grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative
command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run <x>`
referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above.
Expand Down
Loading
Loading