From f58d12bee207b988bad17603276f08dd074800a7 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 07:43:56 -0700 Subject: [PATCH] chore(ci): add auto-promote-staging workflow --- .github/workflows/auto-promote-staging.yml | 118 +++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 .github/workflows/auto-promote-staging.yml diff --git a/.github/workflows/auto-promote-staging.yml b/.github/workflows/auto-promote-staging.yml new file mode 100644 index 0000000..4613f03 --- /dev/null +++ b/.github/workflows/auto-promote-staging.yml @@ -0,0 +1,118 @@ +name: Auto-promote staging → main + +# Fast-forwards `main` to `staging` when all required status checks on +# the staging HEAD commit are green. Eliminates the manual sync-PR round +# for non-critical repos. +# +# Gate list is READ FROM BRANCH PROTECTION (not hardcoded) so each repo +# gets the set of checks its own admin configured as required. Zero +# customization per repo. +# +# Excluded by policy: molecule-core + molecule-controlplane. Those two are +# critical-path and stay manual per CEO directive 2026-04-24. +# +# Safety model: +# - Only fires on push to staging (not PRs into staging) +# - Refuses with --ff-only if main has diverged (hotfix landed directly) +# - Writes a promote commit to git log so the action is visible +# - Requires the branch protection's `required_status_checks.contexts` +# list to be non-empty (i.e. don't auto-promote if no gates configured) + +on: + push: + branches: [staging] + workflow_dispatch: + +permissions: + contents: write + statuses: read + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Read required gates from branch protection + check green + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + + # Pull required status check contexts from branch protection. + # If this 404s (no protection) or returns an empty list, refuse + # to promote — silent auto-promotion on unprotected branches is + # the scary case. + GATES=$(gh api "repos/${REPO}/branches/staging/protection/required_status_checks" \ + --jq '.contexts[]' 2>/dev/null || true) + + if [ -z "$GATES" ]; then + echo "::error::No required_status_checks.contexts configured on staging. Refusing to auto-promote." + echo "ok=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Required gates on staging:" + echo "${GATES}" | sed 's/^/ - /' + + # For each required gate, look up the most recent check-run / + # status on HEAD_SHA and confirm SUCCESS. The context match can + # come from either Checks API or Status API depending on how the + # gate reports. + ALL_GREEN=true + while IFS= read -r gate; do + [ -z "$gate" ] && continue + + # First, try check-runs (GitHub Actions + App-based checks). + conclusion=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs" \ + --jq "[.check_runs[] | select(.name == \"${gate}\")] | sort_by(.completed_at) | last.conclusion" \ + 2>/dev/null || echo "") + + if [ -z "$conclusion" ] || [ "$conclusion" = "null" ]; then + # Fall back to the legacy status API. + conclusion=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/status" \ + --jq "[.statuses[] | select(.context == \"${gate}\")] | sort_by(.updated_at) | last.state" \ + 2>/dev/null || echo "") + fi + + if [ "$conclusion" != "success" ] && [ "$conclusion" != "SUCCESS" ]; then + echo "::warning::Gate '${gate}' is '${conclusion:-missing}' on ${HEAD_SHA} — not promoting." + ALL_GREEN=false + else + echo " ✓ ${gate}: success" + fi + done <<< "$GATES" + + echo "ok=${ALL_GREEN}" >> "$GITHUB_OUTPUT" + + - name: Fast-forward main to staging + if: steps.check.outputs.ok == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + git config user.email "actions@github.com" + git config user.name "github-actions[bot]" + + git fetch origin main:main staging:staging + git checkout main + + # --ff-only refuses if main has moved independently (hotfix on + # main). In that case a human resolves. + if ! git merge --ff-only staging; then + echo "::error::main has diverged from staging history — refusing fast-forward. Resolve manually." + exit 1 + fi + + git push origin main + + echo "Promoted: main is now at $(git rev-parse --short HEAD)"