Files
school_compare/.gitea/workflows/promote.yml
T
TudorandClaude Fable 5 6877abedeb
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
fix(ci): harden promote workflow — env-isolated untrusted input, main-ancestry check, concurrency guard
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
2026-07-13 13:14:28 +01:00

127 lines
4.8 KiB
YAML

name: Promote to Production (manual)
# Second approval gate of the deploy model: run this workflow from the
# Actions UI after testing the feature on staging. It refuses commits
# whose staging E2E gate is not green. See docs/DEPLOY.md.
on:
workflow_dispatch:
inputs:
sha:
description: >-
Commit SHA on main to promote (full or >=7 chars).
Leave empty to promote the latest main commit.
required: false
default: ""
# Only one promotion at a time; never cancel an in-flight promotion.
concurrency:
group: prod-promotion
cancel-in-progress: false
env:
REGISTRY: privaterepo.sitaru.org
BACKEND_IMAGE_NAME: ${{ gitea.repository }}-backend
FRONTEND_IMAGE_NAME: ${{ gitea.repository }}-frontend
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs:
promote-prod:
name: Promote approved commit to Production
runs-on: ubuntu-latest
steps:
- name: Checkout repository (full history for ancestry check)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve and validate target SHA
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: |
set -euo pipefail
case "$SHA_INPUT" in
-*) echo "REFUSED: SHA input may not start with '-'." >&2; exit 1 ;;
esac
if [ -z "$SHA_INPUT" ]; then
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
SHORT_SHA="sha-$(echo "$FULL_SHA" | cut -c1-7)"
echo "full=$FULL_SHA" >> "$GITHUB_OUTPUT"
echo "short=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
- name: Verify the staging E2E gate passed for this commit
run: |
STATUS_JSON=$(curl -fsS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
echo "$STATUS_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
ok = [s for s in d.get('statuses', [])
if 'E2E Journeys against Staging' in s.get('context', '')
and s.get('status') == 'success']
if not ok:
print('REFUSED: no successful \"E2E Journeys against Staging\" status on this commit.')
print('Contexts found:', [s.get('context') for s in d.get('statuses', [])])
sys.exit(1)
print('E2E gate verified green for this commit.')
"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Retag approved images as prod (keeping rollback pointer)
run: |
SHORT_SHA="${{ steps.resolve.outputs.short }}"
for IMAGE in \
"${REGISTRY}/${BACKEND_IMAGE_NAME}" \
"${REGISTRY}/${FRONTEND_IMAGE_NAME}" \
"${REGISTRY}/${PIPELINE_IMAGE_NAME}"; do
# Keep a rollback pointer before moving :prod
docker buildx imagetools create -t "${IMAGE}:prod-previous" "${IMAGE}:prod" || true
docker buildx imagetools create -t "${IMAGE}:prod" "${IMAGE}:${SHORT_SHA}"
echo "Promoted ${IMAGE}:${SHORT_SHA} -> :prod"
done
- name: Trigger production stack update
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
- name: Wait for production to become healthy
run: |
echo "Polling ${PROD_BASE_URL} for up to 5 minutes..."
for i in $(seq 1 60); do
if curl -fsS -o /dev/null --max-time 10 "${PROD_BASE_URL}/"; then
echo "Production is up (attempt $i)"
exit 0
fi
sleep 5
done
echo "Production did not become healthy in time" >&2
exit 1
env:
PROD_BASE_URL: ${{ secrets.PROD_BASE_URL }}