Files
pwap/ops/check-deploy-gate.py
pezkuwichain 7c9fbfb4b2 ci: one approval per deploy, not two (#30)
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.
2026-08-01 00:55:13 -07:00

105 lines
3.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Assert that nothing deploys without passing the approval gate.
Approval used to be enforced by GitHub itself: every deploy job carried
`environment: production`, so GitHub held each one until a human approved. That
had to change, because GitHub batches approval requests by eligibility and the
deploy jobs are not eligible at the same moment — a single run asked for
approval twice, minutes apart, and one approval shipped only part of it.
The gate is now a single job, `approve-deploy`, and the deploy jobs are held by
depending on it. That is deterministic, but it moves the guarantee from
"GitHub enforces it" to "the dependency graph says so" — and a graph can be
edited. This script is what makes it a guarantee again: it fails CI if a deploy
job is not behind the gate, or if a second job quietly grows its own
environment and reintroduces the split.
Run: python3 ops/check-deploy-gate.py [workflow.yml ...]
"""
import sys
import yaml
DEFAULT_WORKFLOWS = [".github/workflows/quality-gate.yml"]
GATE = "approve-deploy"
ENVIRONMENT = "production"
def needs_of(job):
needs = job.get("needs", [])
return [needs] if isinstance(needs, str) else list(needs)
def environment_of(job):
env = job.get("environment")
if isinstance(env, dict):
return env.get("name")
return env
def check(path):
with open(path) as fh:
workflow = yaml.safe_load(fh)
jobs = workflow.get("jobs", {})
problems = []
gated = [
name
for name, job in jobs.items()
if environment_of(job) == ENVIRONMENT
]
if gated != [GATE]:
problems.append(
f"exactly one job may declare `environment: {ENVIRONMENT}`, and it must be "
f"`{GATE}`; found {gated or 'none'}. More than one splits the run into "
f"separate approval rounds — the failure this design exists to prevent."
)
if GATE not in jobs:
problems.append(f"`{GATE}` is missing; nothing holds the deploys.")
return problems
for name, job in jobs.items():
if not name.startswith("deploy-") or name == GATE:
continue
if GATE not in needs_of(job):
problems.append(
f"`{name}` does not list `{GATE}` in its needs, so it would deploy "
f"without approval."
)
continue
condition = str(job.get("if", ""))
# `always()` overrides the implicit "all needs succeeded" rule, so the
# dependency alone stops holding and the condition has to say so.
if "always()" in condition and f"needs.{GATE}.result == 'success'" not in condition:
problems.append(
f"`{name}` uses always() but does not require "
f"needs.{GATE}.result == 'success' — with always(), depending on the "
f"gate no longer waits for it to pass."
)
return problems
def main(argv):
paths = argv[1:] or DEFAULT_WORKFLOWS
failed = False
for path in paths:
problems = check(path)
if problems:
failed = True
print(f"{path}:")
for problem in problems:
print(f" - {problem}")
else:
print(f"{path}: every deploy job is behind `{GATE}`.")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))