All four deploy jobs carried `environment: production`, which reads as the
stricter design — GitHub holds each one until approved. It does not behave that
way. Approval requests are batched by eligibility, and these jobs are never
eligible together: deploy-supabase waits only on the notification while the rest
wait on image builds. So a run asks twice, minutes apart, and nothing announces
the second round.
That happened on 2026-07-31. The first approval shipped the Supabase functions
and migrations; the run went back to waiting and stayed there. For as long as it
sat, the schema had moved and the app serving it had not.
Deployment protection now lives on a single `approve-deploy` job that the four
deploy jobs depend on. One approval releases the whole set.
That trades GitHub's own per-job enforcement for a dependency edge, and an edge
can be dropped by a later edit. ops/check-deploy-gate.py makes it a guarantee
again: it fails CI if a deploy job does not depend on the gate, if a second job
declares the environment and brings the split back, or if a job uses always()
without asserting the gate succeeded — which looks correct and silently
un-holds it. Verified against all three by mutating the workflow.
Also documented in ops/README.md: a run waiting on approval cannot be stopped
with `gh run cancel`; its pending deployment must be rejected. And that
bump-version still pushes to main before the gate — left alone, but written
down rather than left to be rediscovered.
Both were configured by hand through the GitHub API today. Nothing in the repo
reflected them, so they were invisible here, unversioned, and removable without
a trace — which undercuts their purpose, since they exist so that nothing reaches
main or production unreviewed.
apply-repo-settings.sh is now the source of truth for both, and idempotent: each
call PUTs the full desired state rather than stacking changes. --check reports
drift without touching anything, which is what makes this useful later — a
setting silently relaxed is otherwise only discovered when it matters.
Documented alongside it: why CI Gate ✅ is the only required check (it is an
aggregate that already inspects the others, and listing each job would mean a
rename quietly drops a requirement), and why enforce_admins stays false (an
incident needs an escape hatch — rare, visible, recorded).
This is the same class of problem as the migrations fixed earlier today: state
that only exists because someone once clicked something, with no record of what
or why.