fix(ci): relax auto-promote — no-gates mode + already-ahead no-op
This commit is contained in:
parent
b415d91330
commit
3e9d1eb838
80
.github/workflows/auto-promote-staging.yml
vendored
80
.github/workflows/auto-promote-staging.yml
vendored
@ -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)"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user