Independent code review caught a Critical issue inherited from the
pre-extraction workflow: the branch-protection API call falls through
to '{}' on any non-200, then the empty-GATES check treats this as
"no gates configured (or API inaccessible)" and sets ok=true. Combined
with --ff-only being ancestry-only (not test-status), a green-but-
flaky source branch could ff-promote red commits to the target with
zero CI enforcement.
The conflation of three response classes is the bug:
200 with .contexts[] populated → honor the gates (correct)
200 with empty .contexts → "no gates configured" → ok=true (correct)
404 (no branch protection) → "no gates configured" → ok=true (correct)
403 (token lacks permission) → silently treated like 404 (BUG)
Use `gh api -i` to capture the HTTP status line and discriminate:
- 200 → extract body, proceed to gate-check loop
- 404 → legitimate fallback to --ff-only safety, log notice
- 403/401 → fail loud with a concrete fix ("add administration: read
to your caller's permissions block")
- any other → fail loud with the response prefix for debugging
Also:
- Update the README in the workflow header to document the
administration: read requirement.
- Add administration: read to molecule-ci's own self-caller
(auto-promote-staging.yml) so its behavior is preserved.
Verified locally against four real API responses:
- molecule-core/staging → HTTP 200, 8 gates → loop runs
- molecule-ci/main → HTTP 200, 0 gates → ok=true (notice)
- hackathon org-template/main → HTTP 200, 0 gates → ok=true (notice)
- this-repo-does-not-exist → HTTP 404 → legitimate fallback path
Closes a Critical from the post-merge review of #14.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
8.8 KiB
YAML
212 lines
8.8 KiB
YAML
name: Auto-promote branch (reusable)
|
|
|
|
# Reusable version of the auto-promote-staging workflow that lived
|
|
# directly in molecule-ci. Any repo with a `from-branch` (typically
|
|
# `staging`) → `to-branch` (typically `main`) flow can call this
|
|
# workflow to fast-forward `to-branch` whenever `from-branch` is
|
|
# strictly ahead AND all configured required-status-checks on the
|
|
# `from-branch` HEAD are green.
|
|
#
|
|
# Adoption pattern in a consumer repo:
|
|
#
|
|
# # .github/workflows/auto-promote.yml
|
|
# name: Auto-promote staging → main
|
|
# on:
|
|
# push:
|
|
# branches: [staging]
|
|
# workflow_dispatch:
|
|
# permissions:
|
|
# contents: write # push the fast-forward to to-branch
|
|
# statuses: read # read commit status checks
|
|
# administration: read # read branch protection (REQUIRED — see below)
|
|
# jobs:
|
|
# promote:
|
|
# uses: Molecule-AI/molecule-ci/.github/workflows/auto-promote-branch.yml@main
|
|
# with:
|
|
# from-branch: staging
|
|
# to-branch: main
|
|
#
|
|
# Repo-agnostic by design — gates are read from the consuming repo's
|
|
# branch protection at run time, not hardcoded here.
|
|
#
|
|
# `administration: read` is REQUIRED. Without it, the branch-protection
|
|
# API returns 403 and the workflow refuses to fast-forward (fail-loud),
|
|
# rather than silently degrading to --ff-only-only enforcement (which
|
|
# is ancestry-only, not test-status — a green-but-flaky branch would
|
|
# ff-promote red commits). If you intentionally want no-gate
|
|
# enforcement, leave from-branch unprotected — a 404 from the API is
|
|
# treated as "no gates configured" and falls back to --ff-only safety.
|
|
#
|
|
# Excluded-by-policy repos (molecule-core + molecule-controlplane per
|
|
# CEO directive 2026-04-24) simply do not adopt this workflow; the
|
|
# reusable shape adds no surface area to repos that don't call it.
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
from-branch:
|
|
description: "Source branch with green CI"
|
|
required: false
|
|
default: staging
|
|
type: string
|
|
to-branch:
|
|
description: "Target branch to fast-forward"
|
|
required: false
|
|
default: main
|
|
type: string
|
|
|
|
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: Check required gates (if configured) on source HEAD
|
|
id: gates
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
REPO: ${{ github.repository }}
|
|
HEAD_SHA: ${{ github.sha }}
|
|
FROM_BRANCH: ${{ inputs.from-branch }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Read required gates from branch protection. Three response
|
|
# classes, distinguished by HTTP status:
|
|
#
|
|
# 200 — branch protection is configured. Honor the gates.
|
|
# 404 — branch is not protected. Legitimate "no gates";
|
|
# fall back to --ff-only as the sole safety net.
|
|
# 403 — caller's GITHUB_TOKEN can't read branch protection.
|
|
# FAIL LOUD. The previous behavior conflated this
|
|
# with 404 ("api inaccessible") and silently degraded
|
|
# to --ff-only-only — which is ancestry-only, not
|
|
# test-status. A green-but-flaky branch would
|
|
# ff-promote red commits to the target. The fix:
|
|
# require the caller to add `administration: read`
|
|
# to its permissions block, or explicitly accept the
|
|
# no-gates posture by removing branch protection on
|
|
# the source branch.
|
|
#
|
|
# `gh api` exit code is 0 only on 2xx; non-zero on anything
|
|
# else. We use --include to capture HTTP status to discriminate.
|
|
|
|
if PROTECTION_RESP=$(gh api -i "repos/${REPO}/branches/${FROM_BRANCH}/protection/required_status_checks" 2>&1); then
|
|
HTTP_STATUS=200
|
|
else
|
|
HTTP_STATUS=$(echo "$PROTECTION_RESP" | grep -oE '^HTTP/[12](\.[01])? [0-9]{3}' | awk '{print $2}' | head -1)
|
|
HTTP_STATUS=${HTTP_STATUS:-unknown}
|
|
fi
|
|
|
|
case "$HTTP_STATUS" in
|
|
200)
|
|
# Strip headers from gh -i output to get just the body.
|
|
GATES_JSON=$(echo "$PROTECTION_RESP" | awk 'p{print} /^[[:space:]]*$/ && !p {p=1}')
|
|
;;
|
|
404)
|
|
echo "::notice::No branch protection on '${FROM_BRANCH}' — relying on --ff-only safety."
|
|
echo "ok=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
;;
|
|
403|401)
|
|
echo "::error::Cannot read branch protection on '${FROM_BRANCH}' (HTTP ${HTTP_STATUS})."
|
|
echo "::error::Caller's GITHUB_TOKEN lacks 'administration: read' permission."
|
|
echo "::error::Refusing to fast-forward without explicit gate enforcement —"
|
|
echo "::error::a silent fallback to --ff-only here would let green-but-flaky"
|
|
echo "::error::branches promote red commits."
|
|
echo "::error::"
|
|
echo "::error::Fix: add to the caller's workflow's permissions block:"
|
|
echo "::error:: permissions:"
|
|
echo "::error:: contents: write"
|
|
echo "::error:: statuses: read"
|
|
echo "::error:: administration: read"
|
|
echo "::error::"
|
|
echo "::error::Or, if you intentionally want no-gate enforcement, remove"
|
|
echo "::error::branch protection on '${FROM_BRANCH}' so the API returns 404."
|
|
exit 1
|
|
;;
|
|
*)
|
|
echo "::error::Unexpected HTTP status '${HTTP_STATUS}' from branch-protection API."
|
|
echo "::error::Response (first 5 lines):"
|
|
echo "$PROTECTION_RESP" | head -5 | sed 's/^/::error:: /'
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
GATES=$(echo "${GATES_JSON}" | jq -r '.contexts[]?' 2>/dev/null || true)
|
|
|
|
if [ -z "$GATES" ]; then
|
|
echo "::notice::Branch protection on '${FROM_BRANCH}' has zero required-status-checks contexts — relying on --ff-only safety."
|
|
echo "ok=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Required gates on '${FROM_BRANCH}':"
|
|
echo "${GATES}" | sed 's/^/ - /'
|
|
|
|
ALL_GREEN=true
|
|
while IFS= read -r gate; do
|
|
[ -z "$gate" ] && continue
|
|
|
|
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
|
|
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} — skipping promote."
|
|
ALL_GREEN=false
|
|
else
|
|
echo " ✓ ${gate}: success"
|
|
fi
|
|
done <<< "$GATES"
|
|
|
|
echo "ok=${ALL_GREEN}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Fast-forward target branch to source HEAD
|
|
if: steps.gates.outputs.ok == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
FROM_BRANCH: ${{ inputs.from-branch }}
|
|
TO_BRANCH: ${{ inputs.to-branch }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
git config user.email "actions@github.com"
|
|
git config user.name "github-actions[bot]"
|
|
|
|
# Source branch is what's checked out (workflow fires on push to
|
|
# source). Can't fetch into it. Fetch target into a local target.
|
|
git fetch origin "${TO_BRANCH}"
|
|
git checkout -B "${TO_BRANCH}" "origin/${TO_BRANCH}"
|
|
|
|
# Check if target is already at or ahead of source.
|
|
if git merge-base --is-ancestor "origin/${FROM_BRANCH}" "${TO_BRANCH}" 2>/dev/null; then
|
|
echo "${TO_BRANCH} already contains ${FROM_BRANCH}; nothing to promote."
|
|
exit 0
|
|
fi
|
|
|
|
# --ff-only refuses if target has independent commits not on
|
|
# source (divergence — hotfix direct to target). Human resolves.
|
|
if ! git merge --ff-only "origin/${FROM_BRANCH}" 2>&1; then
|
|
echo "::warning::${TO_BRANCH} has diverged from ${FROM_BRANCH} — refusing fast-forward. Resolve manually (likely a direct-to-${TO_BRANCH} commit exists that ${FROM_BRANCH} doesn't have)."
|
|
exit 0
|
|
fi
|
|
|
|
git push origin "${TO_BRANCH}"
|
|
echo "::notice::Promoted: ${TO_BRANCH} is now at $(git rev-parse --short HEAD)"
|