feat(auto-promote): extract as reusable workflow_call for org-wide adoption

Splits auto-promote-staging.yml into:

  - auto-promote-branch.yml — new reusable workflow with
    `on: workflow_call`. Inputs `from-branch` (default 'staging') and
    `to-branch` (default 'main'). Repo-agnostic: gates are read from
    the consuming repo's branch protection at run time, not hardcoded.

  - auto-promote-staging.yml — molecule-ci's own self-running flow,
    now a ~25-line wrapper that calls the reusable workflow with
    staging→main hardcoded. Trigger and behavior unchanged for
    molecule-ci itself.

Adoption pattern in any consumer repo:

    # .github/workflows/auto-promote.yml
    name: Auto-promote staging → main
    on:
      push:
        branches: [staging]
      workflow_dispatch:
    permissions:
      contents: write
      statuses: read
    jobs:
      promote:
        uses: Molecule-AI/molecule-ci/.github/workflows/auto-promote-branch.yml@main
        with:
          from-branch: staging
          to-branch: main

Excluded by policy: molecule-core + molecule-controlplane stay
manual per CEO directive 2026-04-24. Those repos do NOT adopt the
reusable workflow; the extraction adds no surface to repos that
don't call it.

Closes monorepo task #93.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-27 18:33:01 -07:00
parent 9c7f4f5542
commit 9d67da3ef9
2 changed files with 154 additions and 103 deletions

View File

@ -0,0 +1,143 @@
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
# statuses: read
# 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.
#
# 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
# Try to read required gates from branch protection. Free-tier
# private repos may 403; handle that gracefully.
GATES_JSON=$(gh api "repos/${REPO}/branches/${FROM_BRANCH}/protection/required_status_checks" 2>/dev/null || echo '{}')
GATES=$(echo "${GATES_JSON}" | jq -r '.contexts[]?' 2>/dev/null || true)
if [ -z "$GATES" ]; then
echo "No required gates configured on '${FROM_BRANCH}' (or API inaccessible). 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)"

View File

@ -1,24 +1,14 @@
name: Auto-promote staging → main
# Fast-forwards `main` to `staging` when staging is strictly ahead (main
# is an ancestor). Eliminates the manual sync-PR round for non-critical
# repos.
# molecule-ci's own auto-promote: thin wrapper over the reusable
# `auto-promote-branch.yml` workflow factored out for org-wide reuse.
# Other repos consume the same reusable workflow via:
#
# 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.
# uses: Molecule-AI/molecule-ci/.github/workflows/auto-promote-branch.yml@main
#
# Excluded by policy: molecule-core + molecule-controlplane. Those two
# stay manual per CEO directive 2026-04-24.
#
# 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
# Excluded by policy: molecule-core + molecule-controlplane stay
# manual per CEO directive 2026-04-24. Those repos do NOT call the
# reusable workflow.
on:
push:
@ -31,89 +21,7 @@ permissions:
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 staging HEAD
id: gates
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
# 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 "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/^/ - /'
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 main to staging
if: steps.gates.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]"
# staging is the checked-out branch (workflow fires on push to
# staging). Can't fetch into it. Fetch main into a local main.
git fetch origin main
git checkout -B main origin/main
# Check if main is already at or ahead of origin/staging.
if git merge-base --is-ancestor origin/staging main 2>/dev/null; then
echo "main already contains staging; nothing to promote."
exit 0
fi
# --ff-only refuses if main has independent commits not on
# staging (divergence — hotfix direct to main). Human resolves.
if ! git merge --ff-only origin/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 "::notice::Promoted: main is now at $(git rev-parse --short HEAD)"
uses: ./.github/workflows/auto-promote-branch.yml
with:
from-branch: staging
to-branch: main