Skip to content

ci: fold the four PR-body guards into one pr-body-guards.yml job (2 of 3) #26672

ci: fold the four PR-body guards into one pr-body-guards.yml job (2 of 3)

ci: fold the four PR-body guards into one pr-body-guards.yml job (2 of 3) #26672

# The PREVENTION half of the governed-surface regime (#11704). The rule, the
# event split, the ruled approval predicate and the exit contract all live in
# `scripts/pm/check-governed-queue-guard.mjs`; that header is authoritative and
# this file is the invocation. Only the wiring decisions are argued here.
#
# Its sibling `check:pm-governed-merges` is the DETECTION half — a report-only
# post-merge audit. Three incidents (#9550, #10580, the #9319 landing) each
# ended with a governed diff enqueued or merged with zero reviews and each was
# caught by ACCIDENT, which is the expected behaviour of a regime whose only
# pre-merge line of defence is seat discipline. This is the missing refusal.
name: Governed Surface Guard
on:
# BOTH legs are load-bearing, and they mean DIFFERENT things — see the script
# header. `merge_group` is the leg that REFUSES: the queue build is the last
# thing between a speculative merge and `main`, and it is the path a seat
# uses. `pull_request` is an EARLY WARNING that deliberately exits 0, because
# a governed PR held as a draft for the maintainer to merge by hand is the
# regime's healthy end state, and a check that reddens on the healthy case is
# the permanently-red gate the 2026-08-18 ruling retired.
pull_request:
# ⛔ NO `branches:` filter either — the same permanent-pending wedge as the
# `paths:` note below, reached through the other axis of this same trigger:
# `paths:` decides which CHANGES start the workflow, `branches:` decides
# which BASE branches do. A `branches: [main]` filter means a PR based on a
# feature branch does not start this workflow at all, so `Governed Surface
# Queue Guard` publishes NO check run there — an ABSENCE, which branch
# protection holds as permanently pending rather than as a skip. A required
# context must report for ANY base, and this one is required: enrolling it
# in `scripts/check-required-contexts.mjs` brought this file under that
# registry's assertion 7c, which is the same defect #16482 cleared from
# `ci.yml` and `lint.yml` before this workflow was registered.
# Naming `types:` REPLACES GitHub's default set rather than extending it,
# so all three defaults are restated here (#8304). `ready_for_review` is
# the addition and it is the point: flipping a governed draft to ready is
# the first move of the exact sequence this guard exists to interrupt, and
# it is not in the default set — without it the warning would not re-fire
# at the one moment a seat most needs to read it. That same flip is the
# moment an approver taps merge, and the re-fired run holds this REQUIRED
# context `in_progress` for its whole wall (a direct merge is refused
# meanwhile) — which is why this leg is kept cheap rather than dropped: it
# checks out commits and trees only and installs nothing unless the diff
# needs the generator toolchain (the two checkout steps below).
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
# ⛔ NO `paths:` filter, on either leg, and this is not an oversight. A skipped
# job counts as SUCCESS in branch protection, so a path filter would hand the
# queue a green "Governed Surface Queue Guard" for a PR the filter mis-scoped —
# the #4928 shape, on the one check whose entire job is to refuse. The path
# test belongs INSIDE the script, where "nothing governed" is a verdict that
# says so and costs zero API calls. (`merge_group` has no paths support at all,
# so a filter would also make the two legs disagree about what they cover.)
concurrency:
group: governed-surface-guard-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# The default read scopes and nothing beyond them. `pull-requests: read` is
# what the review list needs; there is no write scope here, and adding one to
# make this check "fix" anything would be a widening no ruling covers.
permissions:
contents: read
pull-requests: read
jobs:
governed-surface-guard:
# ⚠️ THIS LITERAL IS THE CHECK-RUN NAME branch protection would pin, and it
# is duplicated in `CHECK_CONTEXT_NAME` in the script — deliberately, and
# pinned in both directions: the script's `--self-test` reads THIS FILE and
# fails if the two ever disagree. Renaming a job silently detaches a
# required context (#6865); this is the cheap half of the two-step that
# makes that impossible to do by accident.
#
# ⛔ Making it a required context is NOT this PR's step. Branch protection
# is the maintainer's: it takes a `REQUIRED_CONTEXTS` row in
# `scripts/check-required-contexts.mjs` PLUS the entry in Settings →
# Rulesets, in one sitting (#6865). Either half alone is an outage — a row
# without the setting reads as `direction A — registered here, NOT in the
# live required set`, which is #5617's own defect.
name: Governed Surface Queue Guard
runs-on: ubuntu-latest
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, the split lint.yml's four
# Type Check lanes use; `typecheck-source-gates` there argues it, holds the
# pooled numbers for this same full-history fetch, and says what a timed-out
# checkout step reads as (`failure`) against the wall (`cancelled`).
# Window here: the 300 most recent completed runs of this workflow,
# 2026-10-05T14:57Z → 2026-10-06T18:59Z, every attempt. Checkout: p50 31 s
# / p90 156 s / p99 595 s, and the old 10-minute wall cancelled 4 runs
# behind it (3 still inside the checkout), each a required context gone
# red with no guard verdict. Body after the checkout, successful runs
# (n = 288): max 1.1 minutes; 2× is 2.2, and never below 10. 20 + 10 = 30.
timeout-minutes: 30
steps:
# The QUEUE leg's checkout, unchanged: the refusing leg reads the queue
# head with the whole tree on disk.
- name: Checkout repository
if: github.event_name != 'pull_request'
uses: actions/checkout@v7
# CHECKOUT BUDGET: lint.yml's `typecheck-source-gates` sizes it.
timeout-minutes: 20
with:
# A governed-surface question answered over a truncated history
# answers with SILENCE, and silence reads as compliance (#9902). The
# merge group's base sha can predate a shallow fetch's floor, so the
# whole history is the only depth that cannot under-report. The
# script refuses outright (exit 1) if either sha is missing, rather
# than diffing what it happens to have.
fetch-depth: 0
# The EARLY-WARNING leg's checkout: the same full history, without file
# contents. The judgment reads commits and trees only (`merge-base`,
# `log`, `diff-tree --name-only`), and the only files it and the
# self-test open live under `scripts/` and `.github/` — so a blobless
# fetch plus a sparse tree of those two answers byte-identically.
# Measured 2026-10-26 on this repository: a blobless full-history clone
# took 7 s for 105 MiB against 58 s for 956 MiB with contents, and the
# judgment on PR #25965 printed the same bytes on both checkouts. ⛔ Still
# `fetch-depth: 0` — the #9902 reason above holds on this leg too. A diff
# that needs the generator toolchain gets the full tree back below.
- name: Checkout repository (commits and trees only)
if: github.event_name == 'pull_request'
uses: actions/checkout@v7
timeout-minutes: 20
with:
fetch-depth: 0
filter: blob:none
sparse-checkout: |
scripts
.github
# Pinned to the same major and spelling as every other setup-node in this
# repo, reading `.nvmrc` like `.github/actions/setup-workspace` does. The
# Part-of Closing-Keyword Guard records why `node-version-file` once died
# here: `actions/setup-node@v5` defaulted `package-manager-cache: true`,
# which shells out to pnpm and hard-errors when pnpm is not yet on PATH.
# That guard runs this same v7 with `node-version-file` and no pnpm at
# all, green. This job DOES acquire pnpm, but strictly after this step,
# so nothing here may start depending on pnpm existing yet.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
# The self-test runs FIRST and in its own step, because it is the
# precondition for trusting the run after it: this guard's predicates
# decide whether a merge lands, so a rotted predicate must redden here
# rather than quietly wave a governed diff through. `bash -e` aborting the
# step on the first failure is the correct semantics for a
# precondition — the collector idiom next door in lint.yml is for
# INDEPENDENT self-tests, which these are not.
- name: Guard predicate self-test
run: node scripts/pm/check-governed-queue-guard.mjs --self-test
# ── the generator toolchain — the #11705 rows can now actually recompute ─
#
# The register's `verify` rows (generator-owned files inside `skills/**`)
# recompute by running the tree's OWN generator through `pnpm … exec tsx`.
# Until now this job installed nothing, so that recompute answered "the
# generator toolchain is not available in this environment" on every run
# and the path stayed governed — a spec PR carrying its regenerated
# `references/_index.md` needed a pinned maintainer approval at merge-group
# time even though the seat-side `--test` lifted the same diff in a dev
# container. The script header filed that trade as not taken; the
# maintainer took it (2026-09-01, verbatim):
#
# > 纯生成的指针行(spec 源变更后再生成的 references/_index.md) 不需要我审核吧
#
# ⭐ NO `--filter` here, deliberately. The set of packages the register's
# recompute needs is `GENERATED_SURFACE_EXCEPTIONS[].verify.pkg`, and a
# filter list in this file would be a SECOND copy of it — one that goes
# stale silently the day a row is added, in the direction that reads as
# compliance (the new row's generator fails to spawn, the path stays
# governed, and nothing says the install was the reason). A full install
# restates nothing, so a register row reaches this job for free — the same
# reason the script imports the predicate instead of reimplementing it.
# The self-test pins that: every `verify.pkg` in the register must be
# covered by the install line below.
#
# ⛔ NO build step, and that is measured rather than assumed: both `verify`
# rows run through `tsx`, which reads the generator's TypeScript sources
# and their relative `./lib/*` imports directly — nothing consults any
# package's `dist/`. Measured on a warm store: install 7s, the skill-refs
# `--check` 1s. A build would be pure cost with nothing depending on it.
#
# ⭐ EVERY step in this block is `continue-on-error` — this is the load-
# bearing half, not defensive decoration. The guard's own promise is that
# "a diff that touches nothing governed is never blocked by an outage", and
# a hard-failing install would newly make EVERY ordinary queue build depend
# on the npm registry and the cache service. With `continue-on-error`, a
# broken install degrades to exactly the behaviour this job had before this
# block existed: the generator cannot spawn, `runSinkGenerator` returns its
# stated "toolchain is not available" reason, and the path stays GOVERNED.
# Fail-closed on the governed path, unaffected on the clear one, and the
# verdict stays inside the script — where the exit contract lives — instead
# of becoming an opaque red step nobody can read a governance answer out of.
#
# Both legs install WHEREVER THE RECOMPUTE CAN CHANGE THE ANSWER. The
# `pull_request` leg is a FORECAST of the queue verdict ("the merge-queue
# run of this same check will REFUSE it"), so a leg that cannot recompute
# forecasts a refusal that will not happen — the false-alarm direction,
# which trains seats to hold PRs that need no holding. Same diff, same
# answer, on both legs and at the seat. But a diff that touches no
# register path never runs a generator at all, so on that leg the script
# is asked first (`--toolchain-needed`, the same predicate the lift
# consults) and the toolchain is acquired only on `true`. The queue leg
# never asks, so its output stays empty and it always installs; a
# question that fails leaves it empty too, which also installs.
- name: Does this diff need the generator toolchain?
id: toolchain
if: github.event_name == 'pull_request'
continue-on-error: true
run: node scripts/pm/check-governed-queue-guard.mjs --toolchain-needed >> "$GITHUB_OUTPUT"
# The generators read the whole tree, which the sparse checkout above
# left out; the blobs arrive on demand from the partial clone's remote.
- name: Restore the full tree for the generator recompute
if: github.event_name == 'pull_request' && steps.toolchain.outputs.needed != 'false'
continue-on-error: true
run: git sparse-checkout disable
- name: Setup pnpm
if: steps.toolchain.outputs.needed != 'false'
uses: ./.github/actions/setup-pnpm
continue-on-error: true
- name: Get pnpm store directory
if: steps.toolchain.outputs.needed != 'false'
shell: bash
continue-on-error: true
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
# Deliberately the same key as `.github/actions/setup-workspace`'s: one
# store, one entry. This job only READS it -- it runs on `pull_request`
# and `merge_group` alone, and no run on either event saves the store.
# The chain stays spelled out here rather than calling that composite:
# `check-governed-queue-guard.mjs --self-test` pins every toolchain step
# one by one, each gated on the question above and each degrading on
# its own.
- name: Restore pnpm cache
if: steps.toolchain.outputs.needed != 'false'
uses: actions/cache/restore@v6
continue-on-error: true
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
- name: Install dependencies
if: steps.toolchain.outputs.needed != 'false'
continue-on-error: true
run: pnpm install --frozen-lockfile
# The live judgment. Everything it reads arrives through `env:` or the
# event payload on disk — no `${{ }}` interpolation into the shell line,
# so no PR-controlled text ever becomes part of a command.
#
# GITHUB_TOKEN is the workflow's own default token at the read scopes
# declared above; it is what makes the review read possible at all, and
# an unreadable review list is a REFUSAL with its own exit code, never a
# pass. ⛔ Fail-open is wrong in this file specifically — it exists
# because every other layer in this chain failed open.
- name: Governed surfaces may not enter the merge queue unreviewed
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/pm/check-governed-queue-guard.mjs