Merge pull request #25 from Molecule-AI/auto/p9-reusable-auto-promote

ci: extract PR-based auto-promote-staging into reusable workflow (P9)
This commit is contained in:
Hongming Wang 2026-04-30 01:02:34 -07:00 committed by GitHub
commit 6afeb47e5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -0,0 +1,262 @@
name: Auto-promote staging → main (PR-based, reusable)
# Reusable PR-based auto-promote for repos whose `main` branch has
# protection rules that require status checks "set by the expected
# GitHub apps" — direct `git push` from a workflow can't satisfy
# that, only PR merges through the merge queue can.
#
# Distinct from the simpler ff-only auto-promote in this same repo
# (auto-promote-staging.yml): that one does `git merge --ff-only` +
# direct push and only works on repos WITHOUT required-status-checks.
# This reusable workflow is for the protected-branch case.
#
# Call from each repo's .github/workflows/ via a thin wrapper:
#
# name: Auto-promote staging → main
# on:
# workflow_run:
# workflows: [CI, E2E Staging Canvas, ...]
# types: [completed]
# workflow_dispatch:
# inputs:
# force:
# description: "Force promote (manual override)"
# required: false
# default: "false"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# promote:
# uses: Molecule-AI/molecule-ci/.github/workflows/auto-promote-staging-pr.yml@main
# with:
# gates: "ci.yml,e2e-staging-canvas.yml,e2e-api.yml,codeql.yml"
# force: ${{ github.event.inputs.force == 'true' }}
# secrets: inherit
#
# IMPORTANT: the caller MUST keep the `on.workflow_run.workflows`
# display-name list in sync with the `gates` input (which uses
# workflow filenames). The reusable can't validate this — display
# names and filenames are decoupled in GitHub Actions.
#
# Required repo settings (one-time, in the CALLER repo):
#
# Settings → Actions → General → Workflow permissions
# → ✅ Allow GitHub Actions to create and approve pull requests
#
# Without it, every workflow run fails with:
#
# pull request create failed: GraphQL: GitHub Actions is not
# permitted to create or approve pull requests (createPullRequest)
#
# Toggle: caller repo variable AUTO_PROMOTE_ENABLED=true. Override
# via the `enabled-var` input if a different name is needed.
# When the variable is unset, the workflow logs what it would have
# done but doesn't open the PR — useful for dry-running the gate
# logic without surfacing a noisy PR while staging CI is still flaky.
on:
workflow_call:
inputs:
gates:
description: >-
Comma-separated list of workflow FILENAMES (not display
names) that must be conclusion=success on the staging head
SHA before promote fires. Example:
"ci.yml,e2e-staging-canvas.yml,codeql.yml". File paths are
used (not display names) because gh run list with display
names is ambiguous when two workflows share a name (observed
2026-04-28 with codeql.yml + GitHub UI's Code-quality default
setup both surfacing as "CodeQL").
required: true
type: string
target-branch:
description: "Target branch to promote TO (default: main)"
required: false
type: string
default: main
source-branch:
description: "Source branch to promote FROM (default: staging)"
required: false
type: string
default: staging
enabled-var:
description: >-
Repo variable name that gates this workflow. Set this
variable to "true" in the caller repo's Settings →
Variables → Actions to enable. Defaults to
AUTO_PROMOTE_ENABLED.
required: false
type: string
default: AUTO_PROMOTE_ENABLED
merge-method:
description: >-
Merge method for `gh pr merge --auto`. One of merge|squash|
rebase. Defaults to "merge" (matches user preference for
merge commits over squash).
required: false
type: string
default: merge
force:
description: >-
Skip the AUTO_PROMOTE_ENABLED variable check. Pass true
when the caller's workflow_dispatch input is force=true.
Default false.
required: false
type: boolean
default: false
jobs:
check-all-gates-green:
# Only consider promotions for the source branch's push events.
# PR runs into the source branch don't promote. workflow_dispatch
# passes through unconditionally.
if: >
(github.event_name == 'workflow_run' &&
github.event.workflow_run.head_branch == inputs.source-branch &&
github.event.workflow_run.event == 'push')
|| github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
outputs:
all_green: ${{ steps.gates.outputs.all_green }}
head_sha: ${{ steps.gates.outputs.head_sha }}
steps:
- name: Check all required gates on this SHA
id: gates
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
REPO: ${{ github.repository }}
GATES_CSV: ${{ inputs.gates }}
SOURCE_BRANCH: ${{ inputs.source-branch }}
run: |
set -euo pipefail
# Split the comma-separated gates input. Trim whitespace per
# entry so callers can format readably (e.g. "ci.yml, e2e.yml").
IFS=',' read -ra GATES <<< "$GATES_CSV"
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
echo "Checking gates on SHA ${HEAD_SHA}"
ALL_GREEN=true
for gate_raw in "${GATES[@]}"; do
gate="${gate_raw## }"
gate="${gate%% }"
if [ -z "$gate" ]; then
continue
fi
# Query the most recent run of this workflow on this SHA.
# event=push to avoid picking up PR runs. branch filter
# guards against someone dispatching the gate on a non-
# source branch at the same SHA.
RESULT=$(gh run list \
--repo "$REPO" \
--workflow "$gate" \
--branch "$SOURCE_BRANCH" \
--event push \
--commit "$HEAD_SHA" \
--limit 1 \
--json status,conclusion \
--jq '.[0] | "\(.status)/\(.conclusion // "none")"' \
2>/dev/null || echo "missing/none")
echo " $gate → $RESULT"
# Only completed/success counts. Anything else aborts.
if [ "$RESULT" != "completed/success" ]; then
ALL_GREEN=false
fi
done
echo "all_green=${ALL_GREEN}" >> "$GITHUB_OUTPUT"
if [ "$ALL_GREEN" != "true" ]; then
echo "::notice::auto-promote: not all gates are green on ${HEAD_SHA} — staying on current ${{ inputs.target-branch }}"
fi
promote:
needs: check-all-gates-green
if: needs.check-all-gates-green.outputs.all_green == 'true'
runs-on: ubuntu-latest
steps:
- name: Check rollout gate
env:
ENABLED_VAR_NAME: ${{ inputs.enabled-var }}
ENABLED_VAR_VALUE: ${{ vars[inputs.enabled-var] }}
FORCE: ${{ inputs.force }}
run: |
set -eu
# Caller repo controls rollout via the named variable.
# Default name is AUTO_PROMOTE_ENABLED; callers can override.
if [ "${ENABLED_VAR_VALUE:-}" != "true" ] && [ "${FORCE:-false}" != "true" ]; then
{
echo "## ⏸ Auto-promote disabled"
echo
echo "Repo variable \`${ENABLED_VAR_NAME}\` is not set to \`true\`."
echo "All gates are green on ${{ inputs.source-branch }}; would have opened a promote PR to \`${{ inputs.target-branch }}\`."
echo
echo "To enable: Settings → Secrets and variables → Actions → Variables → \`${ENABLED_VAR_NAME}=true\`."
echo "To test once manually: workflow_dispatch with \`force=true\`."
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::auto-promote disabled — dry run only"
exit 0
fi
- name: Open (or reuse) ${{ inputs.source-branch }} → ${{ inputs.target-branch }} promote PR + enable auto-merge
if: ${{ vars[inputs.enabled-var] == 'true' || inputs.force == true }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TARGET_SHA: ${{ needs.check-all-gates-green.outputs.head_sha }}
SOURCE_BRANCH: ${{ inputs.source-branch }}
TARGET_BRANCH: ${{ inputs.target-branch }}
MERGE_METHOD: ${{ inputs.merge-method }}
GATES_CSV: ${{ inputs.gates }}
run: |
set -euo pipefail
# Look for an existing open promote PR (idempotent on re-run).
# The PR's head IS the source branch — the whole point is
# "advance target to source's tip", so we don't need a per-SHA
# branch like auto-sync-main-to-staging.yml uses.
PR_NUM=$(gh pr list --repo "$REPO" \
--base "$TARGET_BRANCH" --head "$SOURCE_BRANCH" --state open \
--json number --jq '.[0].number // ""')
if [ -z "$PR_NUM" ]; then
TITLE="${SOURCE_BRANCH} → ${TARGET_BRANCH}: auto-promote ${TARGET_SHA:0:7}"
BODY_FILE=$(mktemp)
cat > "$BODY_FILE" <<EOFBODY
Automated promotion of \`${SOURCE_BRANCH}\` (\`${TARGET_SHA:0:8}\`) to \`${TARGET_BRANCH}\`. Required gates green at this SHA: ${GATES_CSV}.
This PR is auto-generated by a thin caller of \`Molecule-AI/molecule-ci/.github/workflows/auto-promote-staging-pr.yml\` whenever every required gate completes green on the same source-branch SHA. It exists because protected branches require status checks "set by the expected GitHub apps" — direct \`git push\` from a workflow can't satisfy that, only PR merges through the queue can.
Merge queue lands this; no human action needed unless gates fail.
EOFBODY
PR_URL=$(gh pr create --repo "$REPO" \
--base "$TARGET_BRANCH" --head "$SOURCE_BRANCH" \
--title "$TITLE" \
--body-file "$BODY_FILE")
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$' | tail -1)
rm -f "$BODY_FILE"
echo "::notice::Opened PR #${PR_NUM}"
else
echo "::notice::Re-using existing promote PR #${PR_NUM}"
fi
# Enable auto-merge — the merge queue picks it up once
# required gates are green on the merge_group ref.
if ! gh pr merge "$PR_NUM" --repo "$REPO" --auto --"$MERGE_METHOD" 2>&1; then
echo "::warning::Failed to enable auto-merge on PR #${PR_NUM} — operator may need to merge manually."
fi
{
echo "## ✅ Auto-promote PR opened"
echo
echo "- Source: \`${SOURCE_BRANCH}\` at \`${TARGET_SHA:0:8}\`"
echo "- Target: \`${TARGET_BRANCH}\`"
echo "- PR: #${PR_NUM}"
echo
echo "Merge queue lands the PR once required gates are green; no human action needed unless gates fail."
} >> "$GITHUB_STEP_SUMMARY"