molecule-ci/.github/workflows/auto-promote-branch.yml
security-auditor 2bcd52b444 fix(ci): lowercase 'molecule-ai/' in cross-repo workflow refs
Gitea is case-sensitive on owner slugs; canonical is lowercase
`molecule-ai/...`. Mixed-case `Molecule-AI/...` refs fail-at-0s
when the runner tries to resolve the cross-repo workflow / checkout.

Same fix as molecule-controlplane#12. Mechanical case-correction;
no behavior change beyond making CI resolve again.

Refs: internal#46

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:58:55 -07:00

220 lines
9.3 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@v1
# 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.
#
# `@v1` is a moving tag pointing at the latest 1.x release of
# molecule-ci's reusable workflows (GitHub Actions convention, same
# as `actions/checkout@v4`). Breaking changes get a new `@v2` tag
# and the old `@v1` keeps working for existing consumers. Pinning to
# `@main` is also accepted for forward-compat preview but is
# unstable — any change merged here rolls out instantly to consumers
# without a release boundary.
#
# `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)"