Preview builds were never guarded, and the fix is the fork point
Date: 2026-08-31 Status: closed Supersedes: none Superseded-by: none — current
Context
Section titled “Context”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.
Decision
Section titled “Decision”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.
Alternatives considered
Section titled “Alternatives considered”- 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.ymlispaths:-gated to the CLI and host test suite. The Vercel preview is the only thing compiling these surfaces beforemain, so removing it would trade money for defects. - Use
HEAD~nwith 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.
Consequences
Section titled “Consequences”- Preview builds now skip when a branch touches no build inputs of that surface.
No production behaviour changes;
maintakes 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).
Verification
Section titled “Verification”Seven cases against real commit ranges in a scratch repo of the same shape, before wiring:
| case | expected | got |
|---|---|---|
| preview, prose-only branch, blog | skip | skip |
preview, site/ changed, blog | build | build |
preview, site/ changed, codes | skip | skip |
preview, cli/src/schema-fingerprint.ts changed, codes | build | build |
| unknown surface | build | build |
base == head | build | build |
main ref, no previous SHA | unchanged HEAD^ path | unchanged |
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.
Re-verified 2026-09-04
Section titled “Re-verified 2026-09-04”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.
Sign-off
Section titled “Sign-off”- subagent: claude-sonnet-5, re-verification above
- human: alex@devarno.com