name: branch-protection drift check

# Catches out-of-band edits to branch protection (UI clicks, manual gh
# api PATCH from a one-off ops session) by comparing live state against
# tools/branch-protection/apply.sh's desired state every day. Fails the
# workflow when they drift; the failure is the signal.
#
# When it fails: re-run apply.sh to put the live state back to the
# script's intent, OR update apply.sh to encode the new intent and
# commit. Either way the script is the source of truth.

on:
  schedule:
    # 14:00 UTC daily. Off-hours for most teams; gives a fresh signal
    # at the start of every working day.
    - cron: '0 14 * * *'
  workflow_dispatch:
  pull_request:
    branches: [staging, main]
    paths:
      - 'tools/branch-protection/**'
      - '.github/workflows/**'
      - '.github/workflows/branch-protection-drift.yml'

permissions:
  contents: read

jobs:
  drift:
    name: Branch protection drift
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      # Token strategy by trigger:
      #
      # - schedule (daily canary): hard-fail when the admin token is
      #   missing. This is the *only* trigger where silent soft-skip is
      #   dangerous — a missing secret on the cron run means the drift
      #   gate has effectively disappeared with no human in the loop to
      #   notice. Per feedback_schedule_vs_dispatch_secrets_hardening.md
      #   the rule is "schedule/automated triggers must hard-fail".
      #
      # - pull_request (touching tools/branch-protection/**): soft-skip
      #   with a prominent warning. A PR cannot retroactively drift the
      #   live state — drift happens *between* PRs (UI clicks, manual
      #   gh api PATCH) and is the schedule's job to catch. The PR-time
      #   gate would only catch typos in apply.sh, which the apply.sh
      #   *_payload unit tests catch better. A human is reviewing the
      #   PR and will see the warning in the workflow log.
      #
      # - workflow_dispatch (operator one-off): soft-skip with warning,
      #   so an operator can run a diagnostic without configuring the
      #   secret first.
      - name: Verify admin token present (hard-fail on schedule only)
        env:
          GH_TOKEN_FOR_ADMIN_API: ${{ secrets.GH_TOKEN_FOR_ADMIN_API }}
        run: |
          if [[ -n "$GH_TOKEN_FOR_ADMIN_API" ]]; then
            echo "GH_TOKEN_FOR_ADMIN_API present — drift_check will run with admin scope."
            exit 0
          fi
          if [[ "${{ github.event_name }}" == "schedule" ]]; then
            echo "::error::GH_TOKEN_FOR_ADMIN_API secret missing on the daily canary." >&2
            echo "" >&2
            echo "The schedule run is the SoT for branch-protection drift detection." >&2
            echo "Without admin scope it silently passes, hiding any out-of-band edits." >&2
            echo "Set GH_TOKEN_FOR_ADMIN_API at Settings → Secrets and variables → Actions." >&2
            exit 1
          fi
          echo "::warning::GH_TOKEN_FOR_ADMIN_API secret missing — drift_check will be SKIPPED."
          echo "::warning::PR drift checks need repo-admin scope to read /branches/:b/protection."
          echo "::warning::This is non-fatal: the daily schedule run is the canonical drift gate."
          echo "SKIP_DRIFT_CHECK=1" >> "$GITHUB_ENV"

      - name: Run drift check
        if: env.SKIP_DRIFT_CHECK != '1'
        env:
          # Repo-admin scope, needed for /branches/:b/protection.
          GH_TOKEN: ${{ secrets.GH_TOKEN_FOR_ADMIN_API }}
        run: bash tools/branch-protection/drift_check.sh

      # Self-test the parity script before running it on the real
      # workflows — pins the script's classification logic against
      # synthetic safe/unsafe/missing/unsafe-mix/matrix fixtures so a
      # regression in the script can't false-pass on the production
      # workflow audit. Cheap (~0.5s); always runs.
      - name: Self-test check-name parity script
        run: bash tools/branch-protection/test_check_name_parity.sh

      # Check-name parity gate (#144 / saved memory
      # feedback_branch_protection_check_name_parity).
      #
      # drift_check.sh asserts the live branch protection matches what
      # apply.sh would set; check_name_parity.sh closes the orthogonal
      # gap: it asserts every required check name in apply.sh maps to a
      # workflow job whose "always emits this status" shape is intact.
      #
      # The two checks fail in different scenarios:
      #
      #   - drift_check fails → live state was rewritten out-of-band
      #     (UI click, manual PATCH).
      #   - check_name_parity fails → an apply.sh required name has no
      #     emitter, OR the emitting workflow has a top-level paths:
      #     filter without per-step if-gates (the silent-block shape).
      #
      # Cheap (~1s); runs without the admin token because it only reads
      # apply.sh + .github/workflows/ from the checkout.
      - name: Run check-name parity gate
        run: bash tools/branch-protection/check_name_parity.sh
