name: Whole-repo invariants (merge-skew guard)

# Closes the merge-skew failure class (issue #366).
#
# Several gates assert a GLOBAL invariant / checked-in DERIVED artifact — `derived == f(sources)` —
# but the main CI only ever verifies them against a PR's OWN head. Two PRs can each pass in isolation
# and still break the invariant once BOTH squash-merge, because `main`'s `derived` is then
# `f(sources_A ∪ sources_B)`, which neither branch's green CI ever saw (the 052 migration collision,
# #359; the stale `retro.bpmn` DI, #365). Nothing re-checked `main`, so the breakage landed silently
# and poisoned the next unrelated PR to touch the same job.
#
# This lean workflow re-asserts those whole-repo invariants where the merge actually happens:
#   - `merge_group`  — the queue's PROSPECTIVE merged commit, so a skew is blocked BEFORE it lands.
#                      THIS is the merge-skew guard: `derived == f(sources_A ∪ sources_B)`.
#   - `pull_request` — required so the SAME check (matched by job name) also reports on the PR head.
#                      GitHub's merge queue will not enqueue a PR until every required status check
#                      has reported on it, and a check that fires only on `merge_group` never does —
#                      it stays "expected" and the PR can never enter the queue (deadlock). The PR-head
#                      run also validates the PR's OWN tree (a self-collision / stale artifact the PR
#                      introduces alone), catching it before the queue rather than at merge time.
#   - `push: [main]` — a fast backstop that fails a `main`-scoped build within minutes if something
#                      slipped through, instead of first surfacing on an unrelated open PR.
#   - `schedule`     — a daily catch-all for any skew introduced by a merge that bypassed the queue.
on:
  merge_group:
  pull_request:
  push:
    branches: [main]
  schedule:
    # 06:00 UTC daily — cheap catch-all backstop.
    - cron: "0 6 * * *"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  invariants:
    name: whole-repo invariants
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          # Full history: the migration immutability gate diffs against the merge-base with
          # origin/main, and the layout gate needs the whole tree — neither works on a shallow clone.
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "24"

      - name: Install dependencies
        run: npm ci

      # Prefix-collision (+ immutability): two branches that each took "the next" free migration
      # prefix collide once merged. Re-run on the merged/`main` tree so the collision can't hide.
      - name: Check migration prefixes (no collisions)
        run: npm run check:migrations

      # BPMN DI freshness: a merged semantic model can carry flows whose DI was regenerated on neither
      # branch. Regenerate the DI over the merged model and fail if any committed diagram is stale.
      - name: Check BPMN diagram freshness (layout)
        run: npm run layout:check

      # NOTE: there is deliberately NO `urban gen --check` (`gen:check`) step here. In THIS repo the
      # only `urban gen` output is `nano-generated/`, which is GIT-IGNORED (since the initial commit)
      # and regenerated at build time via `pretypecheck`/`pretest` (`=> urban gen`). It is never a
      # committed artifact, so there is nothing to freshness-check — on a clean checkout `urban gen
      # --check` only reports it "missing" and fails every run (#423). Because it is regenerated from
      # the MERGED sources on every typecheck/test, it cannot merge-skew; merged-tree correctness of
      # the generated code is already covered by the `typecheck + test (Node)` job, which runs
      # `urban gen` then compiles on the `merge_group`/`push` commit. Do not re-add `gen:check` here.

      # Navigation index is another checked-in derived artifact; re-assert it on the merged tree too.
      - name: Check navigation index freshness
        run: npm run sync:nav:check

      # Projected MCP tool bodies (openapi.yaml) are a checked-in derived artifact too (epic #605 S0):
      # each is DERIVED from `components.schemas` by scripts/inline-mcp-bodies.ts. Two branches can
      # each edit a source component and its inline body in isolation yet leave the merged tree stale;
      # re-assert freshness on the merged tree so a $ref cannot silently re-leak into the tool surface.
      - name: Check MCP tool-body schemas freshness
        run: npm run check:mcp-bodies

      # Backstop: catch ANY other committed generated file that the merged sources render stale, even
      # one without its own `--check` script above. A clean tree is the whole-repo invariant.
      - name: No stale committed artifacts on the merged tree
        run: git diff --exit-code
