From 617fc09d7f2a5ab6230b1b450dea44ca363128a4 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 08:06:16 -0700 Subject: [PATCH] =?UTF-8?q?fix(ci):=20relax=20auto-promote=20=E2=80=94=20n?= =?UTF-8?q?o-gates=20mode=20+=20already-ahead=20no-op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/auto-promote-staging.yml | 80 +++++++++++----------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/.github/workflows/auto-promote-staging.yml b/.github/workflows/auto-promote-staging.yml index 4613f03..2e9c858 100644 --- a/.github/workflows/auto-promote-staging.yml +++ b/.github/workflows/auto-promote-staging.yml @@ -1,22 +1,24 @@ 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. +# Fast-forwards `main` to `staging` when staging is strictly ahead (main +# is an ancestor). 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. +# Gate handling: +# - If the repo has required_status_checks configured AND the API +# returns them, all must be SUCCESS on the staging HEAD commit. +# - If no gates are configured (or the API 403s on a private free-tier +# repo), `--ff-only` is the sole safety. It refuses if main has +# independent commits staging doesn't contain. # -# Excluded by policy: molecule-core + molecule-controlplane. Those two are -# critical-path and stay manual per CEO directive 2026-04-24. +# Excluded by policy: molecule-core + molecule-controlplane. Those two +# 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) +# Safety: +# - Only fires on push to staging (PRs into staging don't promote) +# - `--ff-only` refuses if main has diverged (hotfix landed directly) +# - Promote commit goes through GITHUB_TOKEN; shows up in git log as +# a deliberate act on: push: @@ -36,8 +38,8 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - - name: Read required gates from branch protection + check green - id: check + - name: Check required gates (if configured) on staging HEAD + id: gates env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} @@ -46,44 +48,36 @@ jobs: 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) + # Try to read required gates from branch protection. Free-tier + # private repos may 403; handle that gracefully. + GATES_JSON=$(gh api "repos/${REPO}/branches/staging/protection/required_status_checks" 2>/dev/null || echo '{}') + GATES=$(echo "${GATES_JSON}" | jq -r '.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" + echo "No required gates configured (or API inaccessible). Relying on --ff-only safety." + echo "ok=true" >> "$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." + echo "::warning::Gate '${gate}' is '${conclusion:-missing}' on ${HEAD_SHA} — skipping promote." ALL_GREEN=false else echo " ✓ ${gate}: success" @@ -93,7 +87,7 @@ jobs: echo "ok=${ALL_GREEN}" >> "$GITHUB_OUTPUT" - name: Fast-forward main to staging - if: steps.check.outputs.ok == 'true' + if: steps.gates.outputs.ok == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash @@ -103,16 +97,22 @@ jobs: git config user.email "actions@github.com" git config user.name "github-actions[bot]" - git fetch origin main:main staging:staging + git fetch origin main:main staging:staging 2>&1 + + # Check if main is already at or ahead of staging — nothing to do. + if git merge-base --is-ancestor staging main 2>/dev/null; then + echo "main already contains staging; nothing to promote." + exit 0 + fi + 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 + # --ff-only refuses if main has independent commits not on + # staging (divergence — hotfix direct to main). Human resolves. + if ! git merge --ff-only staging 2>&1; then + echo "::warning::main has diverged from staging — refusing fast-forward. Resolve manually (likely a direct-to-main commit exists that staging doesn't have)." + exit 0 fi git push origin main - - echo "Promoted: main is now at $(git rev-parse --short HEAD)" + echo "::notice::Promoted: main is now at $(git rev-parse --short HEAD)"