diff --git a/.gitea/workflows/promote.yml b/.gitea/workflows/promote.yml new file mode 100644 index 0000000..811e212 --- /dev/null +++ b/.gitea/workflows/promote.yml @@ -0,0 +1,102 @@ +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: "" + +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: Resolve target SHA + id: resolve + run: | + SHA_INPUT="${{ gitea.event.inputs.sha }}" + if [ -z "$SHA_INPUT" ]; then + SHA_INPUT="${{ gitea.sha }}" + 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)" + 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 }}