diff --git a/.github/workflows/deploy-static.yaml b/.github/workflows/deploy-static.yaml index 315e536320..eb007663c1 100644 --- a/.github/workflows/deploy-static.yaml +++ b/.github/workflows/deploy-static.yaml @@ -265,3 +265,80 @@ jobs: environment: ${{ needs.detect.outputs.env }} secrets: INFRA_WORKFLOW_TOKEN: ${{ secrets.INFRA_WORKFLOW_TOKEN }} + + ####### + # Manual promotion jobs — appear after deploy on push to master. + # Developers click "Review deployments" in the Actions UI to trigger. + # Requires GitHub environments "promote-preprod" and "promote-prod" + # configured with required reviewers in repo Settings → Environments. + ####### + promote-to-preprod: + name: "Promote: master → preprod" + needs: [detect, trigger-deploy] + if: > + always() && !failure() && !cancelled() && + needs.detect.outputs.env == 'staging' + runs-on: ubuntu-latest + environment: promote-preprod + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate changelog and create PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./build/ci/generate-promotion-pr.sh master preprod + + promote-to-prod: + name: "Promote: preprod → prod" + needs: [detect, trigger-deploy] + if: > + always() && !failure() && !cancelled() && + needs.detect.outputs.env == 'staging' + runs-on: ubuntu-latest + environment: promote-prod + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate changelog and create PR + id: create_pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./build/ci/generate-promotion-pr.sh preprod prod + + - name: Post to Slack + if: ${{ steps.create_pr.outputs.pr_url != 'already exists' && steps.create_pr.outputs.pr_url != 'no-changes' }} + uses: slackapi/slack-github-action@v1.27.0 + with: + payload: | + { + "blocks": [ + { + "type": "header", + "text": { "type": "plain_text", "text": "Production Promotion PR Created" } + }, + { + "type": "section", + "fields": [ + { "type": "mrkdwn", "text": "*Commits:* ${{ steps.create_pr.outputs.total }}" }, + { "type": "mrkdwn", "text": "*Features:* ${{ steps.create_pr.outputs.feat_count }}" }, + { "type": "mrkdwn", "text": "*Fixes:* ${{ steps.create_pr.outputs.fix_count }}" }, + { "type": "mrkdwn", "text": "*PR:* <${{ steps.create_pr.outputs.pr_url }}|View PR>" } + ] + }, + { "type": "divider" }, + { + "type": "section", + "text": { "type": "mrkdwn", "text": "_Triggered by ${{ github.actor }}_" } + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK }} diff --git a/.github/workflows/promotion-gate.yaml b/.github/workflows/promotion-gate.yaml new file mode 100644 index 0000000000..0250d54f36 --- /dev/null +++ b/.github/workflows/promotion-gate.yaml @@ -0,0 +1,60 @@ +name: "Promotion Gate" + +# Runs on PRs targeting preprod or prod. +# Validates the source branch matches the allowed promotion path. +# Add "Promotion Gate" as a required status check in branch protection +# settings for preprod and prod — PRs cannot merge without this passing. +# +# Allowed paths: +# preprod ← master, hotfix/* +# prod ← preprod, hotfix/* +# +# Who can approve merges is controlled by GitHub environment reviewers +# on the "promote-preprod" and "promote-prod" environments, not here. + +on: + pull_request: + branches: [preprod, prod] + merge_group: + branches: [preprod, prod] + +jobs: + validate-promotion-path: + name: "Promotion Gate" + runs-on: ubuntu-latest + steps: + - name: Check source branch + run: | + TARGET="${{ github.base_ref }}" + SOURCE="${{ github.head_ref }}" + + echo "PR: $SOURCE → $TARGET" + + # hotfix/* branches are allowed into both preprod and prod + if [[ "$SOURCE" == hotfix/* ]]; then + echo "✅ $TARGET ← $SOURCE: hotfix branch allowed" + exit 0 + fi + + if [[ "$TARGET" == "preprod" ]]; then + if [[ "$SOURCE" == "master" ]]; then + echo "✅ preprod ← master: allowed" + else + echo "❌ BLOCKED: preprod only accepts PRs from master or hotfix/*" + echo "" + echo " Got: $SOURCE → preprod" + echo " Expected: master → preprod (or hotfix/* → preprod)" + exit 1 + fi + + elif [[ "$TARGET" == "prod" ]]; then + if [[ "$SOURCE" == "preprod" ]]; then + echo "✅ prod ← preprod: allowed" + else + echo "❌ BLOCKED: prod only accepts PRs from preprod or hotfix/*" + echo "" + echo " Got: $SOURCE → prod" + echo " Expected: preprod → prod (or hotfix/* → prod)" + exit 1 + fi + fi diff --git a/build/ci/generate-promotion-pr.sh b/build/ci/generate-promotion-pr.sh new file mode 100755 index 0000000000..2e8cf6744e --- /dev/null +++ b/build/ci/generate-promotion-pr.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# generate-promotion-pr.sh — Creates a promotion PR with auto-generated changelog. +# Usage: ./generate-promotion-pr.sh +# +# Requires: GH_TOKEN environment variable for gh CLI authentication. +# Outputs (to GITHUB_OUTPUT if set): pr_url, total, feat_count, fix_count + +set -euo pipefail + +SOURCE="${1:?Usage: $0 }" +TARGET="${2:?Usage: $0 }" +TIMESTAMP=$(date -u +'%Y-%m-%d %H:%M UTC') + +# Generate changelog from commits between branches +COMMITS=$(git log "${TARGET}..${SOURCE}" --pretty=format:"%s (%h) by %an" --no-merges 2>/dev/null || echo "") + +if [[ -z "$COMMITS" || "$COMMITS" == "" ]]; then + echo "No new commits between ${SOURCE} and ${TARGET}. Skipping PR creation." + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=no-changes" >> "$GITHUB_OUTPUT" + echo "total=0" >> "$GITHUB_OUTPUT" + echo "feat_count=0" >> "$GITHUB_OUTPUT" + echo "fix_count=0" >> "$GITHUB_OUTPUT" + fi + exit 0 +fi + +FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) +FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) +TOTAL=$(echo "$COMMITS" | grep -c . || true) + +# Build PR body +{ + echo "## Promote to \`${TARGET}\`" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Source** | \`${SOURCE}\` |" + echo "| **Target** | \`${TARGET}\` |" + echo "| **Date** | ${TIMESTAMP} |" + echo "| **Total Commits** | ${TOTAL} |" + echo "| **Features** | ${FEAT_COUNT} |" + echo "| **Bug Fixes** | ${FIX_COUNT} |" + echo "" + echo "### Changes" + echo "" + echo "$COMMITS" | sed 's/^/- /' + echo "" + echo "---" + echo "_Auto-generated by Deploy Static workflow_" +} > /tmp/pr_body.md + +TITLE="Promote to ${TARGET} — ${TIMESTAMP}" + +PR_URL=$(gh pr create \ + --base "${TARGET}" \ + --head "${SOURCE}" \ + --title "${TITLE}" \ + --body-file /tmp/pr_body.md 2>&1) || PR_URL="already exists" + +echo "PR: ${PR_URL}" + +# Export outputs for GitHub Actions +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" + echo "total=${TOTAL}" >> "$GITHUB_OUTPUT" + echo "feat_count=${FEAT_COUNT}" >> "$GITHUB_OUTPUT" + echo "fix_count=${FIX_COUNT}" >> "$GITHUB_OUTPUT" +fi