diff --git a/.github/workflows/auto-promote-staging-pr.yml b/.github/workflows/auto-promote-staging-pr.yml new file mode 100644 index 0000000..b908378 --- /dev/null +++ b/.github/workflows/auto-promote-staging-pr.yml @@ -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" <&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"