Skip to content

Preview builds were never guarded, and the fix is the fork point

Date: 2026-08-31 Status: closed Supersedes: none Superseded-by: none — current

The Vercel build-cost campaign records this repo as WAVE-1-COMPLETE: path guards shipped for petrova-blog and petrova-codes on 2026-08-09 and were verified in production. That verification was performed on the production branch only.

Measured on 2026-08-31 against the Aug 20–31 billing cycle, this repo’s three projects are 49.9% of the whole Vercel account — petrova-host 23h52m (29.7%), petrova-blog 10h56m (13.6%), petrova-codes 5h20m (6.6%). Two facts explain it, and only one of them was known.

The known one holds up: replaying the guard predicate over the 265 non-merge commits on main since 2026-08-20, both surfaces skip 265 of 265. The guard works exactly as designed on the production branch.

The unknown one is scripts/vercel-ignore.sh:48. A guard needs a base commit; VERCEL_GIT_PREVIOUS_SHA is the last deployed commit, and a branch that has never deployed has none. The script then falls to HEAD^, and inside Vercel’s shallow clone that frequently does not resolve either — so it takes the fail-closed exit and builds. Every first push to every branch therefore built every surface, for the life of the guard.

Observed rather than inferred, from list_deployments: branch chore/sync-playbook-from-eva produced READY builds of both petrova-host and petrova-blog at four separate SHAs, and fix/g3-projection-and-registration-drift built both again — while the same window’s main sweeps were correctly canceled.

The fail-closed exit is not a defect; F-01 exists because the opposite default shipped full builds of markdown-only commits elsewhere in the fleet. The defect is that “no base” was treated as an uncertainty when for a preview it is a known quantity: the fork point.

On a non-main ref with no VERCEL_GIT_PREVIOUS_SHA, deepen the clone and resolve the base as git merge-base origin/main $head. If that fails, fall through to the existing HEAD^ attempt, and if that fails too, take the existing fail-closed exit. No fail-closed path is removed: unknown surface, unreachable base, base == head and a failed git diff all still build.

This changes the question a preview asks from “did my last commit touch my inputs” to “does this branch touch my inputs”, which is the honest question for a surface that has never deployed from that ref.

Separately and in the same diff, cli/src/schema-fingerprint.ts is added to the codes input list. codes/scripts/pull-content.ts:30 imports fingerprintSchemaText from it at prebuild, so it is a genuine build input that the path list omitted; the module imports only node:crypto, so the addition is exactly one file and not the cli/ tree. This is a correctness fix that makes the guard build more often, and it is recorded here so it is not later read as a cost change.

  • Kill previews on the affected branches (a ref guard) — rejected. No workflow in this repo runs a production build of the host surface on pull_request; integration-spine-ci.yml is paths:-gated to the CLI and host test suite. The Vercel preview is the only thing compiling these surfaces before main, so removing it would trade money for defects.
  • Use HEAD~n with a fixed depth — rejected. It guesses the branch length, and guesses wrong on exactly the long-lived branches that cost the most.
  • Leave it and accept the preview tax — rejected on the measurement. Under the billing model established by the campaign (cores × ceil(wall-minutes), one-minute floor, only CANCELED free), each of those preview builds bills a full minimum.
  • Preview builds now skip when a branch touches no build inputs of that surface. No production behaviour changes; main takes an unchanged path through the script.
  • A surface whose branch legitimately changes its inputs still builds on every push.
  • The saving is not claimed here. What is claimed is the predicate’s decision over history, and the realised number comes off the usage dashboard at the next checkpoint, which no agent can read (F-09).

Seven cases against real commit ranges in a scratch repo of the same shape, before wiring:

caseexpectedgot
preview, prose-only branch, blogskipskip
preview, site/ changed, blogbuildbuild
preview, site/ changed, codesskipskip
preview, cli/src/schema-fingerprint.ts changed, codesbuildbuild
unknown surfacebuildbuild
base == headbuildbuild
main ref, no previous SHAunchanged HEAD^ pathunchanged

Production evidence is deferred to the commit that records it: this decision doc touches no surface’s build inputs, so landing it is itself the skip test.

Live falsifier: throwaway branch throwaway/preview-guard-falsifier, one commit touching only README.md (not a host build input), pushed and deleted after. Result: dpl_BaeLDePZPqJWzkBTi4mkKxXWGhAt, state: CANCELED — the fork-point guard skipped it correctly, in production, five days after this doc’s own commit-message proof.