name: Auto-promote staging → main

# Fires after any of the staging-branch quality gates complete. When ALL
# required gates are green on the same staging SHA, opens (or re-uses)
# a PR `staging → main` and enables auto-merge so the merge queue lands
# it. Closes the gap that historically let features sit on staging for
# weeks waiting for a bulk promotion PR (see molecule-core#1496 for the
# 1172-commit example).
#
# 2026-04-28 rewrite (PR #142): the previous version did a direct
# `git merge --ff-only origin staging && git push origin main`. That
# breaks against main's branch-protection ruleset, which requires
# status checks "set by the expected GitHub apps" — direct pushes
# can't satisfy that condition (only PR merges through the queue can).
# The workflow was failing every tick with:
#   remote: error: GH006: Protected branch update failed for refs/heads/main.
#   remote: - Required status checks ... were not set by the expected GitHub apps.
# Fix: mirror the PR-based pattern from auto-sync-main-to-staging.yml
# (the reverse-direction sync, fixed in #2234 for the same reason).
# Both directions now use the same merge-queue path that humans use,
# no special-case bypass.
#
# Safety model:
# - Runs ONLY on workflow_run events for the staging branch.
# - Requires EVERY named gate workflow to have the same head_sha and
#   all be `conclusion == success`. If any of them is red, skipped,
#   cancelled, or pending, we abort (stay on the current main).
# - The PR base=main head=staging path lets GitHub itself enforce
#   branch protection. If main has diverged from staging or required
#   checks aren't satisfied, the merge queue declines the PR — no
#   need for a manual ff-only ancestry check here.
# - Loop safety: the auto-sync-main-to-staging workflow fires when
#   main lands the auto-promote PR, but its merge into staging is by
#   GITHUB_TOKEN which doesn't trigger downstream workflow_run events
#   (GitHub Actions safety). So this workflow doesn't re-fire from
#   its own promote landing.
#
# Toggle via repo variable AUTO_PROMOTE_ENABLED (true/unset). When
# 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.
#
# **One-time repo setting (load-bearing):** this workflow opens the
# staging→main PR via `gh pr create` using the default GITHUB_TOKEN.
# Since GitHub's 2022 default change, that token cannot create or
# approve PRs unless the repo opts in. The toggle is at:
#
#   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)
#
# Observed 2026-04-29 01:43 UTC blocking promotion of fcd87b9 (PRs
# #2248 + #2249); manually bridged via PR #2252. Re-check this
# setting if auto-promote starts failing with createPullRequest
# errors after a repo or org admin change.

on:
  workflow_run:
    workflows:
      - CI
      - E2E Staging Canvas (Playwright)
      - E2E API Smoke Test
      - CodeQL
    types: [completed]
  workflow_dispatch:
    inputs:
      force:
        description: "Force promote even when AUTO_PROMOTE_ENABLED is unset (manual override)"
        required: false
        default: "false"

permissions:
  contents: write
  pull-requests: write

jobs:
  check-all-gates-green:
    # Only consider staging pushes. PRs into staging don't promote.
    if: >
      (github.event_name == 'workflow_run' &&
       github.event.workflow_run.head_branch == 'staging' &&
       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 }}
        run: |
          set -euo pipefail

          # Required gate workflow files. Use file paths (relative to
          # .github/workflows/) rather than display names because:
          #
          #   1. `gh run list --workflow=<name>` is ambiguous when two
          #      workflows have the same `name:` — observed 2026-04-28
          #      with "CodeQL" matching both `codeql.yml` (explicit) and
          #      GitHub's UI-configured Code-quality default setup
          #      (internal "codeql"). gh CLI returns "could not resolve
          #      to a unique workflow" → empty result → gate evaluated
          #      as missing/none → auto-promote dead-locked despite all
          #      checks actually passing.
          #
          #   2. File paths are the unique identifier for workflows;
          #      `name:` is just a display string and can collide.
          #
          # When adding/removing a gate, update this list AND the
          # branch-protection required-checks list (which uses check-run
          # display names, not workflow names; the two are decoupled and
          # should be kept in sync manually).
          GATES=(
            "ci.yml"
            "e2e-staging-canvas.yml"
            "e2e-api.yml"
            "codeql.yml"
          )

          echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
          echo "Checking gates on SHA ${HEAD_SHA}"

          ALL_GREEN=true
          for gate in "${GATES[@]}"; do
            # Query the most recent run of this workflow on this SHA.
            # event=push to avoid picking up PR runs. branch=staging to
            # guard against someone dispatching the gate on a non-staging
            # branch at the same SHA.
            RESULT=$(gh run list \
              --repo "$REPO" \
              --workflow "$gate" \
              --branch staging \
              --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. completed/failure or
            # in_progress/anything or no record at all = abort.
            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 main"
          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:
          AUTO_PROMOTE_ENABLED: ${{ vars.AUTO_PROMOTE_ENABLED }}
          FORCE_INPUT: ${{ github.event.inputs.force }}
        run: |
          set -eu
          # Repo variable AUTO_PROMOTE_ENABLED=true flips this on. While
          # it's unset, the workflow dry-runs (logs what it would have
          # done) but doesn't open the promote PR. Set the variable in
          # Settings → Secrets and variables → Actions → Variables.
          if [ "${AUTO_PROMOTE_ENABLED:-}" != "true" ] && [ "${FORCE_INPUT:-false}" != "true" ]; then
            {
              echo "## ⏸ Auto-promote disabled"
              echo
              echo "Repo variable \`AUTO_PROMOTE_ENABLED\` is not set to \`true\`."
              echo "All gates are green on staging; would have opened a promote PR to \`main\`."
              echo
              echo "To enable: Settings → Secrets and variables → Actions → Variables → \`AUTO_PROMOTE_ENABLED=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) staging → main promote PR + enable auto-merge
        if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          TARGET_SHA: ${{ needs.check-all-gates-green.outputs.head_sha }}
        run: |
          set -euo pipefail

          # Look for an existing open promote PR (idempotent on re-run
          # of the workflow). The PR's head IS the staging branch — the
          # whole point is "advance main to staging's tip", so we don't
          # need a per-SHA branch like auto-sync-main-to-staging uses.
          PR_NUM=$(gh pr list --repo "$REPO" \
            --base main --head staging --state open \
            --json number --jq '.[0].number // ""')

          if [ -z "$PR_NUM" ]; then
            TITLE="staging → main: auto-promote ${TARGET_SHA:0:7}"
            BODY_FILE=$(mktemp)
            cat > "$BODY_FILE" <<EOFBODY
          Automated promotion of \`staging\` (\`${TARGET_SHA:0:8}\`) to \`main\`. All required staging gates green at this SHA: CI, E2E Staging Canvas, E2E API Smoke, CodeQL.

          This PR is auto-generated by \`.github/workflows/auto-promote-staging.yml\` whenever every required gate completes green on the same staging SHA. It exists because main's branch protection requires 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. Reverse-direction sync (the merge commit on main → staging) is handled by \`auto-sync-main-to-staging.yml\`.
          EOFBODY
            PR_URL=$(gh pr create --repo "$REPO" \
              --base main --head staging \
              --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 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: staging at \`${TARGET_SHA:0:8}\`"
            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"
