Date: 2026-08-09 Status: Accepted
Context
This repository feeds four Vercel projects from one tree:
| project | Root Directory | ignoreCommand before this change |
|---|---|---|
erid-apex |
apps/erid-apex |
none |
erid-coming-soon |
apps/erid-coming-soon |
none |
erid-docs |
apps/erid-docs |
none |
erid-cairnet |
cairnet |
none |
Every push therefore rebuilt all four, whatever changed. That was defensible while build minutes were assumed to scale with build duration. They do not.
Vercel bills build CPU as
cores x ceil(wall minutes), with a one-minute floor
so a deployment that compiles nothing still bills a full 4 core-minutes on a
4-core machine, and only a CANCELED deployment is free — a fast READY one
is billed in full. Deployment count is the cost driver, not duration. This was
measured across a 47-project fleet on 2026-08-09; erid-docs and erid-apex
each land on exactly 10 deployments x 4 cores = 40 billed minutes for the cycle,
which is what quantisation on the core count looks like.
One commit in this repo's own history is the argument. a83e407,
"chore(console): bump to 1ddf1fb", changes exactly one path — the console
submodule pointer, which no application in this tree reads. It deployed
erid-docs twice (two branches, same SHA): 8 billed core-minutes for a
pointer move. rocky-console is its own Vercel project and builds from its own
repo; the erid four had no business rebuilding.
Decision
Add one shared Ignored Build Step, scripts/vercel-ignore.sh, and wire it into
all four projects via an ignoreCommand in each Root Directory's vercel.json.
The script exits 0 to skip and 1 to build, and every uncertain branch builds. It
is a direct port of the same guard already proven in production in
bhava-blue/bhava-blue and petrova-codes/petrova, deliberately copied rather
than re-derived — see Two traps below for what re-deriving costs.
Build inputs are not Root Directories
Each app here is self-contained: its own package.json and package-lock.json,
no root workspace, and no src/ importing across its own boundary. So for three
of the four the predicate really is just the project's own directory.
erid-docs is the exception, and it is why this list is written by hand rather
than generated from the Root Directory settings. apps/erid-docs/src/lib/ledger.ts
does:
const ROOT = join(process.cwd(), '..', '..');
const DECISIONS_DIR = join(ROOT, 'docs', 'decisions');
const SPECS_DIR = join(ROOT, 'docs', 'specs');
and src/app/[...slug]/page.tsx calls generateStaticParams() over the result,
so the repo-root ledger is baked in at build time. docs/decisions/ and
docs/specs/ are erid-docs build inputs. Measured over 203 non-merge commits,
a naive apps-only predicate would have skipped 56 of them wrongly — 28% of
all history quietly serving a stale ledger. Note also that listMarkdown
swallows a read failure with catch { return [] }, so a wrong skip would have
rendered as an empty ledger rather than as a build error. This document exists
in the directory that this app publishes; the guard has to know that.
console, ralph, regolith, atlas, hearth, lore and design-tokens
are deliberately absent from every path list — nothing here reads them at build
time. lore/ carries its own vercel.json but has no Vercel project in the
account, so it is nothing's input.
Two traps, and they fail in opposite directions
Fail open — merge commits. git show --name-only on a merge commit is
empty. A predicate written against the head commit therefore sees no changed
files and skips the merge that shipped the change. Nearly half of this repo's
main-branch commits are merges, so a from-scratch guard would have stopped
deploying real work. The script diffs base..head instead, which sees through
merges: on cce982d ("Merge pull request #123"), git show yields nothing
while git diff parent1..merge correctly yields cairnet/src/middleware.ts.
Fail closed — the shallow clone. VERCEL_GIT_PREVIOUS_SHA is the last
deployed commit, not the previous commit in the log, so the better the guard
works the further back that SHA sits — and Vercel's clone is shallow. Without
deepening, a correctly fail-closed guard builds every time and skips nothing.
This was observed in petrova-codes production as base 6d920c4 not in clone
and took three attempts to close there. The script unshallows (falling back to
--deepen=250) before giving up.
Verification
13 cases replayed against real commits in this repository, before merge:
a83e407(console pointer only) → SKIP on all four. The commit that motivated the whole change.55536a6(docs/decisionschange) → BUILD onerid-docs, SKIP onerid-apex. The input-set discrimination working in both directions.cce982d(merge touchingcairnetonly) → BUILD oncairnet, SKIP on the other three, withgit show --name-onlyempty for that commit.- Unknown app, no argument,
base == head, and a bogus base SHA → BUILD.
Sized over 203 non-merge commits, using the corrected input sets:
| project | commits touching its inputs | would skip |
|---|---|---|
erid-apex |
11 / 203 | 192 (94%) |
erid-coming-soon |
10 / 203 | 193 (95%) |
erid-cairnet |
15 / 203 | 188 (92%) |
erid-docs |
66 / 203 | 137 (67%) |
No realised reduction is claimed. That table is the predicate's decision over commits that already happened; future commit mix is not obliged to resemble it, and per-commit replay is not per-deployment billing — Vercel deploys per push, and several of these commits arrive in one push. The number that matters comes off the Vercel dashboard a cycle from now, and a production SKIP is not proven until CANCELED deployments are observed. An ignore step that is present and runs is not the same as one that skips.
Consequences
- Four projects stop rebuilding on changes they do not read. The
consolesubmodule bump, theralph/regolithtrees, and every root-level document outsidedocs/decisionsanddocs/specsnow cost nothing. docs/decisions/anddocs/specs/become load-bearing for a build, not only for governance. Anyone moving or renaming those directories must updateapps/erid-docs/src/lib/ledger.tsand this guard together.- Adding a fifth Vercel project to this repo now requires a
casearm inscripts/vercel-ignore.sh. The unknown-app branch builds, so forgetting is safe — it costs money, not correctness.