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 }}