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

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 commit is contained in:
Tudor
2026-07-13 13:14:28 +01:00
co-authored by Claude Fable 5
parent 2b563cc0bf
commit 6877abedeb
+32 -8
View File
@@ -14,6 +14,11 @@ on:
required: false required: false
default: "" default: ""
# Only one promotion at a time; never cancel an in-flight promotion.
concurrency:
group: prod-promotion
cancel-in-progress: false
env: env:
REGISTRY: privaterepo.sitaru.org REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
@@ -25,18 +30,37 @@ jobs:
name: Promote approved commit to Production name: Promote approved commit to Production
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Resolve target SHA - name: Checkout repository (full history for ancestry check)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve and validate target SHA
id: resolve id: resolve
# SECURITY: the dispatch input is untrusted — it reaches the shell
# only via env (never spliced into `run:` with ${{ }}) and is only
# used as a quoted argument. The resolved value is validated as a
# 40-hex sha and required to be an ancestor of main before any
# later step interpolates it.
env:
SHA_INPUT: ${{ gitea.event.inputs.sha }}
run: | run: |
SHA_INPUT="${{ gitea.event.inputs.sha }}" set -euo pipefail
case "$SHA_INPUT" in
-*) echo "REFUSED: SHA input may not start with '-'." >&2; exit 1 ;;
esac
if [ -z "$SHA_INPUT" ]; then if [ -z "$SHA_INPUT" ]; then
SHA_INPUT="${{ gitea.sha }}" SHA_INPUT="$(git rev-parse origin/main)"
fi
FULL_SHA=$(git rev-parse --verify --quiet "${SHA_INPUT}^{commit}") || {
echo "REFUSED: not a commit in this repository." >&2
exit 1
}
echo "$FULL_SHA" | grep -Eq '^[0-9a-f]{40}$'
if ! git merge-base --is-ancestor "$FULL_SHA" origin/main; then
echo "REFUSED: $FULL_SHA is not on main — only main commits are promotable." >&2
exit 1
fi fi
# Normalise to the full sha via the API so short inputs work
FULL_SHA=$(curl -fsS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/git/commits/${SHA_INPUT}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['sha'])")
SHORT_SHA="sha-$(echo "$FULL_SHA" | cut -c1-7)" SHORT_SHA="sha-$(echo "$FULL_SHA" | cut -c1-7)"
echo "full=$FULL_SHA" >> "$GITHUB_OUTPUT" echo "full=$FULL_SHA" >> "$GITHUB_OUTPUT"
echo "short=$SHORT_SHA" >> "$GITHUB_OUTPUT" echo "short=$SHORT_SHA" >> "$GITHUB_OUTPUT"