From c4885f3c58149a1e9493c3b56b7da4d7428bac55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 04:48:15 +0000 Subject: [PATCH] ci(governance): require pinned human approval for governed-surface merges The governed surface (AGENTS.md, CLAUDE.md, .claude/**, skills/**, docs/adr/**) is merged by a human, not by the queue. That rule lived only in prose until PR #6183: an AGENTS.md change was correctly parked as a draft, an update_pull_request call passing only `reviewers` silently set draft:false, the pull request entered the merge queue and landed as 5b3290fd5 with no human approval, and converting it back to a draft did not dequeue it. Nothing in CI could have refused that. Adds the refusal, split by event because the split is the design: on a pull request the check is deliberately green and prints an early warning (a governed PR parked as a draft is the healthy end state, and a check red on the healthy case is a permanently red check); on a merge-queue build the same finding refuses unless an APPROVED review by an authorized approver is pinned to the pull request's current head sha. The path test runs before any request is built, so an ordinary diff costs zero API calls; an unreadable review list is a refusal with its own exit code, never a pass. Written objectui-native rather than registered as a pinned port: upstream splits the mechanism over two files whose register half is mostly a provenance engine for generated artifacts inside governed paths, and this tree has none of those artifacts. A port would have to declare that deletion, and check-upstream-port-parity's validatePin refuses a divergence whose ported side is empty, so a pin cannot express one. Making the context required is a branch-protection setting only the maintainer can flip; until then the queue leg reports without stopping anything. What the repository can write down is REQUIRED_CONTEXTS, and the check name is registered there. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MnijPVVDakqK2J335JoJtq --- .github/workflows/governed-surface-guard.yml | 127 ++ content/docs/guide/ci-cd-pipeline.md | 55 + package.json | 2 + .../check-governed-queue-guard.test.ts | 178 +++ .../__tests__/merge-queue-reporting.test.ts | 11 + scripts/check-governed-queue-guard.mjs | 1361 +++++++++++++++++ scripts/dependabot-merge-gate.mjs | 14 + 7 files changed, 1748 insertions(+) create mode 100644 .github/workflows/governed-surface-guard.yml create mode 100644 scripts/__tests__/check-governed-queue-guard.test.ts create mode 100644 scripts/check-governed-queue-guard.mjs diff --git a/.github/workflows/governed-surface-guard.yml b/.github/workflows/governed-surface-guard.yml new file mode 100644 index 0000000000..0dda70c6d4 --- /dev/null +++ b/.github/workflows/governed-surface-guard.yml @@ -0,0 +1,127 @@ +# The machine half of the governed-surface no-bypass rule (objectui#6596, +# maintainer ruling 2026-08-27 accepting Option C on #6325). The rule, the event +# split, the approval predicate and the exit contract all live in +# `scripts/check-governed-queue-guard.mjs`; that header is authoritative and this +# file is the invocation. Only the wiring decisions are argued here. +# +# What it is for, in one measurement: PR #6183 touched `AGENTS.md`, was correctly +# parked as a draft, and a GitHub MCP `update_pull_request` call passing only +# `reviewers` silently set `draft: false`. The pull request entered the merge +# queue and landed as `5b3290fd5` with no human approval; converting it back to a +# draft did NOT dequeue it. Nothing in this repository could have refused that — +# the whole defence was seat discipline, and the failure contained no seat +# decision at all. +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 #6183 took. + # `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 this + # regime's healthy end state, and a check that reddens on the healthy case is + # a permanently red check nobody reads. + pull_request: + # `develop` is included for the same reason every other requirable gate here + # names both: a context that does not report on a branch it could be + # required on leaves the pull request pending rather than failing it. + branches: [main, develop] + # ⚠️ Naming `types:` REPLACES GitHub's default set rather than extending it, + # so all three defaults are restated. `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. `scripts/check-governed-queue-guard.mjs + # --self-test` pins this line. + types: [opened, synchronize, reopened, ready_for_review] + # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note + # and the measurements behind it). This is the leg that refuses, and a + # requirable context that does not report on a queue build stalls the queue + # until the ruleset's 60-minute status-check timeout. `types:` is named + # although `checks_requested` is currently the only one GitHub defines. + merge_group: + types: [checks_requested] + +# ⛔ 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 pull request the filter +# mis-scoped — 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` supports no path filter at all, so a +# filter would also make the two legs disagree about what they cover.) The +# script's `--self-test` fails if one is ever added; the same conclusion +# `control-bytes.yml` and `changeset-presence.yml` reached for themselves. + +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 and head reads need; there is no write scope here, and adding one +# to let 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, and a name that lives in exactly one place is a name + # nothing can pin. It is also registered in `REQUIRED_CONTEXTS` in + # `scripts/dependabot-merge-gate.mjs`, which is this repository's own + # written-down answer to "which checks are blocking". + # + # ⛔ Making it a REQUIRED context in the live branch-protection ruleset is + # not this pull request's step and cannot be: that is a repository-settings + # surface only the maintainer can change. Until it is flipped, this job + # REPORTS on a queue build without stopping it. + name: Governed Surface Queue Guard + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + # A governed-surface question answered over a truncated history + # answers with SILENCE, and silence reads as compliance. A 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 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + + # 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. It also holds the + # wiring pins — the job name above, the triggers, `fetch-depth: 0`, the + # absent path filter, and the `REQUIRED_CONTEXTS` registration. + # + # No install and no build: the script imports node builtins and one local + # module (`scripts/invoked-as.mjs`) only, which is also what keeps it + # clear of `scripts/check-pre-install-import-graph.mjs`. + - name: Guard predicate self-test + run: node scripts/check-governed-queue-guard.mjs --self-test + + # The live judgment. Everything it reads arrives through `env:` or the + # event payload on disk — no `${{ }}` interpolation into the shell line, + # so no pull-request-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/check-governed-queue-guard.mjs diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index ddead2537a..783478e5ad 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -38,6 +38,7 @@ one has its own section below. | `shell-escape-residue.yml` | Shell Escape Residue Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a fenced block in `AGENTS.md`, `CLAUDE.md`, `skills/**` or `content/docs/**` carries the enumerated machine-produced shell escape, or a scan root fails to resolve | | `readme-exports.yml` | README Export Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `packages/**/README.md` imports a name from its own package that the package does not export, or the scan's population collapses | | `docs-route-eager-closure.yml` | Docs Route Eager Closure Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a package named in `apps/site/app/components/registerCatalogBlocks.ts` is not already reachable from the docs route's module graph (exit 1), or when the gate's own gauge cannot be trusted (exit 2) | +| `governed-surface-guard.yml` | Governed Surface Queue Guard | PR to `main`, `develop` (incl. `ready_for_review`) — **no path filter**; merge-queue builds | **Yes on a queue build only** — a governed-surface diff with no authorized approval pinned to the PR's current head is refused there; on the pull request itself it is deliberately green and prints an early warning | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | | `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane, `continue-on-error` | | `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No | @@ -1053,6 +1054,60 @@ record it in `MEASURED_PAYLOAD` with what it is for. Run it locally with `pnpm check:docs-route-closure`; a green run prints the full classification, including which file each *free* package is already imported by. +## Governed Surface Guard (`governed-surface-guard.yml`) + +**Trigger:** Pull request to `main` / `develop` (`opened`, `synchronize`, `reopened`, +`ready_for_review`) and merge-queue builds — **no path filter** on either leg. +**Appears as:** **Governed Surface Queue Guard**. +**Blocks a PR?** Not on the pull request, by design. On a merge-queue build it refuses. + +The **governed surface** is a fixed list — `AGENTS.md`, `CLAUDE.md`, `.claude/**`, `skills/**`, +`docs/adr/**` — and the rule about it is that a change to any of them is merged by a human, not by +the queue. That rule used to live only in prose. On +[#6183](https://github.com/objectstack-ai/objectui/pull/6183) an `AGENTS.md` change was correctly +parked as a draft; a GitHub MCP `update_pull_request` call passing only `reviewers` silently also +set `draft: false`; the pull request entered the merge queue and landed with no human approval, and +converting it back to a draft did not dequeue it. Nothing in CI could have refused that. This +workflow is the refusal ([#6596](https://github.com/objectstack-ai/objectui/issues/6596)). + +**The two legs mean different things, and that is the whole design.** On a **pull request** the +check is deliberately green whatever it finds, and prints an early warning naming the governed paths +and the sequence not to start. A governed pull request sitting as a draft for the maintainer to +merge by hand is the *healthy* end state, so a check that reddened on it would be red on the healthy +case forever — and a permanently red check is one everybody learns to ignore. On a **merge-queue +build** the same finding is a refusal: that is a state a governed pull request should never be in at +all, so red there is red on the anomaly. + +**What clears the queue leg** is an `APPROVED` review by an account in `GOVERNED_APPROVERS` whose +`commit_id` equals the pull request's *current* head sha. The sha pin is what makes the approval an +approval *of something*: a push after the approval goes stale and reopens the refusal, so a +clearance cannot outlive the bytes it was given for. Dismissed and superseded approvals never count. +The remedy the refusal prints **first** is not approval at all — convert the pull request back to a +draft and leave the merge to the maintainer. + +**What it costs when nothing is governed:** nothing. The path test runs before any request is +constructed, so an ordinary pull request produces a `CLEAR` verdict and **zero** GitHub API calls; +an API outage cannot block a diff that touches no governed path. The mirrored requirement is that an +API error on a diff that *is* governed is a refusal with its own exit code (4, distinct from 3 for +"nobody approved"), never a pass — this gate exists because every other layer in the chain failed +open. + +**What it deliberately does not do.** It does not govern its own workflow or CI configuration +generally: that would be a larger rule than the one that was ruled. It cannot stop a maintainer +merging a governed pull request by hand, and does not try — under this regime the human merge *is* +the review record. And it does not make itself required: that is a branch-protection setting only +the maintainer can flip. Until it is flipped, the queue leg reports without stopping anything. What +this repository can write down, and has, is `REQUIRED_CONTEXTS` in `scripts/dependabot-merge-gate.mjs`. + +**If it fails:** read the verdict — it names every governed path that matched, the pull request each +belongs to, and the two ways out. To ask the same question about a file list before pushing, run +`pnpm governed -- AGENTS.md packages/core/src/index.ts` (or +`node scripts/check-governed-queue-guard.mjs --test `); it exits 0 when nothing is governed. +The predicates are covered by `node scripts/check-governed-queue-guard.mjs --self-test`, which the +workflow runs as its own first step because a rotted predicate must redden rather than wave a +governed diff through, and the wiring is pinned by +`scripts/__tests__/check-governed-queue-guard.test.ts`. + ## Link Checking (`check-links.yml`) **Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run diff --git a/package.json b/package.json index 90157792f4..729fe9c92e 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "check:docs-route-closure": "node scripts/check-docs-route-eager-closure.mjs", "check:entry-guard": "node scripts/check-entry-guard.mjs", "check:upstream-port-parity": "node scripts/check-upstream-port-parity.mjs", + "check:governed-queue-guard": "node scripts/check-governed-queue-guard.mjs --self-test", + "governed": "node scripts/check-governed-queue-guard.mjs --test", "check:pre-install-import-graph": "node scripts/check-pre-install-import-graph.mjs", "check:vi-mock-specifiers": "node scripts/check-vi-mock-specifiers.mjs", "check:shell-escape-residue": "node scripts/check-shell-escape-residue.mjs", diff --git a/scripts/__tests__/check-governed-queue-guard.test.ts b/scripts/__tests__/check-governed-queue-guard.test.ts new file mode 100644 index 0000000000..380f8b6cf3 --- /dev/null +++ b/scripts/__tests__/check-governed-queue-guard.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse as parseYaml } from 'yaml'; + +import { REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; +import { + CHECK_CONTEXT_NAME, + CHECK_JOB_ID, + CHECK_WORKFLOW, + GOVERNED_SURFACES, + governedPathsIn, +} from '../check-governed-queue-guard.mjs'; + +const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); +const GATE = 'scripts/check-governed-queue-guard.mjs'; +const WORKFLOW = `.github/workflows/${CHECK_WORKFLOW}`; + +/** + * objectui#6596. PR #6183 touched `AGENTS.md`, was correctly parked as a draft, + * and a GitHub MCP `update_pull_request` call passing only `reviewers` silently + * set `draft: false`. It entered the merge queue and landed as `5b3290fd5` with + * no human approval; converting it back to a draft did not dequeue it. + * + * The gate itself carries a `--self-test` covering its predicates, and this file + * does NOT duplicate it — it pins the gate to its WIRING, in the direction that + * goes wrong quietly. A refusal nothing runs is indistinguishable from a refusal + * that passes, which is the state the whole governed-surface rule was already in + * one level up: a rule with no mechanism under it. + * + * Deliberately NOT asserted here: whether the context is in the live required + * set. That is repository settings, which no test here can read and no agent may + * change. What IS asserted is that this repository has written the answer down + * where its own tooling reads it, and that every piece of the chain exists. + */ +describe('check-governed-queue-guard is wired, not merely present', () => { + const workflowText = fs.readFileSync(path.join(ROOT, WORKFLOW), 'utf8'); + const workflow = parseYaml(workflowText); + const job = workflow.jobs[CHECK_JOB_ID]; + + it('the gate script and its workflow both exist', () => { + expect(fs.existsSync(path.join(ROOT, GATE))).toBe(true); + expect(fs.existsSync(path.join(ROOT, WORKFLOW))).toBe(true); + }); + + it('package.json aliases it, and the alias points at the script that exists', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + for (const alias of ['check:governed-queue-guard', 'governed']) { + expect(pkg.scripts[alias], `package.json must alias ${alias}`).toBeTruthy(); + expect(pkg.scripts[alias]).toContain(GATE); + } + }); + + it('the job publishes exactly the check name the script declares', () => { + // The #6865-shape defect one repo over: renaming a job detaches a required + // context with nothing to say so. The name lives in two files because a name + // in one file is a name nothing can pin; this is the assertion that keeps + // the two equal. + expect(job.name).toBe(CHECK_CONTEXT_NAME); + }); + + it('this repository has written the context down as blocking', () => { + // `REQUIRED_CONTEXTS` is objectui's own answer to "which checks are + // blocking" (`scripts/dependabot-merge-gate.mjs`), and it is what + // `merge-queue-reporting.test.ts` derives the merge_group floor from. A + // guard outside it is a guard that floor cannot see. + expect(REQUIRED_CONTEXTS).toContain(CHECK_CONTEXT_NAME); + }); + + it('subscribes BOTH legs — the queue build refuses, the pull request warns', () => { + // Both, not either. Without `merge_group` there is no refusal at all; + // without `pull_request` a seat gets no warning before the queue, which is + // the moment #6183 could still have been stopped. + expect(workflow.on).toHaveProperty('merge_group'); + expect(workflow.on).toHaveProperty('pull_request'); + }); + + it('re-fires on ready_for_review — the first move of the incident', () => { + // Naming `types:` REPLACES GitHub's default set rather than extending it, + // so the three defaults have to be restated alongside the addition. This + // asserts the addition AND that restating did not drop one. + expect(workflow.on.pull_request.types).toEqual([ + 'opened', + 'synchronize', + 'reopened', + 'ready_for_review', + ]); + }); + + it('carries no path filter on either leg — a skipped job counts as SUCCESS', () => { + // The one direction this gate may not be wrong in. A path filter that + // mis-scopes hands branch protection a green verdict from a job that never + // ran, on the one check whose entire purpose is to refuse. + for (const leg of ['pull_request', 'merge_group'] as const) { + const on = workflow.on[leg] ?? {}; + expect(on).not.toHaveProperty('paths'); + expect(on).not.toHaveProperty('paths-ignore'); + } + }); + + it('checks out full history — a truncated diff answers with silence', () => { + const checkout = job.steps.find((s: Record) => String(s.uses ?? '').startsWith('actions/checkout')); + expect(checkout, 'the job must check out the repository').toBeDefined(); + expect(checkout.with['fetch-depth']).toBe(0); + }); + + it('grants pull-requests: read and no write scope anywhere', () => { + expect(workflow.permissions).toEqual({ contents: 'read', 'pull-requests': 'read' }); + }); + + it('runs the self-test BEFORE the live judgment, as its precondition', () => { + const runs: string[] = job.steps + .filter((s: Record) => typeof s.run === 'string') + .map((s: Record) => s.run); + const selfTestAt = runs.findIndex((r) => r.includes(`${GATE} --self-test`)); + const liveAt = runs.findIndex((r) => r.includes(GATE) && !r.includes('--self-test')); + expect(selfTestAt, 'the workflow must run the self-test').toBeGreaterThan(-1); + expect(liveAt, 'the workflow must run the live judgment').toBeGreaterThan(-1); + expect(selfTestAt).toBeLessThan(liveAt); + }); + + it('its self-test passes — the half that makes a green run mean something', () => { + const out = execFileSync('node', [GATE, '--self-test'], { cwd: ROOT, encoding: 'utf8' }); + expect(out).toMatch(/check-governed-queue-guard self-test: \d+ cases pass/); + }); +}); + +/** + * The governed surface itself, asserted from OUTSIDE the gate. The gate's own + * `--self-test` pins the same set, and that is not a duplicate: this file pins + * it against the repository's real tree, so a surface that is declared but does + * not exist on disk fails here — a governed path nobody can edit is a rule about + * nothing, and the reverse (a surface silently dropped) is the rule quietly + * shrinking. + */ +describe('the governed surface is the 2026-08-18 definition, and it is real', () => { + it('declares exactly the five ruled surfaces', () => { + expect(GOVERNED_SURFACES.map((s) => s.glob)).toEqual([ + 'docs/adr/**', + '.claude/**', + 'skills/**', + 'AGENTS.md', + 'CLAUDE.md', + ]); + }); + + it('every declared surface exists in this tree', () => { + for (const surface of GOVERNED_SURFACES) { + // `in` rather than `surface.prefix ?? surface.exact`: the register is a + // union of frozen literals, so exactly one of the two keys exists on each + // member and neither exists on all of them. `tsconfig.scripts.json` infers + // these types from the `.mjs` (allowJs, checkJs off), so the optional-chain + // spelling is a real type error here rather than a stylistic preference. + const target = 'prefix' in surface ? surface.prefix : surface.exact; + expect( + fs.existsSync(path.join(ROOT, target)), + `GOVERNED_SURFACES declares ${target}, which does not exist. A governed path nobody can ` + + `edit is a rule about nothing; either the surface moved (update the register) or the ` + + `entry was speculative (drop it).`, + ).toBe(true); + } + }); + + it('does not govern its own workflow, or CI configuration generally', () => { + // The widening deliberately NOT taken. Promoting CI config to a governed + // surface is a strictly larger rule than the one ruled, and taking it in an + // implementation would be a governance gate acquiring policy nobody agreed + // to. Pinned so a later edit has to be a decision. + expect(governedPathsIn([WORKFLOW, GATE, '.github/workflows/ci.yml'])).toEqual([]); + }); + + it('the two root rows are EXACT — a vendored copy is ordinary source', () => { + expect(governedPathsIn(['examples/AGENTS.md', 'packages/cli/templates/CLAUDE.md'])).toEqual([]); + expect(governedPathsIn(['AGENTS.md']).map((s) => s.id)).toEqual(['agents-md']); + }); +}); diff --git a/scripts/__tests__/merge-queue-reporting.test.ts b/scripts/__tests__/merge-queue-reporting.test.ts index 6a5658ce04..7697e9ecc4 100644 --- a/scripts/__tests__/merge-queue-reporting.test.ts +++ b/scripts/__tests__/merge-queue-reporting.test.ts @@ -115,6 +115,17 @@ const MUST_SUBSCRIBE_MERGE_GROUP = new Map([ 'this gate carries no path filter, reports on every pull request, and is requirable; ' + '`scripts/dependabot-merge-gate.mjs` already classifies it as a required context', ], + [ + 'governed-surface-guard.yml', + 'produces Governed Surface Queue Guard — added by objectui#6596. The one entry here whose ' + + 'REASON is the queue build itself rather than merely reporting on it: its `pull_request` leg ' + + 'is deliberately green whatever it finds, and its refusal exists only on `merge_group`, so a ' + + 'missing subscription would remove the entire verdict rather than just delay it. It carries ' + + 'no path filter either — a skipped job counts as SUCCESS in branch protection, which on a ' + + 'check whose whole job is to refuse is the failure mode itself — so it reports on every pull ' + + 'request and is requirable; `scripts/dependabot-merge-gate.mjs` classifies it as a required ' + + 'context', + ], ]); /** diff --git a/scripts/check-governed-queue-guard.mjs b/scripts/check-governed-queue-guard.mjs new file mode 100644 index 0000000000..f764af4cf0 --- /dev/null +++ b/scripts/check-governed-queue-guard.mjs @@ -0,0 +1,1361 @@ +#!/usr/bin/env node + +/** + * check-governed-queue-guard — a governed-surface change may not reach `main` + * through the merge queue without a human approval pinned to the bytes that + * approval was given for. + * + * node scripts/check-governed-queue-guard.mjs # in CI, from the event payload + * node scripts/check-governed-queue-guard.mjs --test # offline: "would these govern a PR?" + * node scripts/check-governed-queue-guard.mjs --self-test # offline, no network, no git + * + * ## The measured incident this exists for (objectui#6596, ruling 2026-08-27) + * + * PR #6183 touched `AGENTS.md` and was correctly parked as a draft for the + * maintainer to merge by hand. A GitHub MCP `update_pull_request` call passing + * only `reviewers` silently also set `draft: false`; the pull request entered + * the merge queue and landed as `5b3290fd5` with no human approval. Converting + * it back to a draft afterwards did NOT dequeue it. The tool fact is + * objectstack-ai/objectstack#12200; the incident record and the decision are + * objectui#6325. + * + * Every layer that could have stopped it was a layer of seat discipline, and + * the failure had no seat in it at all — a hidden side effect on a tool call + * plus a queue that does not release what it has taken. The maintainer's ruling + * (「同意,并继续」, accepting Option A + C on #6325) is that the rule stops + * resting on discipline: this file is the refusal. + * + * ## ⭐ The split by EVENT — the single most load-bearing decision here + * + * `merge_group` → a governed diff with no AUTHORIZED approval pinned to the + * pull request's CURRENT head is a REFUSAL. The queue build + * is the last thing between a speculative merge and `main`, + * and it is the path the incident took. + * `pull_request` → the identical finding is an EARLY WARNING that exits 0. + * + * The pull-request leg must not redden, and not out of politeness. A governed + * PR sitting as a draft awaiting the maintainer's own merge is the CORRECT + * terminal state of this regime, so a check that is red on it is red on the + * healthy case, forever — and a permanently red check trains everyone to ignore + * red. The sibling repository retired a gate for exactly that (objectstack's + * 2026-08-18 ruling, 红灯常态化本身有毒). The queue build is the opposite: a + * state a governed PR should never be in at all, so red there is red on the + * anomaly. + * + * ⚠️ Stated out loud rather than discovered: this guard CANNOT stop a + * maintainer merging a governed PR by hand, and does not try. A direct merge + * produces no `merge_group` event. That is not a hole — under this regime the + * human merge IS the review record. What it closes is the seat path: flip + * ready → enqueue → the queue is the entire review, which is the shape of + * #6183 exactly. + * + * ## What satisfies the queue leg + * + * An APPROVED review by an account in `GOVERNED_APPROVERS` whose `commit_id` + * equals the pull request's CURRENT head sha. Stale approvals (any push after + * the approval) never count; DISMISSED and superseded approvals never count. + * The sha pin is what makes the approval an approval OF SOMETHING: it binds the + * clearance to the exact bytes that were read, so a later push reopens this + * refusal instead of riding the old approval through — the generalisation of + * #6183, where the PR's own state changed under a review nobody had given. + * + * The preferred remedy is NOT approval, and the refusal text says so first: + * take the pull request out of the queue, convert it back to DRAFT, and leave + * the merge to the maintainer. + * + * ⛔ An agent seat never submits an approving review on a governed-surface pull + * request, under any account. Every seat in this repository writes under a + * shared GitHub identity, so `GOVERNED_APPROVERS` is a technical control that + * is only as good as that normative rule — the same class as the seat-side + * no-merge rule, and the reason the DRAFT remedy is listed first. + * + * ## Ordering: the path test runs FIRST, and a clear diff costs zero API calls + * + * ⛔ Fail-open on an API error is wrong in this file — it exists because + * everything else in the chain failed open — so an unreadable review list is a + * REFUSAL with its own exit code, never a pass. But a diff touching nothing + * governed must never be blocked by an API hiccup either. The two are + * reconciled by ORDER, not by tolerance: `runGuard` decomposes the diff and + * returns before constructing a single request when nothing governed is in it. + * The self-test pins that with a `fetchReviews` that THROWS if it is called at + * all — a spy, not a mock, because "we did not need the API" is the claim. + * + * ## Multi-PR merge groups, and the under-enumeration trap + * + * A merge group can carry SEVERAL pull requests. `merge_group.head_ref` names + * only the LAST one, so keying the whole group's diff to it would check the + * wrong PR's reviews — and in the direction that reads as compliance: PR B is + * approved, PR A's governed diff rides in behind it. So the group is decomposed + * PER COMMIT: each first-parent commit is one PR landing, its number read from + * its subject, and every governed PR is judged on its OWN reviews. A commit + * that touches a governed path and names no pull request is UNATTRIBUTED — its + * own refusal, with its own exit code. + * + * ## Why this is objectui-native rather than a pinned port + * + * The sibling repository runs this mechanism already, and this file follows its + * shape closely. It is NOT registered in `scripts/upstream-port-pin.json`, and + * that is a measurement rather than a preference: + * + * - Upstream splits the mechanism over two files, and the register half + * (`scripts/pm/check-governed-merges.mjs`, 2,614 lines) is mostly a + * multi-repo post-merge audit plus a provenance-recompute engine for + * GENERATED artifacts sitting inside governed paths. This repository has + * none of those artifacts: `.claude/workflows/` does not exist here, and + * `skills/` carries no generator output (no `references/_index.md`, no + * react-blocks contract) — measured on the tree this landed against. Every + * row of that register is inapplicable, so a port would carry ~1,500 lines + * of machinery that can never fire. + * - `scripts/check-upstream-port-parity.mjs` cannot express that. Its + * `validatePin` refuses a divergence whose `ported` side is empty + * (`files[0].divergences[0].ported is empty`, exit 2 — measured directly + * against the shipped function), so a pin has no way to declare a DELETION. + * A pinned port here is structurally impossible, not merely undesirable. + * + * So the divergence is declared in prose, where a reader can act on it, and the + * obligation the pin would have carried is stated instead: when the sibling's + * predicate changes, this file is a hand re-read, not an automatic re-sync. + * + * ## Exit codes — the refusal is impossible to read as clean + * + * 0 CLEAR — nothing governed in the diff (no API call was made), or every + * governed PR carries an authorized APPROVED review pinned to + * its current head, or this is the `pull_request` early warning. + * 3 REFUSED — governed, and at least one governed PR carries no authorized + * APPROVED review pinned to its current head (none at all, + * unauthorized account, stale sha, dismissed or superseded). + * 4 REFUSED — governed, and the PR head or review list could not be READ. + * Distinct from 3 on purpose: "nobody approved" and "we could + * not find out" are different facts and must be separable. + * 5 REFUSED — governed paths on a commit attributable to no pull request. + * 1 CANNOT RUN — unusable event payload, unsupported event, unreadable git. + * Still non-zero, still red: this file has no green that means + * "did not look". + * 2 BAD USAGE — `--test` with no paths. Not a verdict about a tree. + * + * ## What this file does NOT do + * + * It does not make itself a required context. That is a branch-protection + * setting only the maintainer can flip. What this repository CAN write down is + * `REQUIRED_CONTEXTS` in `scripts/dependabot-merge-gate.mjs` — its own answer + * to "which checks are blocking" — and `CHECK_CONTEXT_NAME` below is registered + * there. Until the live required set carries the same name, this guard REPORTS + * on a queue build without stopping it; the self-test pins the registration and + * the workflow's `name:` so the two can never drift apart silently. + */ + +import { execFileSync } from 'node:child_process'; +import { appendFileSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '..'); + +/** The exit contract, named so the header's table is machine-checkable. */ +export const EXIT_CLEAR = 0; +export const EXIT_CANNOT_RUN = 1; +export const EXIT_BAD_USAGE = 2; +export const EXIT_REFUSED_UNAPPROVED = 3; +export const EXIT_REFUSED_UNREADABLE = 4; +export const EXIT_REFUSED_UNATTRIBUTED = 5; + +/** + * The check-run name branch protection would pin, and the wiring it belongs to. + * Declared HERE as well as in the YAML, deliberately and pinned in both + * directions: the self-test reads the workflow and fails if the two disagree. + * Renaming a job silently detaches a required context, and a name that lives in + * exactly one place is a name nothing can pin. + */ +export const CHECK_CONTEXT_NAME = 'Governed Surface Queue Guard'; +export const CHECK_WORKFLOW = 'governed-surface-guard.yml'; +export const CHECK_JOB_ID = 'governed-surface-guard'; + +/** The events this guard understands, and what each one means to it. */ +export const EVENT_MERGE_GROUP = 'merge_group'; +export const EVENT_PULL_REQUEST = 'pull_request'; + +/** + * The governed surface, verbatim from the 2026-08-18 definition the card + * restates: `AGENTS.md`, `CLAUDE.md`, `.claude/**`, `skills/**`, `docs/adr/**`. + * + * ⛔ This list is the WHOLE scope and widening it is a maintainer decision, not + * an implementation detail. Two omissions are deliberate and worth naming so a + * future reader does not read them as oversights: + * + * - `.github/workflows/**` is NOT governed, so this guard does not govern its + * own workflow file. Making CI configuration governed is a strictly larger + * rule than the one ruled, and taking it here would be widening a + * governance gate past its own ruling. + * - `examples/**` and any vendored template copy of `AGENTS.md` stay out: the + * two root rows are EXACT matches, so `examples/AGENTS.md` is ordinary + * source. + * + * `prefix` rows match by path prefix; `exact` rows match the whole path. `glob` + * is the spelling a human reads in a verdict, never a matcher. + */ +export const GOVERNED_SURFACES = Object.freeze([ + Object.freeze({ id: 'adr', prefix: 'docs/adr/', glob: 'docs/adr/**', what: 'architecture decision records' }), + Object.freeze({ id: 'claude-tree', prefix: '.claude/', glob: '.claude/**', what: 'the agent instruction tree (skills, hooks, settings)' }), + Object.freeze({ id: 'skills-catalog', prefix: 'skills/', glob: 'skills/**', what: 'the published skills catalog' }), + Object.freeze({ id: 'agents-md', exact: 'AGENTS.md', glob: 'AGENTS.md', what: 'the repo-root agent instruction file' }), + Object.freeze({ id: 'claude-md', exact: 'CLAUDE.md', glob: 'CLAUDE.md', what: 'the repo-root Claude instruction file' }), +]); + +/** + * The ONLY accounts whose APPROVED review satisfies the `merge_group` leg. + * + * ⭐ Single source: every rendering derives the names it prints from this array, + * and nothing else in the repository restates them as data. Changing the set is + * a one-line maintainer decision here and nowhere else. + * + * ⚠️ Provenance, stated because it is INHERITED rather than ruled for this + * repository: these are the two accounts the sibling repository's maintainer + * ruled authoritative for its own governed surface on 2026-08-27 (verbatim: + * 「os-zhuang hotlong 批准算数」). objectui's own card rules the MECHANISM, not + * the roster. The roster is carried across because it is the same maintainer + * and the same governed surface, and it is flagged here — and in this change's + * pull request — as the one line the maintainer should confirm or edit. Nothing + * is blocked either way: the ruled remedy this guard prints FIRST is + * back-to-draft plus a human merge, which needs no approver at all. + */ +export const GOVERNED_APPROVERS = Object.freeze(['os-zhuang', 'hotlong']); + +/** + * The governed slice of a path list, grouped by surface. Surfaces with no hit + * are absent — `length === 0` IS the clean answer. + */ +export function governedPathsIn(paths) { + const list = Array.isArray(paths) ? paths : []; + return GOVERNED_SURFACES.map((surface) => ({ + ...surface, + files: list.filter((p) => typeof p === 'string' && (surface.prefix ? p.startsWith(surface.prefix) : p === surface.exact)), + })).filter((surface) => surface.files.length > 0); +} + +/** + * The seat-side answer: "would a pull request touching these paths be + * governed?" Pure, so `--test` costs a process start and nothing else, and the + * refusal text can hand a reader a command that answers the same question the + * queue build answered. + */ +export function testVerdict(paths) { + const list = (Array.isArray(paths) ? paths : []).filter((p) => typeof p === 'string' && p !== ''); + const matched = governedPathsIn(list); + const hit = new Set(matched.flatMap((s) => s.files)); + return { + governed: matched.length > 0, + checked: list.length, + surfacesChecked: GOVERNED_SURFACES.length, + matched, + hitPaths: [...hit], + clearPaths: list.filter((p) => !hit.has(p)), + }; +} + +/** + * The pull-request number a mainline commit subject names, in either spelling + * GitHub writes: a merge commit's `Merge pull request #N from …`, or a squash + * commit's TRAILING `(#N)`. This repository squashes, so the trailing form is + * the one that fires; a subject citing an issue mid-title keeps only the + * trailing parenthetical, which is the pull request. + */ +export function pullNumberFromSubject(subject) { + if (typeof subject !== 'string') return null; + let m = /^Merge pull request #(\d+)\b/.exec(subject); + if (m) return Number(m[1]); + m = /\(#(\d+)\)\s*$/.exec(subject.trim()); + return m ? Number(m[1]) : null; +} + +/** + * The pull-request number a merge-queue head ref names, or null. + * + * GitHub writes `refs/heads/gh-readonly-queue//pr--`. The + * `gh-readonly-queue/` segment is required rather than decorative: a plain + * branch called `pr-12-abcdef1` is not a queue ref, and reading one as a pull + * request number would attribute a diff to a pull request unrelated to it. + * + * The base-branch segment is `.+` rather than `[^/]+` because a base branch may + * itself contain slashes; the trailing `pr--` anchor is what makes the + * greedy match safe. ⚠️ In a MULTI-PR group this names only the LAST pull + * request, so it is a fallback for single-commit groups and never the key the + * whole group is judged on. + */ +export function pullNumberFromQueueRef(ref) { + const m = /(?:^|\/)gh-readonly-queue\/.+\/pr-(\d+)-[0-9a-f]{7,40}$/.exec(String(ref ?? '')); + return m ? Number(m[1]) : null; +} + +/** + * The shas and pull identity a workflow event carries. Pure, so every branch — + * including the malformed payloads — is offline-testable. + * + * `ok: false` is never a quiet default: an event this guard cannot read is + * `EXIT_CANNOT_RUN`, because "I could not tell what was being merged" must not + * render as "nothing governed was being merged". + */ +export function resolveEventContext({ eventName, payload }) { + if (eventName === EVENT_MERGE_GROUP) { + const group = payload?.merge_group; + if (!group?.base_sha || !group?.head_sha) { + return { ok: false, reason: 'the merge_group payload carries no base_sha/head_sha — nothing to diff' }; + } + return { + ok: true, + event: EVENT_MERGE_GROUP, + baseSha: group.base_sha, + headSha: group.head_sha, + namedPull: pullNumberFromQueueRef(group.head_ref), + label: `merge group on ${group.base_ref ?? 'main'}`, + }; + } + if (eventName === EVENT_PULL_REQUEST) { + const pull = payload?.pull_request; + if (!pull?.number || !pull?.base?.sha || !pull?.head?.sha) { + return { ok: false, reason: 'the pull_request payload carries no number/base.sha/head.sha — nothing to diff' }; + } + return { + ok: true, + event: EVENT_PULL_REQUEST, + baseSha: pull.base.sha, + headSha: pull.head.sha, + namedPull: Number(pull.number), + draft: pull.draft === true, + label: `pull request #${pull.number}`, + }; + } + return { + ok: false, + reason: + `unsupported event '${eventName ?? '(none)'}' — this guard reads ${EVENT_MERGE_GROUP} (the refusal) and ` + + `${EVENT_PULL_REQUEST} (the early warning) only`, + }; +} + +/** + * Split the work in a diff into the pull requests that touched a governed + * surface, plus the governed work no pull request can be found for. + * + * Pure. `rows` are `{ sha, subject, pr, paths }` — one per first-parent commit + * in a merge group, or one synthetic row for a `pull_request` run. A row hitting + * nothing governed is dropped entirely and costs nothing downstream; that is + * what makes "a clear diff makes no API call" a property of the data flow + * rather than a promise in a comment. + */ +export function decomposeGovernedWork(rows) { + const byPull = new Map(); + const unattributed = []; + for (const row of Array.isArray(rows) ? rows : []) { + const surfaces = governedPathsIn(row?.paths ?? []); + if (surfaces.length === 0) continue; + const paths = surfaces.flatMap((s) => s.files); + if (typeof row.pr !== 'number' || !Number.isInteger(row.pr) || row.pr <= 0) { + unattributed.push({ sha: row.sha ?? null, subject: row.subject ?? '', paths }); + continue; + } + const seen = byPull.get(row.pr) ?? { pr: row.pr, paths: new Set(), shas: [] }; + for (const p of paths) seen.paths.add(p); + if (row.sha) seen.shas.push(row.sha); + byPull.set(row.pr, seen); + } + const governed = [...byPull.values()] + .map((entry) => ({ pr: entry.pr, shas: entry.shas, paths: [...entry.paths], surfaces: governedPathsIn([...entry.paths]) })) + .sort((a, b) => a.pr - b.pr); + return { governed, unattributed }; +} + +/** + * Does an APPROVED review exist on this pull request? The early-warning leg's + * reading — any approver counts, no sha pin — because that leg never reddens + * and so never needs the stricter, more expensive question. + * + * The reduction is LATEST-DECISIVE-PER-REVIEWER, matching how GitHub itself + * computes a review decision: `COMMENTED` and `PENDING` carry no decision, and a + * `DISMISSED` approval is not an approval any more. A reviewer who approved and + * later requested changes must not still read as an approver — the naive + * `reviews.some(r => r.state === 'APPROVED')` gets that wrong in the fail-open + * direction, which is the one direction this file may not be wrong in. + * + * Pure; the array is expected in GitHub's chronological order, so last wins. + */ +export function approvalVerdict(reviews) { + const decisive = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']); + const latest = new Map(); + for (const review of Array.isArray(reviews) ? reviews : []) { + const state = String(review?.state ?? '').toUpperCase(); + if (!decisive.has(state)) continue; + const login = review?.user?.login ?? `(unknown:${review?.id ?? latest.size})`; + latest.set(login, state); + } + return { + state: [...latest.values()].includes('APPROVED') ? 'approved' : 'unapproved', + approvers: [...latest].filter(([, s]) => s === 'APPROVED').map(([login]) => login), + changesRequestedBy: [...latest].filter(([, s]) => s === 'CHANGES_REQUESTED').map(([login]) => login), + reviewsRead: Array.isArray(reviews) ? reviews.length : 0, + }; +} + +/** + * The `merge_group` predicate: does an account in `GOVERNED_APPROVERS` hold a + * latest-decisive APPROVED review whose `commit_id` equals the pull request's + * CURRENT head sha? + * + * Same latest-decisive-per-reviewer reduction as `approvalVerdict`, with two + * more ways to not count, each reported separately so a queue log can be acted + * on: `staleApprovers` (authorized, APPROVED, wrong sha — a push happened after + * the approval) and `unauthorizedApprovers` (APPROVED, not in the set). An empty + * or unparsable head sha pins NOTHING: fail closed, never "any sha". + * + * ⚠️ An outstanding CHANGES_REQUESTED from another reviewer does not flip the + * verdict, and that is restraint rather than an oversight — the predicate is the + * authorized pinned approval, and widening a governance gate past its own rule + * is how gates acquire policy nobody agreed to. It is printed loudly instead. + * + * Pure; the array is expected in GitHub's chronological order, so last wins. + */ +export function pinnedApprovalVerdict(reviews, headSha) { + const decisive = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']); + const latest = new Map(); + for (const review of Array.isArray(reviews) ? reviews : []) { + const state = String(review?.state ?? '').toUpperCase(); + if (!decisive.has(state)) continue; + const login = review?.user?.login ?? `(unknown:${review?.id ?? latest.size})`; + latest.set(login, { state, commitId: String(review?.commit_id ?? '').toLowerCase() }); + } + const head = /^[0-9a-f]{7,40}$/.test(String(headSha ?? '').toLowerCase()) ? String(headSha).toLowerCase() : null; + const approvers = []; + const staleApprovers = []; + const unauthorizedApprovers = []; + for (const [login, review] of latest) { + if (review.state !== 'APPROVED') continue; + if (!GOVERNED_APPROVERS.includes(login)) unauthorizedApprovers.push(login); + else if (head !== null && review.commitId === head) approvers.push(login); + else staleApprovers.push({ login, commitId: review.commitId }); + } + return { + state: approvers.length > 0 ? 'approved' : 'unapproved', + approvers, + staleApprovers, + unauthorizedApprovers, + changesRequestedBy: [...latest].filter(([, r]) => r.state === 'CHANGES_REQUESTED').map(([login]) => login), + reviewsRead: Array.isArray(reviews) ? reviews.length : 0, + headSha: head ?? String(headSha ?? ''), + }; +} + +/** The refusal an unreadable PR head or review list produces. Never a pass — see the header. */ +export function unreadableApproval(reason) { + return { state: 'unreadable', approvers: [], changesRequestedBy: [], reviewsRead: 0, reason: String(reason ?? 'unknown error') }; +} + +/** + * The verdict, as data. Pure — every branch of the decision is here, and the + * renderer and the exit code both read it rather than re-deriving it. + */ +export function guardVerdict({ event, governed = [], unattributed = [], approvals = new Map(), apiCalls = 0 }) { + const entries = governed.map((entry) => ({ + ...entry, + approval: approvals.get(entry.pr) ?? unreadableApproval('no review reading was recorded for this pull request'), + })); + const base = { event, entries, unattributed, apiCalls, contextName: CHECK_CONTEXT_NAME }; + + if (entries.length === 0 && unattributed.length === 0) { + return { ...base, conclusion: 'clear', exitCode: EXIT_CLEAR, refusalKind: null }; + } + // The early-warning run never reddens: a governed PR awaiting the maintainer's + // own merge is this regime's healthy terminal state, and a check that is red + // on the healthy case is a permanently red check (see the header). + if (event !== EVENT_MERGE_GROUP) { + return { ...base, conclusion: 'warned', exitCode: EXIT_CLEAR, refusalKind: null }; + } + if (unattributed.length > 0) { + return { ...base, conclusion: 'refused', exitCode: EXIT_REFUSED_UNATTRIBUTED, refusalKind: 'unattributed' }; + } + if (entries.some((e) => e.approval.state === 'unreadable')) { + return { ...base, conclusion: 'refused', exitCode: EXIT_REFUSED_UNREADABLE, refusalKind: 'unreadable' }; + } + if (entries.some((e) => e.approval.state !== 'approved')) { + return { ...base, conclusion: 'refused', exitCode: EXIT_REFUSED_UNAPPROVED, refusalKind: 'unapproved' }; + } + return { ...base, conclusion: 'cleared', exitCode: EXIT_CLEAR, refusalKind: null }; +} + +/** + * The words a reader gets. Every rendering names the exact paths that matched + * and states what would satisfy the guard — a refusal a reader cannot act on is + * a refusal they route around. + */ +export function renderGuardVerdict(verdict) { + const lines = []; + const surfaceLines = (entry) => + entry.surfaces.flatMap((s) => [ + ` ${s.glob} x${s.files.length} — ${s.what}`, + ...s.files.slice(0, 12).map((f) => ` - ${f}`), + ...(s.files.length > 12 ? [` … and ${s.files.length - 12} more`] : []), + ]); + + const apiLabel = verdict.event === EVENT_MERGE_GROUP ? 'API read(s) (PR head + reviews)' : 'review lookup(s)'; + lines.push( + `${CHECK_CONTEXT_NAME} — ${verdict.event} — ${verdict.entries.length} governed pull request(s), ` + + `${verdict.unattributed.length} unattributed governed commit(s), ${verdict.apiCalls} ${apiLabel}.`, + ); + + if (verdict.conclusion === 'clear') { + lines.push( + ' ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge.', + ` Derived from GOVERNED_SURFACES in scripts/check-governed-queue-guard.mjs (${GOVERNED_SURFACES.length} surfaces),`, + ' never from a restated list. ⛔ ZERO review lookups were made: the path test runs first and returns,', + ' so a GitHub API outage can never block a diff that touches nothing governed.', + ); + return lines.join('\n'); + } + + // A pinned verdict (the queue leg) carries `headSha`; the early-warning leg's + // `approvalVerdict` shape does not. + const pinned = (approval) => approval.headSha !== undefined; + for (const entry of verdict.entries) { + lines.push('', ` #${entry.pr} — governed:`); + lines.push(...surfaceLines(entry)); + if (entry.approval.state === 'approved') { + lines.push( + pinned(entry.approval) + ? ` ✅ authorized APPROVED review pinned to head ${entry.approval.headSha.slice(0, 12)}, by: ${entry.approval.approvers.join(', ')}` + : ` ✅ APPROVED review present, by: ${entry.approval.approvers.join(', ')}`, + ); + } else if (entry.approval.state === 'unreadable') { + lines.push(` ⛔ the review list could NOT be read — ${entry.approval.reason}`); + } else if (pinned(entry.approval)) { + lines.push( + ` ⛔ NO authorized APPROVED review pinned to head ${String(entry.approval.headSha).slice(0, 12)} ` + + `(${entry.approval.reviewsRead} review(s) read; authorized: ${GOVERNED_APPROVERS.join(', ')})`, + ); + for (const stale of entry.approval.staleApprovers ?? []) { + lines.push( + ` ⚠️ ${stale.login} approved at ${(stale.commitId || '(no commit_id)').slice(0, 12)} but the head is ` + + `${String(entry.approval.headSha).slice(0, 12)} — STALE, never counts: a push after the approval reopens this gate`, + ); + } + if ((entry.approval.unauthorizedApprovers ?? []).length > 0) { + lines.push( + ` ℹ️ APPROVED by account(s) outside GOVERNED_APPROVERS: ${entry.approval.unauthorizedApprovers.join(', ')} — never counts`, + ); + } + } else { + lines.push(` ⛔ NO approving review (${entry.approval.reviewsRead} review(s) read, none decisive-APPROVED)`); + } + if (entry.approval.changesRequestedBy.length > 0) { + lines.push( + ` ⚠️ outstanding CHANGES_REQUESTED from: ${entry.approval.changesRequestedBy.join(', ')}`, + ' (informational — the predicate is an authorized APPROVED review pinned to the', + " pull request's current head; this guard does not widen past its own rule)", + ); + } + } + for (const row of verdict.unattributed) { + lines.push( + '', + ` ⛔ UNATTRIBUTED — commit ${String(row.sha ?? '(unknown)').slice(0, 12)} touches a governed surface and names no pull request:`, + ` subject: ${row.subject || '(empty)'}`, + ...row.paths.slice(0, 12).map((p) => ` - ${p}`), + ); + } + + lines.push(''); + if (verdict.conclusion === 'warned') { + lines.push( + ' ⚠️ EARLY WARNING, not a failure — this run is on the pull request, and this check is deliberately', + ' GREEN here. A governed PR held as a draft for the maintainer to merge by hand IS this regime\'s', + ' healthy end state, and a check that reddens on the healthy case is a permanently red check.', + '', + ' ⛔ What a seat must NOT do with this pull request: flip it ready, enqueue it, or arm auto-merge.', + ' One governed path governs the WHOLE pull request — proportion is not a question.', + ' ⚠️ A GitHub MCP `update_pull_request` call can set `draft: false` as a SIDE EFFECT of passing', + ' only `reviewers` (objectstack-ai/objectstack#12200). That is how objectui#6183 left draft,', + ' and converting back to a draft did NOT dequeue it. Do not send that call on this PR.', + '', + ' If it IS enqueued anyway, the merge-queue run of this same check REFUSES it unless every', + ' governed pull request above carries an authorized approval pinned to its head by then.', + ); + return lines.join('\n'); + } + if (verdict.conclusion === 'cleared') { + lines.push( + ' ✅ CLEARED — every governed pull request in this merge group carries an APPROVED review by an', + ` authorized approver (GOVERNED_APPROVERS: ${GOVERNED_APPROVERS.join(', ')}) whose commit_id equals that`, + " pull request's CURRENT head sha (a stale, dismissed, superseded or unauthorized approval never", + ' counts). ⛔ An agent seat never submits an approving review on a governed-surface pull request,', + ' under any account — every seat here writes under a shared identity, so that rule is what this', + ' technical control rests on.', + ); + return lines.join('\n'); + } + + lines.push(' ⛔ REFUSED — this merge group must not land.'); + if (verdict.refusalKind === 'unattributed') { + lines.push( + ' A governed-surface change is in this merge group that cannot be attributed to any pull request,', + ' so there is no review record it could possibly satisfy. Fail closed: a governed change nobody', + ' can point at a reviewable pull request for is the most anomalous input this guard can receive.', + ); + } else if (verdict.refusalKind === 'unreadable') { + lines.push( + ' The review list could not be READ for at least one governed pull request above. ⛔ This is a', + ' refusal and not a pass, deliberately: this guard exists because every other layer in this chain', + ' failed open. "Nobody approved" and "we could not find out" are different facts (exit 3 vs 4) and', + ' neither of them is "approved". Re-run the job once the API is reachable.', + ); + } else { + lines.push( + ' At least one governed pull request above carries NO authorized APPROVED review pinned to its', + ' current head, and the merge queue would have been the entire review — the shape of objectui#6183,', + ' which left draft through a hidden tool side effect and landed as 5b3290fd5 unreviewed.', + ); + } + lines.push( + '', + ' What satisfies this check:', + ' 1. ⭐ PREFERRED — take the pull request out of the queue: convert it back to DRAFT (disarming', + ' auto-merge alone does NOT dequeue it), and leave the merge to the maintainer. A human merge', + ' IS the review record for a governed surface; that is the regime, not a workaround of it.', + ` 2. Or: obtain an APPROVED review by an authorized approver (GOVERNED_APPROVERS: ${GOVERNED_APPROVERS.join(', ')})`, + " pinned to each governed pull request's CURRENT head sha, then re-queue (any push after the", + ' approval goes stale and reopens this refusal). ⛔ An agent seat never submits that approval,', + ' under any account.', + ' Neither of those is "edit this check".', + '', + ' Verify any file list before acting: node scripts/check-governed-queue-guard.mjs --test ', + ); + return lines.join('\n'); +} + +/** + * The orchestrator, with its one IO dependency injected. + * + * ⭐ The early return below is the ordering guarantee expressed as control flow: + * nothing governed ⇒ verdict, before `fetchReviews` exists as a possibility. The + * self-test passes a `fetchReviews` that THROWS, so "a clear diff costs zero API + * calls" is measured rather than asserted. + */ +export async function runGuard({ event, rows, fetchReviews, fetchPullHead }) { + const { governed, unattributed } = decomposeGovernedWork(rows); + if (governed.length === 0 && unattributed.length === 0) { + return guardVerdict({ event, governed, unattributed, apiCalls: 0 }); + } + const approvals = new Map(); + let apiCalls = 0; + for (const entry of governed) { + try { + if (event === EVENT_MERGE_GROUP) { + // The queue leg judges the pinned predicate, so it needs the pull + // request's CURRENT head sha — the merge_group payload carries no + // per-PR heads. Two reads, head first: an unreadable head refuses + // without ever constructing the review request. + apiCalls += 1; + const headSha = await fetchPullHead(entry.pr); + apiCalls += 1; + approvals.set(entry.pr, pinnedApprovalVerdict(await fetchReviews(entry.pr), headSha)); + } else { + // The early-warning leg never reddens, so it never needs the head. + apiCalls += 1; + approvals.set(entry.pr, approvalVerdict(await fetchReviews(entry.pr))); + } + } catch (error) { + approvals.set(entry.pr, unreadableApproval(String(error?.message ?? error).split('\n')[0])); + } + } + return guardVerdict({ event, governed, unattributed, approvals, apiCalls }); +} + +// -- git (diff decomposition; zero API) ------------------------------------- + +function git(root, args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); +} + +/** Is `rev` an object this checkout actually has? A missing sha is a hard failure, never an empty diff. */ +function hasRev(root, rev) { + try { + git(root, ['cat-file', '-e', `${rev}^{commit}`]); + return true; + } catch { + return false; + } +} + +/** + * The first-parent commits between `baseSha` and `headSha`, each with the paths + * it changed and the pull request its subject names. One row per pull-request + * landing in a merge group; see the header on why the group is decomposed + * rather than keyed to `head_ref`. + */ +export function enumerateRows(root, baseSha, headSha, fallbackPull = null) { + const log = git(root, ['log', '--first-parent', '--format=%H%x09%s', `${baseSha}..${headSha}`]); + const commits = log + .split('\n') + .filter((l) => l !== '') + .map((l) => { + const [sha, ...rest] = l.split('\t'); + return { sha, subject: rest.join('\t') }; + }); + return commits.map((commit) => ({ + ...commit, + // The fallback is only unambiguous when the range holds exactly one commit; + // in a multi-PR group `head_ref` names the LAST pull request, and applying + // it to an earlier commit attributes a governed diff to the wrong one. + pr: pullNumberFromSubject(commit.subject) ?? (commits.length === 1 ? fallbackPull : null), + paths: git(root, ['diff-tree', '-r', '--no-commit-id', '--no-renames', '--name-only', '-m', '--first-parent', commit.sha]) + .split('\n') + .filter((p) => p !== ''), + })); +} + +// -- the GitHub reads (PR head + reviews — the only API surface) ------------- + +function apiHeaders(token) { + return { + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; +} + +/** + * Every review on a pull request, paginated. Throws on any non-2xx — the caller + * turns a throw into a REFUSAL, never into a pass, so there is no tolerant + * branch to get wrong here. + */ +export function makeReviewReader({ apiUrl, slug, token, fetchImpl = fetch, perPage = 100, maxPages = 10 }) { + return async function fetchReviews(pull) { + const all = []; + for (let page = 1; page <= maxPages; page += 1) { + const url = `${apiUrl}/repos/${slug}/pulls/${pull}/reviews?per_page=${perPage}&page=${page}`; + const res = await fetchImpl(url, { headers: apiHeaders(token) }); + if (!res.ok) throw new Error(`GET /repos/${slug}/pulls/${pull}/reviews answered HTTP ${res.status}`); + const batch = await res.json(); + if (!Array.isArray(batch)) throw new Error(`the reviews endpoint answered a non-array body for #${pull}`); + all.push(...batch); + if (batch.length < perPage) return all; + } + throw new Error(`#${pull} has more than ${perPage * maxPages} reviews — refusing to judge a truncated list`); + }; +} + +/** + * The pull request's CURRENT head sha — what the predicate pins a review's + * `commit_id` against. Same channel as the review read, under the workflow's + * `pull-requests: read` scope and nothing wider. Throws on any non-2xx and on a + * body with no parseable `head.sha`: a head this guard cannot read pins NOTHING, + * and the caller turns the throw into a REFUSAL (exit 4), never a pass. + */ +export function makePullHeadReader({ apiUrl, slug, token, fetchImpl = fetch }) { + return async function fetchPullHead(pull) { + const res = await fetchImpl(`${apiUrl}/repos/${slug}/pulls/${pull}`, { headers: apiHeaders(token) }); + if (!res.ok) throw new Error(`GET /repos/${slug}/pulls/${pull} answered HTTP ${res.status}`); + const body = await res.json(); + const sha = String(body?.head?.sha ?? ''); + if (!/^[0-9a-f]{7,40}$/i.test(sha)) { + throw new Error(`GET /repos/${slug}/pulls/${pull} answered no parseable head.sha — cannot pin approvals`); + } + return sha; + }; +} + +// -- the seat-side `--test` predicate --------------------------------------- + +/** The words `--test` prints. Pure, so the self-test reads them without a process. */ +export function renderTestVerdict(verdict) { + const lines = []; + if (!verdict.governed) { + lines.push( + `✅ NOT GOVERNED — ${verdict.checked} path(s) checked against ${verdict.surfacesChecked} governed surface(s); none matched.`, + ' An ordinary pull request: the normal review and merge-queue route applies.', + ); + return lines.join('\n'); + } + lines.push(`⛔ GOVERNED — ${verdict.hitPaths.length} of ${verdict.checked} path(s) are on a governed surface:`); + for (const surface of verdict.matched) { + lines.push(` ${surface.glob} x${surface.files.length} — ${surface.what}`); + for (const file of surface.files.slice(0, 12)) lines.push(` - ${file}`); + if (surface.files.length > 12) lines.push(` … and ${surface.files.length - 12} more`); + } + lines.push( + '', + ' One governed path governs the WHOLE pull request — proportion is not a question.', + ' ⛔ Do not flip it ready, enqueue it, or arm auto-merge. Park it as a DRAFT and leave the merge', + ' to the maintainer; a human merge IS the review record for a governed surface.', + ` The merge-queue run of "${CHECK_CONTEXT_NAME}" refuses this diff unless an authorized approval`, + ` (GOVERNED_APPROVERS: ${GOVERNED_APPROVERS.join(', ')}) is pinned to the pull request's current head.`, + ); + return lines.join('\n'); +} + +// -- CLI -------------------------------------------------------------------- + +function runTestMode(argv) { + const paths = argv.slice(argv.indexOf('--test') + 1).filter((a) => !a.startsWith('--')); + if (paths.length === 0) { + console.error( + 'check-governed-queue-guard --test needs at least one path.\n' + + ' node scripts/check-governed-queue-guard.mjs --test AGENTS.md packages/core/src/index.ts\n' + + 'Refusing to answer "not governed" about an empty list: a question nobody asked must not read as a clearance.', + ); + return EXIT_BAD_USAGE; + } + const verdict = testVerdict(paths); + console.log(renderTestVerdict(verdict)); + return verdict.governed ? EXIT_REFUSED_UNAPPROVED : EXIT_CLEAR; +} + +async function main() { + const env = process.env; + const eventName = env.GITHUB_EVENT_NAME; + let payload; + try { + payload = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH ?? '', 'utf8')); + } catch (error) { + console.error( + `⛔ ${CHECK_CONTEXT_NAME}: could not read GITHUB_EVENT_PATH (${String(error?.message ?? error).split('\n')[0]}).\n` + + ' This guard reads the workflow event payload and nothing else; without it there is no diff to judge,\n' + + ' and "could not look" must never exit 0 here.', + ); + return EXIT_CANNOT_RUN; + } + + const context = resolveEventContext({ eventName, payload }); + if (!context.ok) { + console.error(`⛔ ${CHECK_CONTEXT_NAME}: ${context.reason}.`); + return EXIT_CANNOT_RUN; + } + + for (const rev of [context.baseSha, context.headSha]) { + if (!hasRev(repoRoot, rev)) { + console.error( + `⛔ ${CHECK_CONTEXT_NAME}: ${rev} is not in this checkout, so the diff cannot be read.\n` + + ' The job must check out with `fetch-depth: 0`; a truncated history answers a governed-surface\n' + + ' question with silence, and silence reads as compliance.', + ); + return EXIT_CANNOT_RUN; + } + } + + let rows; + try { + const mergeBase = git(repoRoot, ['merge-base', context.baseSha, context.headSha]).trim(); + rows = enumerateRows(repoRoot, mergeBase, context.headSha, context.namedPull); + } catch (error) { + console.error(`⛔ ${CHECK_CONTEXT_NAME}: could not read the diff (${String(error?.message ?? error).split('\n')[0]}).`); + return EXIT_CANNOT_RUN; + } + + const slug = env.GITHUB_REPOSITORY ?? 'objectstack-ai/objectui'; + const reader = { + apiUrl: (env.GITHUB_API_URL ?? 'https://api.github.com').replace(/\/+$/, ''), + slug, + token: env.GITHUB_TOKEN || env.GH_TOKEN || null, + }; + + const verdict = await runGuard({ + event: context.event, + rows, + fetchReviews: makeReviewReader(reader), + fetchPullHead: makePullHeadReader(reader), + }); + const report = [`${context.label} — ${rows.length} commit(s) in range`, renderGuardVerdict(verdict)].join('\n'); + console.log(report); + + // The step summary is where a reader actually looks at a red queue build. + if (env.GITHUB_STEP_SUMMARY) { + try { + appendFileSync(env.GITHUB_STEP_SUMMARY, `## ${CHECK_CONTEXT_NAME}\n\n\`\`\`text\n${report}\n\`\`\`\n`); + } catch { + /* a summary that cannot be written changes no verdict */ + } + } + return verdict.exitCode; +} + +if (isEntrypoint(import.meta.url) && !process.argv.includes('--self-test')) { + process.exitCode = process.argv.includes('--test') ? runTestMode(process.argv) : await main(); +} + +// -- self-test (offline: pure functions + replay fixtures; no network, no git) -- + +/** + * The measured incident this guard descends from, with its real surface. + * Predicted direction, and the whole point of pinning it: it is REFUSED on + * `merge_group` with no approving review, and it is a GREEN early warning on + * `pull_request`. A fixture that passed the queue leg would mean this guard + * would not have stopped the thing it was built to stop. + */ +const REPLAYS = [ + { + name: 'objectui#6183 — a governed AGENTS.md PR left draft through an update_pull_request side effect, queued, and merged as 5b3290fd5 with zero reviews', + pr: 6183, + subject: 'docs(agents): seat protocol updates (#6183)', + files: ['AGENTS.md'], + }, + { + name: 'the same shape on the instruction tree — a .claude/** change mixed with ordinary source', + pr: 6184, + subject: 'chore(hooks): tighten a PreToolUse guard (#6184)', + files: ['.claude/hooks/guard-shared-stash.sh', 'packages/core/src/index.ts'], + }, + { + name: 'the published skills catalog, the surface the domain label routes on', + pr: 6185, + subject: 'docs(skill): rewrite the composition rules (#6185)', + files: ['skills/objectui/rules/composition.md', 'skills/objectui/SKILL.md'], + }, + { + name: 'an architecture decision record', + pr: 6186, + subject: 'docs(adr): record the governed-surface decision (#6186)', + files: ['docs/adr/0099-example.md'], + }, +]; + +export async function selfTest() { + let checked = 0; + const failures = []; + const assert = (name, cond, detail) => { + checked += 1; + if (!cond) failures.push(`${name}${detail ? `: ${detail}` : ''}`); + }; + const row = (pr, files, sha = 'a'.repeat(40), subject = `x (#${pr})`) => ({ sha, subject, pr, paths: files }); + const approved = (...logins) => logins.map((login) => ({ state: 'APPROVED', user: { login } })); + const HEAD = 'f'.repeat(40); + const OLD = '0'.repeat(40); + const approvedAt = (login, sha) => ({ state: 'APPROVED', user: { login }, commit_id: sha }); + const pinnedPass = (login = GOVERNED_APPROVERS[0]) => pinnedApprovalVerdict([approvedAt(login, HEAD)], HEAD); + const run = (event, rows, approvals = new Map()) => { + const { governed, unattributed } = decomposeGovernedWork(rows); + return guardVerdict({ event, governed, unattributed, approvals, apiCalls: governed.length }); + }; + + // -- the governed surface IS the ruled definition, in both directions ------ + // + // The card restates the 2026-08-18 definition as five surfaces. Pinning the + // membership BOTH ways is what stops a sixth being added here instead of by a + // maintainer, and a fifth being dropped by an edit nobody reads. + assert('the-register-declares-exactly-the-five-ruled-surfaces', GOVERNED_SURFACES.length === 5, String(GOVERNED_SURFACES.length)); + assert( + 'and-they-are-the-ruled-globs-verbatim', + GOVERNED_SURFACES.map((s) => s.glob).join() === 'docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md', + GOVERNED_SURFACES.map((s) => s.glob).join(), + ); + for (const surface of GOVERNED_SURFACES) { + const sample = surface.prefix ? `${surface.prefix}sample.md` : surface.exact; + const { governed } = decomposeGovernedWork([row(1, [sample])]); + assert(`the-register-drives-the-verdict-for-${surface.id}`, governed.length === 1 && governed[0].paths.includes(sample), sample); + } + // The two exact rows are EXACT: a vendored or example copy is ordinary source. + assert('an-example-copy-of-AGENTS.md-is-not-the-governed-one', governedPathsIn(['examples/AGENTS.md']).length === 0); + assert('a-near-miss-adr-directory-is-not-governed', governedPathsIn(['docs/adrs/z.md', 'docs/adr-notes.md']).length === 0); + // ⛔ The widening this guard deliberately does NOT take: it does not govern + // its own workflow, and pinning that keeps the scope honest against a future + // edit that quietly promotes CI config to a governed surface. + assert('this-guards-own-workflow-is-NOT-governed', governedPathsIn([`.github/workflows/${CHECK_WORKFLOW}`, 'scripts/check-governed-queue-guard.mjs']).length === 0); + assert('ordinary-source-is-not-governed', governedPathsIn(['packages/core/src/index.ts', 'content/docs/guide/x.md', 'package.json']).length === 0); + + // -- the exit contract as a table ------------------------------------------ + assert('exit-clear-is-0', EXIT_CLEAR === 0); + assert('exit-cannot-run-is-1', EXIT_CANNOT_RUN === 1); + assert('exit-bad-usage-is-2', EXIT_BAD_USAGE === 2); + assert( + 'the-three-refusals-are-distinct-non-zero-codes', + new Set([EXIT_REFUSED_UNAPPROVED, EXIT_REFUSED_UNREADABLE, EXIT_REFUSED_UNATTRIBUTED]).size === 3 && + ![EXIT_REFUSED_UNAPPROVED, EXIT_REFUSED_UNREADABLE, EXIT_REFUSED_UNATTRIBUTED].includes(0), + ); + + // -- the merge-queue head ref ---------------------------------------------- + assert('queue-ref-yields-its-pr', pullNumberFromQueueRef('refs/heads/gh-readonly-queue/main/pr-6183-484ae0019cd') === 6183); + assert('queue-ref-without-the-refs-prefix-too', pullNumberFromQueueRef('gh-readonly-queue/main/pr-42-abcdef1') === 42); + assert('a-base-branch-with-a-slash-is-still-read', pullNumberFromQueueRef('refs/heads/gh-readonly-queue/release/v5/pr-7-abcdef1') === 7); + assert('an-ordinary-branch-that-merely-looks-like-one-is-NOT-a-queue-ref', pullNumberFromQueueRef('refs/heads/pr-12-abcdef1') === null); + assert('a-plain-branch-is-null', pullNumberFromQueueRef('refs/heads/claude/issue-1-x') === null); + assert('nonsense-is-null-never-a-number', pullNumberFromQueueRef(undefined) === null && pullNumberFromQueueRef('') === null); + + // -- the subject parser (this repo squashes, so the trailing form fires) ---- + assert('a-squash-subject-yields-its-pr', pullNumberFromSubject('fix(grid): read the policy first (#6722)') === 6722); + assert('a-merge-commit-subject-does-too', pullNumberFromSubject('Merge pull request #6722 from claude/x') === 6722); + assert( + 'a-subject-citing-an-issue-mid-title-keeps-only-the-trailing-parenthetical', + pullNumberFromSubject('fix(plugin-detail): re-key three fetch effects (#6697) (#6725)') === 6725, + ); + assert('a-subject-naming-no-pr-is-null', pullNumberFromSubject('chore: direct work') === null && pullNumberFromSubject(undefined) === null); + + // -- event payloads, including the malformed ones --------------------------- + const mg = resolveEventContext({ + eventName: 'merge_group', + payload: { merge_group: { base_sha: 'b'.repeat(40), head_sha: 'h'.repeat(40), head_ref: 'refs/heads/gh-readonly-queue/main/pr-99-abcdef1', base_ref: 'refs/heads/main' } }, + }); + assert('a-merge_group-payload-resolves-to-its-shas-and-named-pr', mg.ok && mg.event === 'merge_group' && mg.namedPull === 99, JSON.stringify(mg)); + const pr = resolveEventContext({ + eventName: 'pull_request', + payload: { pull_request: { number: 123, draft: true, base: { sha: 'b'.repeat(40) }, head: { sha: 'h'.repeat(40) } } }, + }); + assert('a-pull_request-payload-resolves-to-its-number-and-draft-state', pr.ok && pr.namedPull === 123 && pr.draft === true, JSON.stringify(pr)); + assert('a-merge_group-with-no-shas-CANNOT-RUN-never-reads-as-an-empty-diff', resolveEventContext({ eventName: 'merge_group', payload: { merge_group: {} } }).ok === false); + assert('a-pull_request-with-no-head-sha-CANNOT-RUN', resolveEventContext({ eventName: 'pull_request', payload: { pull_request: { number: 1, base: { sha: 'x' } } } }).ok === false); + const unsupported = resolveEventContext({ eventName: 'push', payload: {} }); + assert('an-unsupported-event-CANNOT-RUN-and-names-both-events-it-does-read', !unsupported.ok && /merge_group/.test(unsupported.reason) && /pull_request/.test(unsupported.reason), unsupported.reason); + + // -- the any-approver predicate (the early-warning leg's) ------------------- + assert('an-approval-is-an-approval', approvalVerdict(approved('hotlong')).state === 'approved'); + assert('no-reviews-at-all-is-unapproved', approvalVerdict([]).state === 'unapproved'); + assert('a-COMMENTED-review-is-not-an-approval', approvalVerdict([{ state: 'COMMENTED', user: { login: 'a' } }]).state === 'unapproved'); + // ⭐ The fail-open direction a naive `.some(r => r.state === 'APPROVED')` gets + // wrong, and the only direction this file may not be wrong in. + assert( + 'an-approval-later-superseded-by-CHANGES_REQUESTED-is-NOT-an-approval', + approvalVerdict([ + { state: 'APPROVED', user: { login: 'a' } }, + { state: 'CHANGES_REQUESTED', user: { login: 'a' } }, + ]).state === 'unapproved', + ); + assert( + 'a-CHANGES_REQUESTED-later-superseded-by-an-approval-IS-an-approval', + approvalVerdict([ + { state: 'CHANGES_REQUESTED', user: { login: 'a' } }, + { state: 'APPROVED', user: { login: 'a' } }, + ]).state === 'approved', + ); + assert('a-DISMISSED-approval-is-not-an-approval', approvalVerdict([{ state: 'DISMISSED', user: { login: 'a' } }]).state === 'unapproved'); + assert( + 'one-reviewers-changes-request-does-not-erase-anothers-approval-but-IS-reported', + (() => { + const v = approvalVerdict([...approved('a'), { state: 'CHANGES_REQUESTED', user: { login: 'b' } }]); + return v.state === 'approved' && v.changesRequestedBy.join() === 'b'; + })(), + ); + assert('the-state-comparison-is-case-insensitive-the-API-has-shipped-both', approvalVerdict([{ state: 'approved', user: { login: 'a' } }]).state === 'approved'); + + // -- the pinned predicate (the queue leg's) -------------------------------- + assert('the-authorized-set-is-the-two-carried-accounts', GOVERNED_APPROVERS.join() === 'os-zhuang,hotlong'); + for (const login of GOVERNED_APPROVERS) { + assert(`an-authorized-approval-pinned-to-the-current-head-passes: ${login}`, pinnedApprovalVerdict([approvedAt(login, HEAD)], HEAD).state === 'approved'); + } + const stale = pinnedApprovalVerdict([approvedAt(GOVERNED_APPROVERS[0], OLD)], HEAD); + assert('a-STALE-authorized-approval-never-counts', stale.state === 'unapproved' && stale.staleApprovers[0]?.login === GOVERNED_APPROVERS[0]); + assert('an-approval-with-no-commit_id-is-stale-never-pinned', pinnedApprovalVerdict(approved(GOVERNED_APPROVERS[0]), HEAD).state === 'unapproved'); + const outsider = pinnedApprovalVerdict([approvedAt('not-authorized', HEAD)], HEAD); + assert('an-unauthorized-approval-never-counts-even-pinned-to-head', outsider.state === 'unapproved' && outsider.unauthorizedApprovers.join() === 'not-authorized'); + assert( + 'an-authorized-approval-later-superseded-by-CHANGES_REQUESTED-never-counts', + pinnedApprovalVerdict([approvedAt(GOVERNED_APPROVERS[0], HEAD), { state: 'CHANGES_REQUESTED', user: { login: GOVERNED_APPROVERS[0] }, commit_id: HEAD }], HEAD).state === 'unapproved', + ); + assert( + 'a-DISMISSED-authorized-approval-never-counts', + pinnedApprovalVerdict([approvedAt(GOVERNED_APPROVERS[1], HEAD), { state: 'DISMISSED', user: { login: GOVERNED_APPROVERS[1] }, commit_id: HEAD }], HEAD).state === 'unapproved', + ); + assert('no-reviews-at-all-is-unapproved-under-the-pinned-predicate-too', pinnedApprovalVerdict([], HEAD).state === 'unapproved'); + assert( + 'an-unauthorized-approval-does-not-mask-an-authorized-pinned-one', + pinnedApprovalVerdict([approvedAt('not-authorized', HEAD), approvedAt(GOVERNED_APPROVERS[1], HEAD)], HEAD).approvers.join() === GOVERNED_APPROVERS[1], + ); + assert('the-sha-comparison-is-case-insensitive', pinnedApprovalVerdict([approvedAt(GOVERNED_APPROVERS[0], HEAD.toUpperCase())], HEAD).state === 'approved'); + assert( + 'an-unparsable-head-sha-pins-NOTHING-fail-closed', + pinnedApprovalVerdict([approvedAt(GOVERNED_APPROVERS[0], '')], '').state === 'unapproved' && + pinnedApprovalVerdict([approvedAt(GOVERNED_APPROVERS[0], HEAD)], undefined).state === 'unapproved', + ); + + // -- decomposition, and the multi-PR group trap ---------------------------- + const clearRows = [row(1, ['packages/core/src/index.ts', 'content/docs/guide/x.md'])]; + assert('a-clear-diff-decomposes-to-nothing', decomposeGovernedWork(clearRows).governed.length === 0 && decomposeGovernedWork(clearRows).unattributed.length === 0); + const mixed = decomposeGovernedWork([row(5, ['AGENTS.md', 'packages/core/src/index.ts'])]); + assert('a-mixed-diff-governs-the-whole-pr-and-lists-only-the-governed-paths', mixed.governed[0].paths.join() === 'AGENTS.md', JSON.stringify(mixed.governed[0].paths)); + const batched = decomposeGovernedWork([row(11, ['docs/adr/0099-x.md'], 'a'.repeat(40)), row(12, ['packages/core/src/x.ts'], 'b'.repeat(40))]); + assert('a-batched-group-attributes-the-governed-diff-to-ITS-OWN-pr-not-the-last-one', batched.governed.length === 1 && batched.governed[0].pr === 11, JSON.stringify(batched.governed.map((g) => g.pr))); + const twoGoverned = decomposeGovernedWork([row(11, ['AGENTS.md']), row(12, ['skills/objectui/SKILL.md'], 'b'.repeat(40))]); + assert('two-governed-prs-in-one-group-are-both-carried', twoGoverned.governed.map((g) => g.pr).join() === '11,12'); + const unattributed = decomposeGovernedWork([{ sha: 'c'.repeat(40), subject: 'chore: direct work', pr: null, paths: ['CLAUDE.md'] }]); + assert('a-governed-commit-naming-no-pr-is-UNATTRIBUTED-never-dropped', unattributed.unattributed.length === 1 && unattributed.governed.length === 0); + assert('an-UNGOVERNED-commit-naming-no-pr-is-simply-not-our-business', decomposeGovernedWork([{ sha: 'd'.repeat(40), subject: 'x', pr: null, paths: ['README.md'] }]).unattributed.length === 0); + + // -- the verdict table, both events ---------------------------------------- + const clearV = run('merge_group', clearRows); + assert('a-clear-merge-group-is-CLEAR-and-exits-0', clearV.conclusion === 'clear' && clearV.exitCode === EXIT_CLEAR); + assert('and-it-made-zero-review-lookups', clearV.apiCalls === 0); + const refusedV = run('merge_group', [row(6183, ['AGENTS.md'])], new Map([[6183, pinnedApprovalVerdict([], HEAD)]])); + assert('an-unapproved-governed-merge-group-is-REFUSED-with-code-3', refusedV.conclusion === 'refused' && refusedV.exitCode === EXIT_REFUSED_UNAPPROVED); + const clearedV = run('merge_group', [row(6183, ['AGENTS.md'])], new Map([[6183, pinnedPass()]])); + assert('an-authorized-pinned-approval-CLEARS-the-merge-group-and-exits-0', clearedV.conclusion === 'cleared' && clearedV.exitCode === EXIT_CLEAR); + const staleV = run('merge_group', [row(6183, ['AGENTS.md'])], new Map([[6183, pinnedApprovalVerdict([approvedAt(GOVERNED_APPROVERS[0], OLD)], HEAD)]])); + assert('a-stale-sha-approval-REFUSES-the-merge-group-with-code-3', staleV.conclusion === 'refused' && staleV.exitCode === EXIT_REFUSED_UNAPPROVED); + const outsiderV = run('merge_group', [row(6183, ['AGENTS.md'])], new Map([[6183, pinnedApprovalVerdict([approvedAt('not-authorized', HEAD)], HEAD)]])); + assert('an-unauthorized-account-approval-REFUSES-the-merge-group-with-code-3', outsiderV.conclusion === 'refused' && outsiderV.exitCode === EXIT_REFUSED_UNAPPROVED); + const unreadableV = run('merge_group', [row(6183, ['AGENTS.md'])], new Map([[6183, unreadableApproval('HTTP 502')]])); + assert('an-unreadable-review-list-is-a-REFUSAL-not-a-pass', unreadableV.conclusion === 'refused' && unreadableV.exitCode === EXIT_REFUSED_UNREADABLE); + const missingV = run('merge_group', [row(6183, ['AGENTS.md'])]); + assert('a-governed-pr-with-NO-recorded-reading-refuses-too-there-is-no-default-pass', missingV.exitCode === EXIT_REFUSED_UNREADABLE); + const unattrV = run('merge_group', [{ sha: 'c'.repeat(40), subject: 'chore: x', pr: null, paths: ['CLAUDE.md'] }]); + assert('an-unattributed-governed-commit-is-REFUSED-with-its-own-code', unattrV.conclusion === 'refused' && unattrV.exitCode === EXIT_REFUSED_UNATTRIBUTED); + const partial = run( + 'merge_group', + [row(11, ['AGENTS.md']), row(12, ['skills/objectui/SKILL.md'], 'b'.repeat(40))], + new Map([[11, pinnedPass()], [12, pinnedApprovalVerdict([], HEAD)]]), + ); + assert('one-approved-pr-does-NOT-carry-an-unapproved-sibling-through-the-same-group', partial.exitCode === EXIT_REFUSED_UNAPPROVED); + + // -- the pull_request leg is an EARLY WARNING and never reddens ------------- + const warnedV = run('pull_request', [row(6183, ['AGENTS.md'])], new Map([[6183, approvalVerdict([])]])); + assert('a-governed-unapproved-PULL-REQUEST-is-WARNED-not-refused', warnedV.conclusion === 'warned' && warnedV.exitCode === EXIT_CLEAR); + assert( + 'the-pr-leg-never-reddens-under-ANY-approval-state-that-is-the-permanently-red-poison', + ['unapproved', 'unreadable', 'approved'].every( + (state) => run('pull_request', [row(1, ['AGENTS.md'])], new Map([[1, { state, approvers: [], changesRequestedBy: [], reviewsRead: 0 }]])).exitCode === EXIT_CLEAR, + ), + ); + assert('and-an-unattributed-governed-commit-does-not-redden-a-pr-run-either', run('pull_request', [{ sha: 'c'.repeat(40), subject: 'x', pr: null, paths: ['CLAUDE.md'] }]).exitCode === EXIT_CLEAR); + + // -- the replay fixtures --------------------------------------------------- + for (const replay of REPLAYS) { + const rows = [row(replay.pr, replay.files, 'e'.repeat(40), replay.subject)]; + const queued = run('merge_group', rows, new Map([[replay.pr, pinnedApprovalVerdict([], HEAD)]])); + assert(`replay-REFUSES-at-the-queue: ${replay.name}`, queued.exitCode === EXIT_REFUSED_UNAPPROVED, JSON.stringify(queued.conclusion)); + const early = run('pull_request', rows, new Map([[replay.pr, approvalVerdict([])]])); + assert(`replay-only-WARNS-on-the-pr: ${replay.name}`, early.conclusion === 'warned' && early.exitCode === EXIT_CLEAR); + const text = renderGuardVerdict(queued); + assert(`replay-names-its-governed-paths: ${replay.name}`, replay.files.filter((f) => governedPathsIn([f]).length > 0).every((f) => text.includes(f)), text); + // The subject the fixture carries is the one `enumerateRows` would parse, + // so the attribution leg is exercised on the same string a queue build sees. + assert(`replay-subject-attributes-to-its-own-pr: ${replay.name}`, pullNumberFromSubject(replay.subject) === replay.pr, replay.subject); + } + + // -- ⭐ the ordering guarantee, measured with a spy that THROWS ------------- + // + // "The path test runs first and a clear diff makes no API call" is a claim + // about control flow, so it is tested by making the API impossible to touch. + // A mock returning [] would have passed against a version that called it. + let apiTouched = 0; + const explode = () => { + apiTouched += 1; + throw new Error('the API must not be reached for a diff that touches nothing governed'); + }; + let orderedClear = null; + let orderedThrow = null; + try { + orderedClear = await runGuard({ event: 'merge_group', rows: clearRows, fetchReviews: explode, fetchPullHead: explode }); + } catch (error) { + orderedThrow = String(error?.message ?? error); + } + assert( + 'a-clear-diff-NEVER-constructs-a-head-or-review-request', + apiTouched === 0 && orderedThrow === null && orderedClear?.exitCode === EXIT_CLEAR && orderedClear?.conclusion === 'clear', + `apiTouched=${apiTouched} threw=${orderedThrow ?? 'no'}`, + ); + // …and the other half: a governed diff DOES reach both reads — head first — + // so the case above proves an ordering, not a dead code path. + const trace = []; + const traced = await runGuard({ + event: 'merge_group', + rows: [row(1, ['AGENTS.md'])], + fetchPullHead: () => { + trace.push('head'); + return HEAD; + }, + fetchReviews: () => { + trace.push('reviews'); + return [approvedAt(GOVERNED_APPROVERS[0], HEAD)]; + }, + }); + assert('a-governed-queue-diff-reads-head-THEN-reviews', trace.join() === 'head,reviews', trace.join()); + assert( + 'the-pinned-predicate-is-wired-end-to-end-an-authorized-pinned-approval-CLEARS', + traced.conclusion === 'cleared' && traced.exitCode === EXIT_CLEAR && traced.apiCalls === 2, + JSON.stringify({ conclusion: traced.conclusion, apiCalls: traced.apiCalls }), + ); + const tracedStale = await runGuard({ + event: 'merge_group', + rows: [row(1, ['AGENTS.md'])], + fetchPullHead: () => HEAD, + fetchReviews: () => [approvedAt(GOVERNED_APPROVERS[0], OLD)], + }); + assert('the-pinned-predicate-is-wired-end-to-end-a-stale-approval-REFUSES', tracedStale.exitCode === EXIT_REFUSED_UNAPPROVED); + // The early-warning leg makes NO head read: it never reddens, so the stricter + // question would be traffic bought for nothing. + let prHeadReads = 0; + const prLeg = await runGuard({ + event: 'pull_request', + rows: [row(1, ['AGENTS.md'])], + fetchPullHead: () => { + prHeadReads += 1; + return HEAD; + }, + fetchReviews: () => approved('anyone'), + }); + assert( + 'the-pr-leg-makes-NO-head-read-and-keeps-the-any-approver-reading', + prHeadReads === 0 && prLeg.conclusion === 'warned' && prLeg.apiCalls === 1 && + renderGuardVerdict(prLeg).includes('✅ APPROVED review present, by: anyone') && + renderGuardVerdict(prLeg).includes('1 review lookup(s).'), + renderGuardVerdict(prLeg), + ); + // A throwing reader on a GOVERNED diff becomes a refusal, never a pass — and + // `runGuard` must CONTAIN the throw rather than propagate it, so this is + // caught too: an escaping error would abort every case after it, and an + // aborted self-test hides the failures it already collected. + let thrown = null; + let thrownEscaped = null; + try { + thrown = await runGuard({ + event: 'merge_group', + rows: [row(1, ['AGENTS.md'])], + fetchPullHead: () => HEAD, + fetchReviews: () => { + throw new Error('HTTP 403'); + }, + }); + } catch (error) { + thrownEscaped = String(error?.message ?? error); + } + assert( + 'a-throwing-review-read-on-a-governed-diff-REFUSES-and-the-throw-never-escapes', + thrownEscaped === null && thrown?.exitCode === EXIT_REFUSED_UNREADABLE && /403/.test(renderGuardVerdict(thrown)), + thrownEscaped ? `escaped: ${thrownEscaped}` : renderGuardVerdict(thrown), + ); + // An unreadable PR HEAD is its own refusal, and the review request is never + // even constructed after it — fail closed, in order. + let reviewsAfterHeadFailure = 0; + const headFailed = await runGuard({ + event: 'merge_group', + rows: [row(1, ['AGENTS.md'])], + fetchPullHead: () => { + throw new Error('HTTP 500'); + }, + fetchReviews: () => { + reviewsAfterHeadFailure += 1; + return []; + }, + }); + assert( + 'an-unreadable-pr-head-REFUSES-with-exit-4-and-never-reads-reviews', + headFailed.exitCode === EXIT_REFUSED_UNREADABLE && reviewsAfterHeadFailure === 0 && /500/.test(renderGuardVerdict(headFailed)), + `reviewsAfterHeadFailure=${reviewsAfterHeadFailure}`, + ); + + // -- the words a reader acts on -------------------------------------------- + const refusalText = renderGuardVerdict(refusedV); + assert('a-refusal-names-the-exact-paths-that-matched', refusalText.includes('AGENTS.md'), refusalText); + assert('a-refusal-names-the-pull-request', refusalText.includes('#6183'), refusalText); + assert('a-refusal-states-what-would-satisfy-it', /What satisfies this check/.test(refusalText) && /DRAFT/.test(refusalText) && /APPROVED review/.test(refusalText), refusalText); + assert('a-refusal-names-the-preferred-remedy-first-and-it-is-DEQUEUE-not-approve', refusalText.indexOf('DRAFT') < refusalText.indexOf('obtain an APPROVED review'), refusalText); + assert('a-refusal-forecloses-the-edit-the-check-remedy', /Neither of those is "edit this check"/.test(refusalText), refusalText); + assert('a-refusal-carries-the-runnable-derivation-command', refusalText.includes('check-governed-queue-guard.mjs --test'), refusalText); + assert('a-refusal-names-the-incident-it-descends-from', /6183/.test(refusalText) && /5b3290fd5/.test(refusalText), refusalText); + const clearText = renderGuardVerdict(clearV); + assert('a-clear-run-says-it-cost-zero-lookups', /ZERO review lookups/.test(clearText), clearText); + assert('a-clear-run-points-at-the-register-rather-than-listing-surfaces', clearText.includes('GOVERNED_SURFACES') && !clearText.includes('docs/adr/**'), clearText); + const warnText = renderGuardVerdict(warnedV); + assert('the-warning-says-out-loud-that-it-is-deliberately-green', /EARLY WARNING/.test(warnText) && /GREEN here/.test(warnText), warnText); + assert('the-warning-tells-a-seat-what-not-to-do', /flip it ready, enqueue it, or arm auto-merge/.test(warnText), warnText); + // ⭐ The warning names the exact mechanism of the incident, because the seat + // reading it is the seat about to make the same call. + assert('the-warning-names-the-hidden-draft-false-side-effect', /draft: false/.test(warnText) && /update_pull_request/.test(warnText), warnText); + assert('the-warning-forecasts-the-queue-refusal', /REFUSES it/.test(warnText), warnText); + assert('an-outstanding-changes-request-is-reported-even-though-it-does-not-flip-the-verdict', /CHANGES_REQUESTED from: b/.test(renderGuardVerdict(run('merge_group', [row(1, ['AGENTS.md'])], new Map([[1, approvalVerdict([...approved('a'), { state: 'CHANGES_REQUESTED', user: { login: 'b' } }])]]))))); + const kinds = [refusedV, unreadableV, unattrV].map((v) => renderGuardVerdict(v)); + assert('the-three-refusal-kinds-render-three-different-explanations', new Set(kinds).size === 3); + assert('the-unreadable-refusal-says-it-is-deliberately-not-a-pass', /refusal and not a pass/.test(kinds[1]), kinds[1]); + assert( + 'the-refusal-remedy-names-every-authorized-approver-from-the-constant', + GOVERNED_APPROVERS.every((login) => refusalText.includes(login)) && refusalText.includes('GOVERNED_APPROVERS'), + refusalText, + ); + assert('the-refusal-remedy-states-the-agent-no-approve-prohibition', /An agent seat never submits that approval/.test(refusalText), refusalText); + const staleText = renderGuardVerdict(staleV); + assert( + 'a-stale-refusal-names-both-shas-so-a-reader-can-see-the-push-that-unpinned-it', + staleText.includes(OLD.slice(0, 12)) && staleText.includes(HEAD.slice(0, 12)) && /STALE, never counts/.test(staleText), + staleText, + ); + assert( + 'an-unauthorized-refusal-says-the-approval-never-counts', + /APPROVED by account\(s\) outside GOVERNED_APPROVERS: not-authorized — never counts/.test(renderGuardVerdict(outsiderV)), + renderGuardVerdict(outsiderV), + ); + const clearedText = renderGuardVerdict(clearedV); + assert( + 'the-cleared-summary-states-the-pinned-predicate-and-derives-its-accounts-from-the-constant', + /commit_id equals/.test(clearedText) && GOVERNED_APPROVERS.every((login) => clearedText.includes(login)), + clearedText, + ); + assert('a-pinned-pass-renders-the-head-it-is-pinned-to', clearedText.includes(`pinned to head ${HEAD.slice(0, 12)}`), clearedText); + + // -- the seat-side `--test` predicate -------------------------------------- + const governedTest = testVerdict(['AGENTS.md', 'packages/core/src/index.ts']); + assert('--test-reports-a-mixed-diff-as-GOVERNED', governedTest.governed && governedTest.hitPaths.join() === 'AGENTS.md' && governedTest.clearPaths.join() === 'packages/core/src/index.ts'); + const clearTest = testVerdict(['packages/core/src/index.ts', 'package.json']); + assert('--test-reports-an-ordinary-diff-as-NOT-GOVERNED', !clearTest.governed && clearTest.checked === 2); + const governedTestText = renderTestVerdict(governedTest); + assert('--test-names-the-surface-and-the-file', /GOVERNED/.test(governedTestText) && governedTestText.includes('AGENTS.md') && governedTestText.includes('the repo-root agent instruction file'), governedTestText); + assert('--test-prescribes-the-draft-remedy', /DRAFT/.test(governedTestText) && /human merge IS the review record/.test(governedTestText), governedTestText); + assert('--test-names-the-check-a-queue-build-would-run', governedTestText.includes(CHECK_CONTEXT_NAME), governedTestText); + assert('--test-on-a-clear-list-says-the-normal-route-applies', /NOT GOVERNED/.test(renderTestVerdict(clearTest)), renderTestVerdict(clearTest)); + // Empty input is BAD USAGE, not a clearance: `--test` with no paths would + // otherwise print "not governed" about a question nobody asked. + assert('--test-with-no-paths-is-BAD-USAGE-not-a-clearance', runTestMode(['node', 'x', '--test']) === EXIT_BAD_USAGE); + + // -- the WIRING pins: the workflow and the required-context register -------- + // + // Without these, renaming the job detaches the context this repository has + // written down as blocking, and the name declared here becomes a name nothing + // publishes. Read from disk on purpose: a constant asserting against itself + // proves nothing. + try { + const wf = readFileSync(join(repoRoot, '.github', 'workflows', CHECK_WORKFLOW), 'utf8'); + assert('the-workflow-exists-and-declares-the-job-id-this-file-names', wf.includes(`\n ${CHECK_JOB_ID}:\n`), CHECK_JOB_ID); + assert('the-workflow-publishes-EXACTLY-the-context-name-this-file-declares', wf.includes(`name: ${CHECK_CONTEXT_NAME}\n`), CHECK_CONTEXT_NAME); + assert('the-workflow-triggers-on-merge_group-the-leg-that-actually-refuses', /^ {2}merge_group:\s*$/m.test(wf), 'merge_group trigger absent'); + assert('the-workflow-triggers-on-pull_request-the-early-warning-leg', /^ {2}pull_request:\s*$/m.test(wf), 'pull_request trigger absent'); + // ⭐ `ready_for_review` is the addition that matters: flipping a governed + // draft to ready is the first move of the exact sequence #6183 took, and it + // is NOT in GitHub's default `types:` set. Naming `types:` REPLACES the + // default set rather than extending it, so all four are restated. + assert('the-workflow-re-fires-on-ready_for_review-the-first-move-of-the-incident', /types:\s*\[opened, synchronize, reopened, ready_for_review\]/.test(wf), 'ready_for_review absent from types'); + assert('the-workflow-invokes-THIS-script', wf.includes('scripts/check-governed-queue-guard.mjs'), 'invocation absent'); + assert('the-workflow-runs-the-self-test-before-the-live-judgment', wf.indexOf('--self-test') < wf.indexOf('GITHUB_TOKEN'), 'the self-test does not precede the live step'); + assert('the-workflow-checks-out-full-history-a-truncated-diff-answers-with-silence', /fetch-depth:\s*0/.test(wf), 'fetch-depth: 0 absent'); + assert('the-workflow-declares-pull-requests-read-the-only-scope-the-review-read-needs', /pull-requests:\s*read/.test(wf), 'pull-requests: read absent'); + // ⛔ A skipped job counts as SUCCESS in branch protection, so a path filter + // would hand the queue a green verdict for a diff the filter mis-scoped — + // on the one check whose entire job is to refuse. The path test belongs + // INSIDE the script, where "nothing governed" costs zero API calls. + assert('the-workflow-carries-no-paths-filter-a-skipped-guard-counts-as-SUCCESS', !/^\s*paths(-ignore)?:/m.test(wf), 'a paths filter would make this guard skippable'); + } catch (error) { + assert('the-workflow-file-is-readable', false, String(error?.message ?? error).split('\n')[0]); + } + try { + const gate = readFileSync(join(repoRoot, 'scripts', 'dependabot-merge-gate.mjs'), 'utf8'); + assert( + 'this-repository-has-written-the-context-down-as-blocking', + gate.includes(`'${CHECK_CONTEXT_NAME}'`), + `${CHECK_CONTEXT_NAME} is absent from scripts/dependabot-merge-gate.mjs — REQUIRED_CONTEXTS is this repo's own answer to "which checks are blocking", and a guard outside it is a guard the queue floor cannot derive`, + ); + } catch (error) { + assert('the-required-context-register-is-readable', false, String(error?.message ?? error).split('\n')[0]); + } + + for (const f of failures) console.error(` x ${f}`); + if (failures.length > 0) { + console.error(`FAIL check-governed-queue-guard self-test: ${failures.length} of ${checked} case(s) failed.`); + return 1; + } + console.log( + `OK check-governed-queue-guard self-test: ${checked} cases pass ` + + '(the five ruled surfaces pinned in both directions including the widenings NOT taken, the queue/PR ' + + 'event split, latest-decisive approval reduction, the authorized-approval-pinned-to-head predicate on ' + + 'the queue leg — pass, stale, unauthorized, dismissed/superseded, none — with the PR leg head-read-free, ' + + 'multi-PR group decomposition, four replayed governed shapes, the zero-API ordering guarantee measured ' + + 'with throwing spies, the head-then-reviews read order with both unreadable refusals, the seat-side ' + + '--test predicate, and the workflow + required-context wiring pins).', + ); + return 0; +} + +if (isEntrypoint(import.meta.url) && process.argv.includes('--self-test')) { + process.exit(await selfTest()); +} diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs index 7aede0f0f0..550816745f 100644 --- a/scripts/dependabot-merge-gate.mjs +++ b/scripts/dependabot-merge-gate.mjs @@ -131,6 +131,19 @@ import { isEntrypoint } from './invoked-as.mjs'; * shell-escape-residue.yml Shell Escape Residue Scan * readme-exports.yml README Export Check * docs-route-eager-closure.yml Docs Route Eager Closure Check + * governed-surface-guard.yml Governed Surface Queue Guard + * + * `Governed Surface Queue Guard` is the newest and the one whose reading here + * differs from every other row, so it is worth a sentence. On a PULL REQUEST it + * is deliberately green whatever it finds — a governed pull request parked as a + * draft for the maintainer is the healthy case, and a check red on the healthy + * case is a permanently red check (objectui#6596). Its refusal lives on the + * `merge_group` leg. Listing it here is still correct and still load-bearing: + * this list is what `merge-queue-reporting.test.ts` derives the `merge_group` + * subscription floor from, and a guard whose whole point is the queue build must + * be inside that floor. It also costs a Dependabot bump nothing — such a diff + * touches no governed path, so the check reports CLEAR without making a single + * API call. * * The four shards are spelled out individually on purpose. A single `Test` * entry, or any pattern match, would be satisfied by whichever shard happened @@ -158,6 +171,7 @@ export const REQUIRED_CONTEXTS = Object.freeze([ 'Shell Escape Residue Scan', 'README Export Check', 'Docs Route Eager Closure Check', + 'Governed Surface Queue Guard', ]); /**