name: Auto-sync main → staging

# Reflects every push to `main` back onto `staging` so the
# staging-as-superset-of-main invariant holds.
#
# Background:
#
# `auto-promote-staging.yml` advances main via `git merge --ff-only`
# + `git push origin main` — that's a clean fast-forward, no merge
# commit. But manual merges of `staging → main` PRs through the
# GitHub UI / API create a merge commit on main that staging
# doesn't have. The next `staging → main` PR then evaluates as
# "BEHIND" because staging is missing that merge commit, requiring
# a manual `gh pr update-branch` round-trip.
#
# This happened twice on 2026-04-28 (PRs #2202, #2205, both manual
# bridges). Each time the bridge needed update-branch + a re-CI
# round before merging. Operationally annoying and avoidable.
#
# Architecture:
#
# This repo's `staging` branch is protected by a `merge_queue`
# ruleset (id 15500102) that blocks ALL direct pushes — no bypass
# even for org admins or the GitHub Actions integration. Direct
# `git push origin staging` returns GH013. So instead of pushing
# directly, this workflow:
#
#   1. Checks if main is already in staging's ancestry → no-op.
#   2. Creates an `auto-sync/main-<sha>` branch from staging.
#   3. Tries `git merge --ff-only origin/main` → if staging hasn't
#      diverged this is a clean ff.
#   4. Otherwise `git merge --no-ff origin/main` to absorb main's
#      tip while keeping staging's history.
#   5. Pushes the auto-sync branch.
#   6. Opens a PR (base=staging, head=auto-sync/main-<sha>) and
#      enables auto-merge so the merge queue lands it.
#
# This mirrors the path human PRs take through staging — same
# rules, same gates, no special-case bypass.
#
# Loop safety:
#
# `GITHUB_TOKEN`-authored merges (including the merge queue's land
# of the auto-sync PR) do NOT trigger downstream workflow runs
# (GitHub Actions safety). So when the auto-sync PR lands on
# staging, `auto-promote-staging.yml` is NOT triggered by that
# push. The next developer push to staging triggers auto-promote
# normally. No loop possible.
#
# Concurrency:
#
# Two pushes to main in quick succession (e.g., manual UI merge
# immediately followed by auto-promote-staging's ff-merge) could
# otherwise open two overlapping auto-sync PRs. The concurrency
# group serializes runs; the second waits for the first to exit.
# (The first run exits after opening + auto-merge-queueing the PR,
# not after the merge actually completes — so multiple PRs can be
# open simultaneously, but the merge queue handles them serially.)

on:
  push:
    branches: [main]
  # workflow_dispatch lets:
  #   1. Operators manually backfill a missed sync (e.g. after a manual
  #      UI merge that the runner missed).
  #   2. auto-promote-staging.yml's polling tail explicitly invoke us
  #      after the promote PR lands. This is load-bearing: when the
  #      merge queue lands a promote-PR merge, the resulting push to
  #      `main` is "by GITHUB_TOKEN", and per GitHub's no-recursion
  #      rule (https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)
  #      that push event does NOT fire any downstream workflows. The
  #      `on: push` trigger above is silently dead for the very pattern
  #      we exist to handle. Verified empirically 2026-05-02 against
  #      SHA 76c604fb (PR #2437 staging→main): only ONE workflow fired
  #      (publish-workspace-server-image, dispatched explicitly by
  #      auto-promote's polling tail with an App token). Every other
  #      `on: push: branches: [main]` workflow — including this one —
  #      was suppressed. Until the underlying merge call moves to an
  #      App token, an explicit dispatch is the only reliable path.
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

concurrency:
  group: auto-sync-main-to-staging
  cancel-in-progress: false

jobs:
  sync-staging:
    # ubuntu-latest matches every other workflow in this repo. The
    # earlier `[self-hosted, macos, arm64]` was a copy-paste artefact
    # from the molecule-controlplane repo (which IS private and uses a
    # Mac runner) — molecule-core has no Mac runner registered, so the
    # job sat unassigned whenever the trigger fired. Verified 2026-05-02:
    # this is the ONLY workflow in molecule-core/.github/workflows/ with
    # a non-ubuntu runs-on.
    runs-on: ubuntu-latest
    steps:
      - name: Checkout staging
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          ref: staging
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Configure git author
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Check if staging already contains main
        id: check
        run: |
          set -euo pipefail
          git fetch origin main
          if git merge-base --is-ancestor origin/main HEAD; then
            echo "needs_sync=false" >> "$GITHUB_OUTPUT"
            {
              echo "## ✅ No-op"
              echo
              echo "staging already contains \`origin/main\` ($(git rev-parse --short=8 origin/main))."
            } >> "$GITHUB_STEP_SUMMARY"
          else
            echo "needs_sync=true" >> "$GITHUB_OUTPUT"
            MAIN_SHORT=$(git rev-parse --short=8 origin/main)
            echo "main_short=${MAIN_SHORT}" >> "$GITHUB_OUTPUT"
            echo "branch=auto-sync/main-${MAIN_SHORT}" >> "$GITHUB_OUTPUT"
            echo "::notice::staging is missing main's tip (${MAIN_SHORT}) — opening sync PR"
          fi

      - name: Create auto-sync branch + merge main
        if: steps.check.outputs.needs_sync == 'true'
        id: prep
        run: |
          set -euo pipefail
          BRANCH="${{ steps.check.outputs.branch }}"

          # If a previous auto-sync run already opened a branch for the
          # same main sha, prefer reusing it (idempotent behavior on
          # workflow restart). Force-update from latest staging anyway
          # so it absorbs any staging-side commits that landed since.
          git checkout -B "$BRANCH"

          if git merge --ff-only origin/main; then
            echo "did_ff=true" >> "$GITHUB_OUTPUT"
            echo "::notice::Fast-forwarded ${BRANCH} to origin/main"
          else
            echo "did_ff=false" >> "$GITHUB_OUTPUT"
            if ! git merge --no-ff origin/main -m "chore: sync main → staging (auto)"; then
              # Hygiene: leave the work tree clean before failing.
              git merge --abort || true
              {
                echo "## ❌ Conflict"
                echo
                echo "Auto-merge \`main → staging\` failed with conflicts."
                echo "A human needs to resolve manually."
              } >> "$GITHUB_STEP_SUMMARY"
              exit 1
            fi
          fi

      - name: Push auto-sync branch
        if: steps.check.outputs.needs_sync == 'true'
        run: |
          set -euo pipefail
          # Force-with-lease so a concurrent auto-sync run can't
          # silently clobber an in-flight branch we just updated. If a
          # different writer touched the branch, we abort and the next
          # run picks up the latest state.
          git push --force-with-lease origin "${{ steps.check.outputs.branch }}"

      - name: Open auto-sync PR + enable auto-merge
        if: steps.check.outputs.needs_sync == 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BRANCH: ${{ steps.check.outputs.branch }}
          MAIN_SHORT: ${{ steps.check.outputs.main_short }}
          DID_FF: ${{ steps.prep.outputs.did_ff }}
        run: |
          set -euo pipefail

          # Find existing PR for this branch (idempotent on workflow
          # restart) before creating a new one.
          PR_NUM=$(gh pr list --head "$BRANCH" --base staging --state open --json number --jq '.[0].number // ""')

          if [ -z "$PR_NUM" ]; then
            # Body lives in a temp file to keep the multi-line content
            # out of the YAML block scalar (un-indented newlines inside
            # an inline shell string break YAML parsing).
            BODY_FILE=$(mktemp)
            if [ "$DID_FF" = "true" ]; then
              TITLE="chore: sync main → staging (auto, ff to ${MAIN_SHORT})"
              cat > "$BODY_FILE" <<EOFBODY
          Automated fast-forward of \`staging\` to \`origin/main\` (\`${MAIN_SHORT}\`). Staging has no in-flight commits that diverge from main. Merge queue lands this; no human action needed.

          This PR is auto-generated by \`.github/workflows/auto-sync-main-to-staging.yml\` on every push to \`main\`. It exists because this repo's \`staging\` branch has a \`merge_queue\` ruleset that blocks direct pushes — even from the GitHub Actions integration.
          EOFBODY
            else
              TITLE="chore: sync main → staging (auto, merge ${MAIN_SHORT})"
              cat > "$BODY_FILE" <<EOFBODY
          Automated merge of \`origin/main\` (\`${MAIN_SHORT}\`) into \`staging\`. Staging has commits main doesn't, so this is a non-ff merge that absorbs main's tip. Merge queue lands this.

          This PR is auto-generated by \`.github/workflows/auto-sync-main-to-staging.yml\` on every push to \`main\`.
          EOFBODY
            fi

            # gh pr create prints the URL on stdout; extract the PR number.
            PR_URL=$(gh pr create \
              --base staging \
              --head "$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 PR #${PR_NUM} for ${BRANCH}"
          fi

          # Enable auto-merge — the merge queue picks it up once
          # required gates are green. Use --merge for merge commits
          # (matches the rest of this repo's PR convention).
          if ! gh pr merge "$PR_NUM" --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-sync PR opened"
            echo
            echo "- Branch: \`$BRANCH\`"
            echo "- PR: #$PR_NUM"
            echo "- Strategy: $([ "$DID_FF" = "true" ] && echo "ff" || echo "merge commit")"
            echo
            echo "Merge queue lands the PR once required gates are green; no human action needed unless gates fail."
          } >> "$GITHUB_STEP_SUMMARY"
