name: Auto-sync main → staging

# Reflects every push to `main` back onto `staging` so the
# staging-as-superset-of-main invariant holds.
#
# ============================================================
# What this workflow does
# ============================================================
#
# On every push to `main`:
#   1. Checks if staging already contains main → no-op.
#   2. Fetches both branches, merges main into staging in the
#      runner workspace (fast-forward if possible, else
#      `--no-ff` merge commit).
#   3. Pushes staging directly to origin via the
#      `devops-engineer` persona's `AUTO_SYNC_TOKEN`.
#
# Authoritative path: a single `git push origin staging` from
# inside this workflow is the SSOT for advancing staging after
# a main push. No PR, no merge queue, no human approval —
# staging is mechanically maintained as a superset of main.
#
# `auto-promote-staging.yml` is the reverse-direction
# counterpart (staging → main, gated on green CI). Together
# they keep the staging-superset-of-main invariant tight.
#
# ============================================================
# Why direct push (and not "open a PR")
# ============================================================
#
# Pre-2026-05-06 the canonical SCM was GitHub.com, where:
#   - The `staging` branch had a `merge_queue` ruleset that
#     blocked ALL direct pushes (no bypass even for org
#     admins or the GitHub Actions integration).
#   - Therefore this workflow opened a PR via `gh pr create`
#     and let auto-merge land it through the queue.
#
# Post-2026-05-06 the canonical SCM is Gitea
# (`git.moleculesai.app/molecule-ai/molecule-core`). Gitea:
#   - Has no `merge_queue` concept.
#   - Allows direct push to protected branches via per-user
#     `push_whitelist_usernames` on the branch protection.
#   - Does not expose a GraphQL endpoint, so `gh pr create`
#     returns `HTTP 405 Method Not Allowed
#     (https://git.moleculesai.app/api/graphql)` — the
#     pre-suspension architecture cannot work on Gitea.
#
# The molecule-ai/molecule-core staging branch protection
# (verified via `GET /api/v1/repos/.../branch_protections`)
# whitelists `devops-engineer` for direct push. So the
# correct Gitea-shape architecture is: authenticate as
# `devops-engineer`, merge locally, push staging directly.
#
# This is structurally simpler than the GitHub-era PR dance
# and removes the dependence on `gh` CLI / GraphQL entirely.
#
# ============================================================
# Identity + token (anti-bot-ring per saved-memory
# `feedback_per_agent_gitea_identity_default`)
# ============================================================
#
# This workflow uses `secrets.AUTO_SYNC_TOKEN`, which is a
# personal access token issued to the `devops-engineer`
# persona on Gitea — NOT the founder PAT. The bot-ring
# fingerprint that triggered the GitHub org suspension on
# 2026-05-06 was characterised by founder PAT acting as CI
# at machine speed; per-persona identities split the
# attribution honestly.
#
# Token scope on Gitea: repo write. Push target restricted
# to `staging` (this workflow is the only writer; main is
# untouched). Compromise blast radius: bounded to staging
# branch + this repo's read surface.
#
# Commits are authored by the persona email
# `devops-engineer@agents.moleculesai.app` so commit history
# reflects which automation produced the merge.
#
# ============================================================
# Failure modes & operational notes
# ============================================================
#
# A — staging has commits main doesn't, and the merge
#     conflicts:
#     - The `--no-ff` merge step exits non-zero. Workflow
#       fails red. Operator (devops-engineer or human)
#       resolves manually:
#         git fetch origin
#         git checkout staging
#         git merge --no-ff origin/main
#         # resolve conflicts
#         git push origin staging
#     - Step summary surfaces the conflict so the failed run
#       is self-explanatory.
#
# B — `AUTO_SYNC_TOKEN` rotated / wrong scope:
#     - `git push` step exits non-zero with `HTTP 401` /
#       `403`. Step summary surfaces the failed push.
#     - Re-issue the token from `~/.molecule-ai/personas/`
#       on the operator host and update the repo Actions
#       secret. Re-run the workflow.
#
# C — staging branch protection no longer whitelists
#     `devops-engineer`:
#     - `git push` exits non-zero with a Gitea protected-
#       branch rejection. Step summary surfaces it.
#     - Re-add `devops-engineer` to
#       `push_whitelist_usernames` on the staging
#       protection (Settings → Branches → staging).
#
# D — concurrent push to main while a sync is in flight:
#     - The `concurrency` group below serialises runs.
#       The second waits for the first; if main advances
#       again while we're syncing, the second run picks
#       up the new tip on its own fetch.
#
# ============================================================
# Loop safety
# ============================================================
#
# The push to staging from this workflow does NOT itself
# fire a `push: branches: [main]` event (different branch),
# so there's no risk of self-recursion. `auto-promote-staging.yml`
# fires on `workflow_run` of CI etc. — it sees the new
# staging tip on its next gate-completion event, NOT on this
# push directly. No loop.

on:
  push:
    branches: [main]
  # workflow_dispatch lets operators manually backfill a
  # missed sync (e.g. if AUTO_SYNC_TOKEN was rotated and a
  # main push slipped through while the secret was stale).
  workflow_dispatch:

permissions:
  contents: write

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

jobs:
  sync-staging:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout staging (with devops-engineer push token)
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          ref: staging
          # AUTO_SYNC_TOKEN authenticates as the
          # `devops-engineer` Gitea persona — the only
          # identity whitelisted for direct push to
          # staging. See header comment for context.
          token: ${{ secrets.AUTO_SYNC_TOKEN }}

      - name: Configure git author
        run: |
          # Per-persona identity, NOT founder PAT.
          # `feedback_per_agent_gitea_identity_default`.
          git config user.name "devops-engineer"
          git config user.email "devops-engineer@agents.moleculesai.app"

      - 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 "::notice::staging is missing main's tip (${MAIN_SHORT}) — merging in-runner and pushing"
          fi

      - name: Merge main into staging (in-runner)
        if: steps.check.outputs.needs_sync == 'true'
        id: merge
        run: |
          set -euo pipefail
          # Already on staging from checkout. Try fast-forward
          # first (cleanest history); fall back to merge commit
          # if staging has commits main doesn't.
          if git merge --ff-only origin/main; then
            echo "did_ff=true" >> "$GITHUB_OUTPUT"
            echo "::notice::Fast-forwarded staging to origin/main"
          else
            echo "did_ff=false" >> "$GITHUB_OUTPUT"
            if ! git merge --no-ff origin/main \
                -m "chore: sync main → staging (auto, ${{ steps.check.outputs.main_short }})"; 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 (or devops-engineer persona) needs to resolve manually:"
                echo
                echo '```'
                echo "git fetch origin"
                echo "git checkout staging"
                echo "git merge --no-ff origin/main"
                echo "# resolve conflicts"
                echo "git push origin staging"
                echo '```'
              } >> "$GITHUB_STEP_SUMMARY"
              exit 1
            fi
          fi

      - name: Push staging to origin
        if: steps.check.outputs.needs_sync == 'true'
        run: |
          set -euo pipefail
          # Direct push to staging. devops-engineer persona is
          # whitelisted for direct push on the staging branch
          # protection (Settings → Branches → staging).
          #
          # No --force / --force-with-lease: a fast-forward or
          # legitimate merge commit on top of current staging
          # is the only thing we'd ever push. If origin/staging
          # advanced under us (concurrent merge), the push
          # legitimately rejects and the next run picks up the
          # new state.
          if ! git push origin staging; then
            {
              echo "## Push rejected"
              echo
              echo "Direct push to \`staging\` failed. Likely causes:"
              echo "- \`AUTO_SYNC_TOKEN\` rotated / wrong scope (HTTP 401/403)"
              echo "- \`devops-engineer\` no longer in"
              echo "  \`push_whitelist_usernames\` on the staging"
              echo "  branch protection (HTTP 422)"
              echo "- staging advanced concurrently — re-running this"
              echo "  workflow on the new main tip will pick it up"
            } >> "$GITHUB_STEP_SUMMARY"
            exit 1
          fi

          {
            echo "## Auto-sync succeeded"
            echo
            echo "- staging advanced to: \`$(git rev-parse --short=8 HEAD)\`"
            echo "- main tip: \`${{ steps.check.outputs.main_short }}\`"
            echo "- Strategy: $([ "${{ steps.merge.outputs.did_ff }}" = "true" ] && echo "fast-forward" || echo "merge commit")"
            echo "- Pushed by: \`devops-engineer\` (per-agent persona, anti-bot-ring)"
          } >> "$GITHUB_STEP_SUMMARY"
