ADR-0107: Deploy-gate relocation — retire the merge queue, gate deploys on green main
Date: 2026-07-21 Status: Accepted
Context
Since 2026-07-14 (see the cost-model history in .github/workflows/ci.yml),
the GitHub merge queue was the authoritative full-suite gate: PRs ran a capped
affected subset, every queue entry ran the full sharded suite on the merged
tree, and push:main ran no tests. Measured reality after a week:
- The queue never batched. 66 CI trains for 63 merged PRs on Jul 21
(~1 PR/train). With
min_entries_to_merge: 1the queue merges each PR the moment it is green, and PRs arrive (~every 5–10 min) slower than the queue drains (~4–6 min), so it is almost always empty. Deeper: with theALLGREENstrategy GitHub runs one full speculative CI per queued PR regardless of grouping — batching config changes when PRs merge, not how many suite runs are paid for. The queue could never deliver the batching it was adopted for. - It doubled the author-blocking path. A green PR waited serially on a second full-suite run (4–13 min wall, 15-min check timeout) before landing. Median time-to-merge ~20+ min; "80% of my time is deploying" (Gera), "$3k/mo trending, want <$1k" (Fede).
- Its catches were flakes. Of 434 merge-queue CI runs Jul 15–21, failures clustered in two storm days (18 on Jul 15, 24 on Jul 17 — the repo-split day) matching known full-suite-only flakes; other days ran 0–3. Each failure ejected + re-queued a PR (another full run) and rebuilt everything behind it. Genuine cross-PR semantic catches: ≈0.
- Cost: ~63 full-suite runs/day ≈ 2,000+ runner-min/day, ~25% of the Actions bill (July ran 108% of its $200 budget by the 21st).
The obvious "just drop the merge-queue run — the branch already tested it"
is unsafe here: PR CI runs the capped affected subset, and hub-module
PRs (selection > AFFECTED_CAP) skip PR-level testing entirely, deferring to
the queue. Deleting the queue with no replacement ships those PRs to prod
with zero test coverage until nightly. And un-capping the PR run is the
already-measured 2026-06-20 model that cost $1k+/mo.
Decision
Move the full-suite gate from in front of every merge to in front of deploys. Same protection for production, zero toll on merging:
- Merges are instant. The merge queue is removed from the
mainruleset (andstrictup-to-date checking stays off). A PR merges the moment its PR checks are green. push:mainruns the authoritative full 12-shard suite, plus type-check, coalesced by a concurrency group keyed on(workflow, event_name, ref)withcancel-in-progress: true: a burst of N merges cancels the superseded runs and one run tests the tree containing all N — this is the batching the queue was supposed to provide, with no train bot and no hourly clock. Expected ~15–25 authoritative runs/day vs the queue's ~63 + requeues (~60–70% cut of that bucket).- Deploys gate on that run's conclusion:
- Vercel prod:
scripts/vercel-ignore.shno longer auto-builds main; thevercel-promotejob in ci.yml creates the production deployment via the Vercel API only after the suite is green. (Verified empirically 2026-07-21: API-created git deployments bypass theignoreCommand, so the skip cannot deadlock the promote.) Prod web builds also drop to one per green run instead of one per merge. - Lambdas + Fargate worker: the
await-main-cicomposite action (.github/actions/await-main-ci) blocks each deploy's gate job until the push:main run for that SHA — or, when coalescing cancelled it, the newest superseding main run — concludes, and fails loud on red.workflow_dispatchdeploys skip the wait (dispatch is the human override). This flips the premise of the PR #4157 rejection: gating on push:main was "always-true theater" only because that run executed no tests; now it is the authoritative gate.
- Vercel prod:
- A red main alerts (
alert-main-red→ Slack#alerts) — deploys hold automatically, a human reverts or fixes forward. Expected rarely: the only new exposure class is a hub-PR failure that the queue would have caught pre-merge, and the week's data says those were ~all flakes. - The group-diff subset machinery dies with the queue —
select-tests,plan-selected,test-selected,scripts/ci/select-tests*.mjs, thevercel-merge-group-stub, and the merge_group stubs inci-nested.yml/claude-code-review.ymlare deleted (one source of truth; the machinery only ever ran on merge_group events).
Cutover order (corrected by bot review on #4303): flip the ruleset
BEFORE merging this PR. A merge-group run evaluates workflow files on the
merge-group ref — which contains this PR's deletion of every merge_group
trigger, so none of the required contexts (Build, Type Check,
Unit Tests, review, Nested Gate) would ever report inside the queue;
the entry would stall to check_response_timeout and get ejected. The PR
cannot ride the queue it deletes. Sequence: (1) API: remove the
merge_queue rule from ruleset "Require CI", drop the Vercel required
context (previews are opt-in — with the queue's stub gone, a non-[preview]
PR would block forever on it), keep Build / Type Check / Unit Tests /
review / Nested Gate, strict: false (requiring up-to-date branches
would reintroduce the re-run treadmill; the post-merge run covers stale-base
drift). (2) Merge this PR directly on green PR checks. Between (1) and (2)
merges are briefly un-queued while main still has the old ci.yml — its
push:main run is the old no-op, and deploys in that window behave exactly as
before the relocation (ungated), which is the status quo, not a regression.
Consequences
- Author-blocking time after a green PR: 5–28 min + flake roulette → seconds.
- Full-suite runs: ~63+/day → ~15–25/day coalesced (est. ~$200/mo saved on the queue bucket); Vercel prod builds drop the same way.
- One flaky test no longer blocks anyone's merge — it only delays deploys.
maincan be red for ~10 min after a bad merge (deploys hold; prod never exposed). Revert hygiene replaces pre-merge blocking. Watch red-main minutes for the first weeks; if genuine (non-flake) red-main events are frequent, the fallback is tightening PR-level coverage for hub paths — not resurrecting the queue.- Hotfix path stays fast: merge instantly → one green main run (~8–12 min) → deploys fire.
- Docs-only merges (ci.yml
paths-ignore) produce no main CI run and no Vercel prod build — correct, since those paths ship nothing into the app. - Deliberately NOT done: hourly train branch + bisect bot (more infra than a
2-dev team needs; revisit at higher headcount), lifting
AFFECTED_CAP(the $1k+/mo 06-20 model), running tests inside Vercel builds (pricier minutes, no sharding).
Drift-guarded by src/__tests__/ci-cost-routing.test.ts (rewritten for this
model: push:main leg present, coalescing on, no merge_group trigger, promote
- await gates wired, red-main alert present).
Revision 2026-07-23 — affected-on-main; coalescing retired
Measured reality after 2 days: coalescing never coalesced. Merges drip ~12+ min apart while the suite runs ~8–12 min wall, so runs rarely overlap — 46 full-suite runs completed on Jul 23 (65 fired, 19 cancelled), 51 on Jul 22, vs the ~15–25/day this ADR assumed. Each is ~45 job-min; the July Actions budget hit 172% by the 23rd. Same failure the merge queue had (assumed batching that arrival cadence never delivers), relocated.
New model (same safety invariants, ~10x cheaper on the common path):
- push:main runs the capped affected subset (the PR lane's
testjob), diffed against the push's ownevent.before— every merge tests exactly its own commit range. - Hub-sized merges escalate: selection >
AFFECTED_CAP(exactly the PRs that skipped PR-lane testing) setsmode=fulland the same run executes the full 12-shard suite. No usableevent.before(force-push/bootstrap) also escalates. Fail-safe, not fail-open. - Per-push concurrency groups on main (no cancellation): with per-diff testing, cancelling a superseded run would leave its commit range permanently untested — a coverage hole, not a saving. PR refs keep stale-run cancellation.
- Deploy gates unchanged:
Unit Tests(test-aggregate) still gates vercel-promote + await-main-ci, now passing from whichever lanemodepicked, failing closed when the lane didn't succeed (including atestjob that died before emittingmode). - Nightly remains the authoritative full-suite backstop for cross-file / base-drift regressions the affected graph can't see.
Expected spend: ~35–50 full runs/day → a handful of hub-merge escalations +
per-push ~2–6 min affected runs. Drift guard updated in the same PR
(ci-cost-routing.test.ts: escalation leg + mode wiring + per-push groups
pinned).
Deploy ordering under per-push runs (added in review)
No-cancellation means two overlapping push runs can both reach
vercel-promote. Each deploys its own GITHUB_SHA, so without a guard a
slower-earlier run could promote an older SHA after a newer one — rolling
prod backward. vercel-promote therefore carries a tip-of-main guard:
it promotes only when its SHA is still the branch tip, otherwise it skips
and the newer push's run owns the deploy (the same outcome cancellation
used to guarantee). A tip-lookup failure holds the promote (fail-closed).
Residual TOCTOU window is the seconds between the tip check and the API
call — vs minutes without the guard; full serialization would need a
deploy queue, which a 2-dev merge cadence doesn't justify. The same
ordering question for await-main-ci consumers (Lambda/Fargate deploys)
is tracked as a follow-up.
Revision 2026-08-07 — hub-sized PRs are tested BEFORE they merge (#5518)
The Context above names the hole and then accepts it:
hub-module PRs (selection >
AFFECTED_CAP) skip PR-level testing entirely, deferring to the queue.
The queue went away and the deferral moved to push:main. That is a real
gate and it fires after the merge, so the failure mode it leaves is not
"a PR merges untested" but "a PR merges untested and the required
Unit Tests check says it passed" — the affected lane skipped itself and
decide-unit-tests-conclusion.sh reported completed/success, titled
"Affected selection capped".
That bill came due on 2026-08-06/07: three capped PRs broke main inside
~36 hours (#5480, #5392, #5485 — the last held every deploy for ~100
minutes), and eight open PRs sat in the same state. Worse for the author, a
hub-sized PR had no way to obtain unit-test evidence at all before
merging: the affected lane refuses by size, test-full could not run on a
non-Dependabot PR event, and the run-tests label does not help — it starts
the affected lane, which caps again.
Change: an over-cap selection on a PR's verdict event (🟢 / 📝 / 🟡
or an approval) emits mode=full and runs the 12-shard suite on the PR head;
test-aggregate and the required check then read test-full, not the lane
that skipped itself. This is the leg that produced the false green — an
ordinary PR push never reaches the cap at all (the approval gate returns
deferred before the selection is computed, and deferred was already
PENDING), so the only run that emitted capped on a normal hub-sized PR was
the verdict run, and it wrote success.
SUPERSEDED 2026-08-09 — this paragraph describes the approval-gate era. The affected lane now runs on every ordinary
pull_requestevent, in parallel with the review (scripts/ci/decide-test-lane.sh), so a hub-sized PR push DOES reach the cap andmode=cappedis its normal per-push outcome. The escalation is unchanged: the check stays PENDING and only the verdict event escalates to the sharded suite (or the delta lane). The false-green analysis above is history and stays as written.
capped survives on one path only: a pull_request push carrying the
run-tests label. There it now reports PENDING rather than green (I8) —
"nothing tested this" and "this passed" stop sharing a representation, which
is invariant I1 of the chokepoint applied to the cap. That half is not
cosmetic either: the label is sticky, so the green it used to leave on the
head SHA would sit there for auto-merge to consume the moment a verdict armed
it, minutes before the escalated suite finished. (I8/I9 in
scripts/ci/decide-unit-tests-conclusion.sh.)
Cost — additive, not offset. ~60–96 billed min per capped verdict run
(12 shards; an estimate from the 12 × ~5–8 min shard times this suite has
run at, not a measurement of this change — re-measure once a real capped
verdict has run). This diff does not remove the post-merge push:main
escalation, so a hub-sized change is now billed two full-suite runs — one
pre-merge, one post-merge — plus one more per additional verdict round. The
approval gate bounds it to once per verdict, not once per push, which is what
keeps it from becoming the un-capped 06-20 model.
"One more per verdict round" understates it slightly: PR refs keep
cancel-in-progress, so a second review event on the same PR kills an
in-flight 12-shard run and its partial shards are still billed. A hub-sized PR
driven through several review rounds realistically pays 3–5 escalations plus
partials, not one.
The counterweight is a prediction, not a saving already banked: during a
red-main window every push escalates, so each hour main stays red bills full
runs on unrelated merges (9 such runs on the night of 2026-08-06, when #5485
held main red ~100 min). Catching those regressions pre-merge should remove
most of that. Re-measure by 2026-09-07 and record the delta here; if the
escalated verdict runs have not paid for themselves, the honest response is to
revisit AFFECTED_CAP or shard count, not to restore the false green.
Deliberately NOT done, so nobody re-derives them:
- Escalating on the
run-testslabel. It is per-push and sticky, so it would bill 12 shards on every push for as long as the label sits on the PR — the un-capped 06-20 model wearing a different trigger. - Merge-base selection for the PR lane. The lane already selects against
origin/<base>; a bot review claiming otherwise was refuted (event.beforeappears only in thepushbranch). - Widening the always-run guard sweep to ordinary tests, or any "> N commits ⇒ full" rule. The first is the full suite by another name; the second keys on something unrelated to the mechanism.
Trade accepted: a hub-sized PR waits for the sharded suite after its
verdict before it can merge — the 12-shard suite's own history in this file
puts that at ~8–12 min wall, and the job's timeout is 25 min. Before this it
merged instantly and detonated on main. fail-fast: false localises a flaky
shard, and re-toggling draft→ready re-fires the verdict (and so the suite).
Drift-guarded by ci-cost-routing.test.ts (the verdict leg on test-full,
bound to mode=full; the ordered push → verdict → capped legs inside the
over-cap block; the test-full gate in test-aggregate, keyed on mode to
match the script) and by decide-unit-tests-conclusion.test.ts, which
executes the real decision script — including the reversed E8 row in its exit
inventory.
Exercised end-to-end before merge. The decision logic is proven by tests
that execute the real script; the wiring could not be, because this change's
own PR selects 3 files and can never reach the cap — it cannot fire its own new
leg. That gap mattered more than it looks: had the leg silently not fired, the
result would not have been a false green but a fleet-wide wedge —
test-full skipped ⇒ test-aggregate exits 1 ⇒ I9's case misses ⇒
emit none ⇒ the required check keeps its PENDING on every verdict, forever.
Fail-closed, correctly, and precisely the "merge-blocking state with no
self-service exit" the capped branch was rewritten to end — applied to every
hub-sized PR at once.
So it was observed rather than inferred, on a throwaway branch carrying this
change plus AFFECTED_CAP=1 (PR #5533, closed unmerged, branch deleted):
| what | observed |
|---|---|
| run | 31197821193, event pull_request_review, head 1f2478188 |
| gate | Approval gate: running the affected lane (event=pull_request_review, state=commented) |
| selection | 3 test files (cap: 1) → mode=full |
| sharded suite | Unit Tests (full 1/12) … (full 12/12) — all 12 ran and passed on a PR event |
| decision | status=completed conclusion=success — mode=full — the sharded full suite is the gate for this run, and it passed |
| write | Patching existing Unit Tests check run 92930435607 — patched in place, so still exactly one per SHA |
| final | Unit Tests → success, output title "Full suite passed" |
The same run corrected a claim: the pre-verdict PENDING it produced is
deferred, not capped. An unlabelled pull_request push short-circuits
at the approval gate before the selection is computed, so it never reaches the
cap — capped is reachable only via the sticky run-tests label.
(Superseded 2026-08-09: with the lane running on every ordinary PR event, an
unlabelled push reaches the cap and capped is the normal PENDING for a
hub-sized PR. deferred now means draft-or-bot.) I8 is still
load-bearing on that path (the stale green it used to leave sits on the head
SHA for auto-merge to consume) but this drill did not exercise it.