PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 36s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 6m3s
Staging health polling asked only whether something answered HTTP 200 at the base URL. It could not tell the new deployment from the old one, so journeys could pass against the previous release, and concurrent merges could move the staging tags underneath a run in flight. Each staging run now mints a build ID and stamps all three images with the commit and that ID, as labels and — for frontend and backend — as a build-time JSON file that environment overrides cannot rewrite. /release.json reports both identities uncached, and scripts/ci/release.py polls for the expected pair before and after the journeys. Only then are the captured build digests tagged verified-<sha>. Promotion resolves those verified tags to immutable digests, revalidates their labels, and refuses a mixed or incomplete set before any :prod tag moves. The whole staging workflow shares one concurrency group with cancellation disabled, so releases serialise. The scripts are stdlib-only and unit-tested against mocked registry and HTTP behaviour; PR checks now run the pipeline and CI suites too. The runbook records what this cannot prove locally, and that the first rollout needs a commit built by this workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.3 KiB
YAML
112 lines
4.3 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
|
|
# Use the built-in Actions token (GITHUB_TOKEN is the documented name;
|
|
# it carries repository read scope), NOT REGISTRY_TOKEN — the registry
|
|
# token has no repo scope, so the commit-status API returns 403.
|
|
run: |
|
|
STATUS_JSON=$(curl -fsS \
|
|
-H "Authorization: token ${{ secrets.GITHUB_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: Resolve verified digests and promote the complete image set
|
|
run: python3 scripts/ci/release.py promote --output release.json
|
|
env:
|
|
EXPECTED_SHA: ${{ steps.resolve.outputs.full }}
|
|
|
|
- name: Trigger production stack update
|
|
run: curl -fsSk -X POST "${{ secrets.PORTAINER_PROD_WEBHOOK }}"
|
|
|
|
- name: Verify production release identity
|
|
run: python3 scripts/ci/release.py wait --release release.json
|
|
env:
|
|
BASE_URL: ${{ secrets.PROD_BASE_URL }}
|