feat(deploy): production gate tied to the promotion PR (replaces the staging marker files) #96

Merged
jfraeys merged 3 commits from jfraeys/feat/promotion-gate into main 2026-10-13 11:16:19 -04:00
Owner

Production deploys are operator-run (CI only validates and opens the promotion PR #77; nothing reacts to a merge into prod). The only guard was local marker files that CI runs on ephemeral runners can never create, so ./setup --confirm-prod was effectively always the type-'deploy' bypass.

New gate (scripts/lib/setup/promotion.sh), no secrets read:

  1. the latest merged PR with base prod names the commit staging validated ("passed on commit ", written by the promote job since #82);
  2. that PR merged that commit, or a descendant that changed only files staging does not exercise (docs and the like: those merges never start a staging run, so main legitimately moves past the validated commit; a descendant that touches roles/, playbooks/, terraform/, scripts/lib/, inventory/ etc. is refused and the files are named);
  3. the checkout being deployed is the commit the PR merged.
    Otherwise the deploy is refused, with the reason and the fix. --confirm-prod still allows an emergency bypass, only by typing 'deploy'; every bypass is appended to ~/.local/state/infra/emergency-deploys.log.

Wiring

  • setup: promotion_gate replaces check_staging_markers for production workspaces (staging workspaces pass as before; the typed workspace-name confirmation stays).
  • scripts/deploy-services.sh: the default (production) inventory is gated before it loads the vault or mints keys; deploys with their own -i (DR) are not gated.
  • scripts/promote-prod.sh (new): verify, show the read-only Terraform plan, ask, then run ./setup --workspace linode --confirm-prod.
  • lint workflow runs scripts/tests/test-promotion.sh.

Tested: 19 cases against a throwaway git repo and a fake PR list: approved; main moved past the approved commit; PR merged a different or non-descendant commit; descendant with only non-deploy changes approved; descendant that changed roles/ refused and names the file; PR not merged; no validated commit in the body; only PRs into prod count; no PR; API unavailable; staging workspaces pass; refusal without the flag; bypass needs the typed word and logs only then; the staged-paths regex matches every path in staging-validate.yml's push trigger and no docs path. deploy-services.sh with the API unavailable stops at the gate before the vault load or any key mint. shellcheck -S error is clean (docker).

Behaviour change: after this merges, deploy-services.sh and ./setup --confirm-prod on production refuse unless the promotion PR was merged for the commit you have checked out, or you use the typed emergency bypass. The old staging-marker code is removed in #97 (stacked on this).

🤖 Generated with Claude Code

Production deploys are operator-run (CI only validates and opens the promotion PR #77; nothing reacts to a merge into prod). The only guard was local marker files that CI runs on ephemeral runners can never create, so `./setup --confirm-prod` was effectively always the type-'deploy' bypass. New gate (scripts/lib/setup/promotion.sh), no secrets read: 1. the latest merged PR with base prod names the commit staging validated ("passed on commit <sha>", written by the promote job since #82); 2. that PR merged that commit, or a descendant that changed only files staging does not exercise (docs and the like: those merges never start a staging run, so main legitimately moves past the validated commit; a descendant that touches roles/, playbooks/, terraform/, scripts/lib/, inventory/ etc. is refused and the files are named); 3. the checkout being deployed is the commit the PR merged. Otherwise the deploy is refused, with the reason and the fix. `--confirm-prod` still allows an emergency bypass, only by typing 'deploy'; every bypass is appended to ~/.local/state/infra/emergency-deploys.log. Wiring - `setup`: promotion_gate replaces check_staging_markers for production workspaces (staging workspaces pass as before; the typed workspace-name confirmation stays). - `scripts/deploy-services.sh`: the default (production) inventory is gated before it loads the vault or mints keys; deploys with their own -i (DR) are not gated. - `scripts/promote-prod.sh` (new): verify, show the read-only Terraform plan, ask, then run `./setup --workspace linode --confirm-prod`. - lint workflow runs `scripts/tests/test-promotion.sh`. Tested: 19 cases against a throwaway git repo and a fake PR list: approved; main moved past the approved commit; PR merged a different or non-descendant commit; descendant with only non-deploy changes approved; descendant that changed roles/ refused and names the file; PR not merged; no validated commit in the body; only PRs into prod count; no PR; API unavailable; staging workspaces pass; refusal without the flag; bypass needs the typed word and logs only then; the staged-paths regex matches every path in staging-validate.yml's push trigger and no docs path. deploy-services.sh with the API unavailable stops at the gate before the vault load or any key mint. shellcheck -S error is clean (docker). Behaviour change: after this merges, deploy-services.sh and ./setup --confirm-prod on production refuse unless the promotion PR was merged for the commit you have checked out, or you use the typed emergency bypass. The old staging-marker code is removed in #97 (stacked on this). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ci(lint): run the promotion gate tests
Some checks failed
Lint / lint (pull_request) Has been cancelled
Secret scan / scan (pull_request) Has been cancelled
5ceb5d56ef
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
feat(deploy): the gate accepts a merged descendant that changed only non-deploy files
All checks were successful
Lint / lint (pull_request) Successful in 1m19s
Secret scan / scan (pull_request) Successful in 13s
6236ebd79e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jfraeys/infra!96
No description provided.