ci: extract PR-based auto-promote-staging into reusable workflow (P9)
Moves the canonical PR-based staging→main auto-promote flow into a
reusable workflow that protected-branch repos can call instead of
duplicating ~240 lines of YAML each.
Why two reusable variants in this repo:
auto-promote-staging.yml (existing — ff-only, direct push)
For repos WITHOUT required-status-checks branch protection.
Already used for molecule-ci, molecule-app, molecule-docs,
molecule-monorepo. Cannot satisfy protected-branch rules
requiring status checks "set by expected GitHub apps".
auto-promote-staging-pr.yml (THIS PR — PR-based)
For repos WITH required-status-checks. Opens (or reuses) a
staging→main PR, enables auto-merge, lets the merge queue land
it. Required path for molecule-core + molecule-controlplane
(per the 2026-04-28 incident where direct ff-only push was
failing GH006 on protected refs).
Inputs:
gates — CSV of workflow filenames to require green
target-branch — promote target (default: main)
source-branch — promote source (default: staging)
enabled-var — repo variable name gating rollout
(default: AUTO_PROMOTE_ENABLED)
merge-method — merge|squash|rebase (default: merge — matches
user preference for merge commits over squash)
force — pass through caller's workflow_dispatch.force input
Caller pattern (kept minimal — see header comment in the workflow):
on:
workflow_run:
workflows: [CI, ...]
types: [completed]
workflow_dispatch:
inputs:
force: ...
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,..."
force: ${{ github.event.inputs.force == 'true' }}
secrets: inherit
The caller's `on.workflow_run.workflows` (display names) MUST stay in
sync with the `gates` input (filenames). The reusable can't validate
this because GitHub Actions decouples display names from filenames;
this is the same coupling the original molecule-core workflow had.
Migration of the existing 242-line molecule-core workflow to this
reusable is a follow-up PR. Same pattern applies to
molecule-controlplane once it grows protected-branch
auto-promote (today CP uses the auto-sync-main-to-staging shape
inherited from #142).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e21371f40e
commit
e7c6798fba
262
.github/workflows/auto-promote-staging-pr.yml
vendored
Normal file
262
.github/workflows/auto-promote-staging-pr.yml
vendored
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user