name: Corpus Drift Check

# Canary for the public results corpus.
#
# `sync-results-data-to-published.yml` refreshes develop's curated maintainer
# subset and shared corrections on `published-results` while preserving the
# complete archive. That refresh failed silently once already: on 2026-08-03 GitHub delivered
# no push events for three consecutive develop merges (045a6c90fc, 37370ce5e4,
# 703a269f7a) -- not even `Develop post-merge`, which fires on every push. One
# of those was #1467, the commit that sanitized the corpus. The mirror never
# opened, nothing alerted, and `published-results` served 183 bundles carrying
# private absolute paths for a day until an unrelated review found it.
#
# Every drift guard in this repo is push-triggered, so every one of them shares
# that failure mode. This canary is deliberately SCHEDULED: a cron run does not
# depend on the delivery of any particular push event, so it still fires for a
# commit whose push was dropped.
#
# It only reports. Opening the mirror stays a human step
# (`gh workflow run sync-results-data-to-published.yml`) so a stale public
# corpus is loud without this workflow gaining write access to a public branch.
#
# Direction matters. A two-way `git diff published develop` also surfaces
# published-only paths (accepted archive submissions that intentionally need
# not exist in develop's curated seed). Recommending a wipe-based full
# mirror for those paths would *delete* public content. This canary classifies:
#   - develop-ahead / content-changed alone: fail + recommend overlay mirror
#   - published-only alone: report as info, never recommend a destructive mirror
#   - mixed (develop-ahead AND published-only): fail for the stale paths, but
#     never recommend a wipe-based full mirror; point at the union-overlay sync
#     that preserves published-only community submissions

on:
  schedule:
    # Daily, offset from the other canaries to spread scheduler load.
    - cron: "37 6 * * *"
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: corpus-drift-check
  cancel-in-progress: false

jobs:
  corpus-drift:
    name: Compare develop vs published-results corpus
    runs-on: ubuntu-latest
    steps:
      - name: Check out develop
        uses: actions/checkout@v4
        with:
          ref: develop
          fetch-depth: 0

      - name: Compare the mirrored corpus paths (directional)
        run: |
          set -euo pipefail
          # Explicit refspec so origin/published-results is a named ref, not only FETCH_HEAD.
          git fetch --no-tags --depth=1 origin published-results:refs/remotes/origin/published-results

          PUB=origin/published-results
          DEV=HEAD

          # Fail clearly rather than silently passing an empty comparison.
          if ! git cat-file -e "${PUB}:results-data" 2>/dev/null; then
            echo "::error::published-results has no results-data/ tree"
            exit 2
          fi

          # Compare the shared corpus content, not the derived inventory.
          # Two-tree compare (no dots): published-results is an orphan branch
          # with no merge-base with develop. Each branch regenerates its own
          # inventory, and published-results intentionally retains archive-only
          # bundles, so the inventory is expected to differ after a union overlay.
          PATHSPECS=(
            'results-data/bundles'
            'results-data/CORPUS_NOTES.md'
            'results-data/SEED_CORPUS_SPEC.md'
            'results-data/README.md'
            'results-data/validate_corpus.py'
          )

          # A = published, B = develop. Diff filter is relative to A→B.
          #  A: paths present on develop only (or added) — develop is ahead.
          #  D: paths present on published only — would be deleted by a full mirror.
          #  M: content differs on both sides — develop is ahead with updates.
          DEVELOP_AHEAD=$(git diff --name-only --diff-filter=A "${PUB}" "${DEV}" -- "${PATHSPECS[@]}" || true)
          PUBLISHED_ONLY=$(git diff --name-only --diff-filter=D "${PUB}" "${DEV}" -- "${PATHSPECS[@]}" || true)
          CONTENT_CHANGED=$(git diff --name-only --diff-filter=M "${PUB}" "${DEV}" -- "${PATHSPECS[@]}" || true)

          STALE_PATHS=$(printf '%s\n%s\n' "${CONTENT_CHANGED}" "${DEVELOP_AHEAD}" | sed '/^$/d' || true)

          if [[ -n "${PUBLISHED_ONLY}" ]]; then
            PO_COUNT=$(printf '%s\n' "${PUBLISHED_ONLY}" | sed '/^$/d' | wc -l | tr -d ' ')
            echo "::notice::published-results has ${PO_COUNT} path(s) not on develop (published-only)."
            echo "These are expected accepted archive submissions that are not in"
            echo "develop's curated seed. Do NOT run a full mirror"
            echo "to clear this list — that would delete public content."
            echo "Published-only paths (first 40):"
            printf '%s\n' "${PUBLISHED_ONLY}" | head -40
            echo ""
          fi

          if [[ -z "${STALE_PATHS}" ]]; then
            if [[ -z "${PUBLISHED_ONLY}" ]]; then
              echo "Corpus is in sync between develop and published-results."
            else
              echo "No develop-ahead drift. published-only paths were reported above (non-fatal)."
            fi
            exit 0
          fi

          COUNT=$(printf '%s\n' "${STALE_PATHS}" | wc -l | tr -d ' ')
          echo "::error::published-results corpus is ${COUNT} path(s) behind develop (develop-ahead or content-changed)."
          echo "The public corpus is stale. If develop has since sanitized or"
          echo "corrected bundles, the public branch is still serving the old"
          echo "content."
          echo ""
          echo "Develop-ahead / content-changed paths (first 40):"
          printf '%s\n' "${STALE_PATHS}" | head -40
          echo ""
          if [[ -n "${PUBLISHED_ONLY}" ]]; then
            # Mixed drift: develop is ahead AND published has community-only
            # paths. Never recommend a wipe-based full mirror of
            # results-data/bundles — that deletes public content. The sync
            # workflow applies a union overlay (checkout develop's files
            # without git-rm of the tree) so published-only paths survive.
            echo "Published-only paths also exist (see notice above)."
            echo "Develop-ahead can be mirrored with the union-overlay sync that"
            echo "overwrites develop's files under results-data/bundles without"
            echo "deleting published-only community submissions."
            echo "Do NOT use a wipe-based full mirror (git rm -rf results-data/bundles)"
            echo "while published-only paths exist — that would delete public content."
            echo "Run the non-destructive overlay mirror:"
            echo "  gh workflow run sync-results-data-to-published.yml --ref develop"
          else
            echo "Mirror develop-ahead onto published-results (union overlay for"
            echo "results-data/bundles; no wipe of published-only content):"
            echo "  gh workflow run sync-results-data-to-published.yml --ref develop"
          fi
          exit 1

      - name: Install uv for the published-tip privacy scan
        uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true

      - name: Privacy scan published-results tip for plaintext path leaks
        run: |
          set -euo pipefail
          # Detector lives on develop; scanned bytes come from published-results.
          # Report-only for the canary surface: exit non-zero on any leak so the
          # scheduled run is loud, without granting write access to the public branch.
          git fetch --no-tags --depth=1 origin published-results:refs/remotes/origin/published-results
          PUB=origin/published-results
          if ! git cat-file -e "${PUB}:results-data/bundles" 2>/dev/null; then
            echo "::error::published-results has no results-data/bundles tree"
            exit 2
          fi

          uv run -- python - <<'PY'
          from __future__ import annotations

          import json
          import subprocess
          import sys

          from benchbox.core.results.anonymization import find_public_path_leaks

          pub = "origin/published-results"
          listed = subprocess.check_output(
              ["git", "ls-tree", "-r", "--name-only", pub, "results-data/"],
              text=True,
          )
          json_paths = [p for p in listed.splitlines() if p.endswith(".json")]
          if not json_paths:
              print("::error::published-results results-data/ has no JSON files to scan", file=sys.stderr)
              sys.exit(2)

          offenders: list[str] = []
          unreadable: list[str] = []
          for path in json_paths:
              try:
                  raw = subprocess.check_output(["git", "show", f"{pub}:{path}"])
                  payload = json.loads(raw)
              except (subprocess.CalledProcessError, UnicodeDecodeError, json.JSONDecodeError) as exc:
                  unreadable.append(f"{path}: {type(exc).__name__}")
                  continue
              leaks = find_public_path_leaks(payload)
              if leaks:
                  offenders.append(f"{path}: {', '.join(sorted(set(leaks))[:5])}")

          if unreadable:
              print(
                  f"::error::{len(unreadable)} published corpus file(s) could not be parsed (failing closed):",
                  file=sys.stderr,
              )
              print("\n".join(unreadable[:20]), file=sys.stderr)
              sys.exit(1)
          if offenders:
              print(
                  f"::error::{len(offenders)} published corpus file(s) expose private paths:",
                  file=sys.stderr,
              )
              print("\n".join(offenders[:20]), file=sys.stderr)
              sys.exit(1)
          print(f"Privacy scan clean: {len(json_paths)} published JSON surface(s), 0 path leaks.")
          PY
