diff --git a/.github/workflows/closing-target-claim-guard.yml b/.github/workflows/closing-target-claim-guard.yml deleted file mode 100644 index 023b93d7572..00000000000 --- a/.github/workflows/closing-target-claim-guard.yml +++ /dev/null @@ -1,140 +0,0 @@ -# A pull request may close a card only while that card's own thread carries a -# `Claim:` naming this pull request's head branch. -# -# Every agent here shares ONE GitHub identity, so the assignee field is a -# presence bit and the `Claim:` comment is the identity record. Two guards -# already run at pull-request time — the Duplicate Fix Guard ("no other open PR -# may claim the same issue") and the Single-Claim Path Guard — and neither asks -# the question that decides ownership. That question WAS already asked, by row -# H46 of the half-state sweep, and asked AFTER THE FACT as a report-only patrol -# input: the cloud-repo incident (one card implemented 29 minutes apart by two -# seats, the second with no claim) and an objectstack card implemented twice in -# one morning were both visible to that predicate and stopped by nothing. A -# control that detects a defect it cannot prevent is a TIMING defect in an -# existing control, not an absent one — so this workflow runs the same -# predicate at the moment the merge can still be stopped. -# -# The rule, the imported predicates, the declined-number policy, the -# branch-rename remedy and the exit contract all live in -# `scripts/check-closing-target-claim.mjs`; that header is authoritative and -# this file is the invocation. -# -# Sibling shape, deliberately copied rather than reinvented: the Part-of -# Closing-Keyword Guard and the Single-Claim Path Guard are this repo's other -# PR-scoped blocking checks, and this takes their job shape, their runtime pin -# and their `pull_request` trigger set. -name: Closing-Target Claim Guard - -# THREE things are load-bearing here. -# -# `edited`: one of the two remedies is to drop the closing keyword and write -# `Part of #N` instead. The body is the frozen half of this check's input, so -# that remedy needs a fresh event rather than a re-run — exactly as in the two -# body-scoped sibling guards. -# -# `synchronize`: the head BRANCH is half the question, and a force-free re-push -# onto a differently-named branch arrives as a new pull request, but a push that -# changes what the PR contains must be re-judged on the commit that changed it. -# -# `merge_group`: and this is where this gate deliberately DIVERGES from both -# siblings, whose headers say they take no queue leg because a merge-queue event -# carries no pull request. True — and here it does not matter, because the queue -# REF NAMES its pull request (`gh-readonly-queue//pr--`). The -# script reads that number with the governed queue guard's own shipped parser -# and then reads the same PR body and the same live comment threads, so the -# queue build judges the SAME pull request by the SAME rule. That is what makes -# the card's "the check holds in the queue too" a wiring fact rather than an -# assumption. -# -# ⚠️ In a MULTI-PR group the queue ref names only the LAST pull request; the -# script header states that limit. Every member of the group passed the -# `pull_request` leg to be armed at all. -on: - pull_request: - types: [opened, edited, reopened, synchronize] - merge_group: - -# ⛔ NO `paths:` filter, on either leg. A skipped job counts as SUCCESS in branch -# protection, so a path filter would hand a green check to the very PR the -# filter mis-scoped — and this gate's input is a PR BODY, which no path filter -# can see at all. (`merge_group` has no paths support either, so a filter would -# also make the two legs disagree about what they cover.) - -# Read-only, and that is the whole grant. This gate reports; it never closes a -# PR, comments, edits a body, or writes a label. -# -# `contents: read` is not optional padding: naming a `permissions:` block at all -# sets every scope NOT listed to `none`, and this job checks the repo out to get -# at the script. `issues: read` is what the card comment threads need — the -# claim record is an ISSUE comment, and the pull-requests scope does not reach -# it. `pull-requests: read` is what the merge_group leg needs to read the body -# of the pull request its queue ref names. -permissions: - contents: read - issues: read - pull-requests: read - -concurrency: - group: closing-target-claim-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - closing-target-claim: - # ⚠️ 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. - # - # ⛔ Making it a required context is NOT this PR's step. Branch protection - # takes a `REQUIRED_CONTEXTS` row PLUS the Settings → Rulesets entry, in one - # sitting, and the settings half is the maintainer's. Either half alone is - # an outage. What the merge_group leg above buys today is that the question - # is asked on the queue build; what it buys the day someone does make this - # required is that it cannot deadlock the queue, which a workflow with no - # queue leg always does. - name: The card this PR closes must claim this branch - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - # Pinned to the same major and spelling as every other setup-node in this - # repo, for the measured reason the sibling guards record: a setup-node - # major whose package-manager-cache default is on reads `packageManager` - # out of package.json and shells out to pnpm to find its store, killing - # the job in the SETUP step with "Unable to locate executable file: pnpm" - # — before the script runs, and naming a tool the workflow source never - # mentions. This job installs no package manager on purpose: the script is - # dependency-free and imports two sibling modules. - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version-file: .nvmrc - - # Everything reaches the script through `env:`, never through `${{ }}` - # inside the `run:` line. A PR body is arbitrary author-controlled text, - # and an expression interpolated into a shell line is substituted before - # bash ever sees it; through `env:` it is inert data. The script's - # self-test pins this spelling. - # - # On the merge_group leg every `github.event.pull_request.*` expression - # renders EMPTY and `MERGE_GROUP_HEAD_REF` carries the queue ref, which is - # what selects the leg inside the script. On the pull_request leg the - # reverse holds. Neither leg needs its own step. - # - # `PR_HEAD_REF` is not decoration: the head branch IS the question this - # gate asks, so a blank one exits NOT MEASURED rather than green. - # - # The token is required, not optional: the claim record is a comment on - # another issue, which cannot be answered from the event payload. - - name: A PR may close only a card that claims its branch - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BODY: ${{ github.event.pull_request.body }} - PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} - MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node scripts/check-closing-target-claim.mjs diff --git a/.github/workflows/duplicate-fix-guard.yml b/.github/workflows/duplicate-fix-guard.yml deleted file mode 100644 index 4fffeb5e806..00000000000 --- a/.github/workflows/duplicate-fix-guard.yml +++ /dev/null @@ -1,156 +0,0 @@ -# GitHub happily lets any number of open PRs declare `Fixes #N` for the same -# issue. On 2026-08-02 that cost a full duplicate implementation: #4555 and -# #4559 both declared `Fixes #4551`, both ran the whole gate suite green, and -# the duplication sat machine-detectable from the moment the second PR opened -# (03:08) until a human noticed it (08:52). All agents here share one GitHub -# identity, so the issue's assignee could not warn the second author either — -# "assigned to os-zhuang" reads the same whether it is you or another session. -# Full post-mortem: #4588. -# -# This gate makes the second PR red at open time. First come, first served — -# the EARLIER open PR (lower number) keeps its claim and stays green; the -# later one fails with a pointer to it. That matches the pm-dispatch claim -# convention ("first claim comment wins") already in .claude/skills/. -# -# Scope: same-repo references only (bare `#N` and qualified `#N`). -# Cross-repo references are the cross-repo-issue-closer's territory, and a -# duplicate across repos cannot be resolved by failing one side's CI anyway. -name: Duplicate Fix Guard - -# `edited` matters as much as `opened`: a PR that adds `Fixes #N` to its body -# after the fact must re-run this check, and one that drops the line must be -# able to go green again. -on: - pull_request: - types: [opened, edited, reopened, synchronize] - -permissions: - pull-requests: read - -# A newer run for the same pull request supersedes this one, so the older run is -# cancelled. The group key is ci.yml's shape; `cancel-in-progress` is true only on -# `pull_request`, so no other event's run is ever cancelled. -concurrency: - group: duplicate-fix-guard-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.after || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - duplicate-fix-guard: - name: No other open PR may claim the same issue - runs-on: ubuntu-latest - # Run wall over the 100 most recent completed runs to 2026-10-27: p99 0.3 min, max - # 0.8. - timeout-minutes: 5 - steps: - - name: Check declared issues against other open PRs - uses: actions/github-script@v9 - with: - script: | - const pr = context.payload.pull_request; - const thisRepo = `${context.repo.owner}/${context.repo.repo}`; - - // GitHub's own closing-keyword set. The optional colon is part of - // GitHub's accepted syntax (`Fixes: #123`). Two same-repo forms: - // bare `#N` and qualified `owner/repo#N` naming THIS repo. - const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved'; - const pattern = new RegExp( - `\\b(?:${KEYWORDS}):?\\s+(?:([\\w.-]+)\\/([\\w.-]+))?#(\\d+)\\b`, - 'gi', - ); - - // Strip fenced blocks and inline code spans before matching. - // GitHub's own closing-keyword parser ignores code formatting, and - // this guard must not be stricter than the linker it protects: - // this very PR's first run counted its own DISCUSSION of - // `Fixes #4551` (in backticks, describing the incident) as a - // declaration. Benign there — #4551 is closed — but a prose - // mention of an issue some other open PR really fixes would have - // been a spurious red. - const declaredIssues = (body) => { - const prose = (body || '') - .replace(/```[\s\S]*?```/g, ' ') - .replace(/`[^`\n]*`/g, ' '); - const found = new Set(); - for (const [, owner, repo, number] of prose.matchAll(pattern)) { - // A qualified reference to ANOTHER repo is not ours to judge. - if (owner && `${owner}/${repo}`.toLowerCase() !== thisRepo.toLowerCase()) continue; - found.add(Number(number)); - } - return found; - }; - - const mine = declaredIssues(pr.body); - if (mine.size === 0) { - core.info('This PR declares no same-repo closing keywords — nothing to guard.'); - return; - } - core.info(`This PR declares: ${[...mine].map((n) => `#${n}`).join(', ')}`); - - // Branch-name convention (advisory, never red): a fix branch named - // `claude/issue--` is discoverable by the next session - // with one `git ls-remote --heads origin | grep -E 'issue-(-|$)'`. - // #4555 vs #4559 happened partly because the branches shared no - // token to grep. Both spellings delimit the number on the RIGHT: - // undelimited, `issue-186` matches `claude/issue-18611-x`, so the - // advisory stays silent for a branch naming a DIFFERENT card — - // the false negative. The `$` arm keeps the slug-less spelling - // `claude/issue-186` matching; `issue-` anchors the left, so - // `issue-1186` and `issue-0186` do not match card 186 either. - // Warning only — existing branches must not go red retroactively. - const branch = pr.head.ref; - if (![...mine].some((n) => new RegExp(`issue-${n}(-|$)`).test(branch))) { - core.warning( - `Branch \`${branch}\` does not name any declared issue. ` + - `Convention: claude/issue-- (e.g. claude/issue-${[...mine][0]}-short-slug) ` + - `so parallel sessions can discover in-flight work with git ls-remote.`, - ); - } - - // Drafts count: a draft PR is work in flight, which is exactly - // what the second session needs to see. - const openPrs = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100, - }); - - const conflicts = []; - for (const other of openPrs) { - if (other.number === pr.number) continue; - const theirs = declaredIssues(other.body); - const shared = [...mine].filter((n) => theirs.has(n)); - if (shared.length > 0) conflicts.push({ other, shared }); - } - - if (conflicts.length === 0) { - core.info('No other open PR declares these issues.'); - return; - } - - // First come, first served: only the LATER PR goes red. Failing - // both would leave the original author red through no action of - // their own; failing the earlier one would reward racing. - const older = conflicts.filter((c) => c.other.number < pr.number); - const newer = conflicts.filter((c) => c.other.number > pr.number); - - for (const { other, shared } of newer) { - core.info( - `#${other.number} (newer) also declares ${shared.map((n) => `#${n}`).join(', ')} — ` + - `it will fail its own run of this guard; this PR keeps its claim.`, - ); - } - - if (older.length > 0) { - const lines = older.map(({ other, shared }) => - ` - ${shared.map((n) => `#${n}`).join(', ')} is already claimed by #${other.number} ` + - `(${other.html_url}, branch \`${other.head.ref}\`${other.draft ? ', draft' : ''})`, - ); - core.setFailed( - `Another open PR already declares a fix for the same issue(s):\n${lines.join('\n')}\n` + - `If this PR is the duplicate, close it and add anything it uniquely covers to the ` + - `earlier PR or a follow-up issue (that is what saved #4560 when #4559 was closed). ` + - `If the EARLIER one is abandoned, close it first — this check re-runs on 'edited' ` + - `and 'synchronize', and goes green once the conflict is gone.`, - ); - } diff --git a/.github/workflows/partof-closing-keyword-guard.yml b/.github/workflows/partof-closing-keyword-guard.yml deleted file mode 100644 index 8adf46dd9f2..00000000000 --- a/.github/workflows/partof-closing-keyword-guard.yml +++ /dev/null @@ -1,103 +0,0 @@ -# A PR that declares itself only `Part of #N` must not also tell GitHub to -# close `#N`. GitHub's closing-keyword parser matches the keyword plus the -# number and ignores the surrounding prose entirely — negations and modals -# included — so the sentence an author writes to PREVENT an auto-close is -# exactly what performs it on merge. That happened: a half-delivered card was -# closed `completed` two seconds after its PR merged, by its own warning -# sentence, and a closed card reads as finished, so it was found only by a -# post-merge inventory re-pull. The author wrote the warning correctly and -# still lost the card, which is why this is mechanical and not advice. -# -# The rule, the wording of the failure, and the code-stripping this depends on -# all live in `scripts/check-partof-closing-keyword.mjs` and the predicate it -# reuses; that header is authoritative, this file is the invocation. -# -# Sibling shape, deliberately copied rather than reinvented: the Duplicate Fix -# Guard is one of this repo's other PR-body-scoped blocking checks, and it takes -# the same trigger set for the same measured reason. -name: Part-of Closing-Keyword Guard - -# `edited` is load-bearing, not decoration. The body is this check's whole -# input, and GitHub does not re-deliver a `pull_request` event when a body -# changes under any other activity type — while `rerun_failed_jobs` replays the -# frozen payload, so a re-run of a fixed body stays red forever. With `edited` -# subscribed, rewording the sentence fires a fresh event carrying the fresh -# body and the check goes green with no push and no re-run. -# -# No `merge_group:` trigger, and that is not an oversight: a merge-queue event -# carries no pull request and therefore no body, so this check has nothing to -# judge there. That also keeps it out of the required-context registry, whose -# entries must report on queue builds; see the script header on where branch -# protection fits. -on: - pull_request: - types: [opened, edited, reopened, synchronize] - -# `contents: read` checks the repo out to get at the script, and naming a -# `permissions:` block at all sets every scope NOT listed to `none`, so that one -# line is the whole grant. It used to also grant `pull-requests: read`, for a -# step that gathered the PR's commit messages; that rule is a pre-push hook now -# (the script header says why), so the scope went with it. This gate reports, -# and never closes a PR, comments, or edits a body. -permissions: - contents: read - -concurrency: - group: partof-closing-keyword-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - partof-closing-keyword: - name: Part-of PR must not also close its card - runs-on: ubuntu-latest - # Run wall over the 100 most recent completed runs to 2026-10-27: p99 0.8 min, max - # 0.8. - timeout-minutes: 5 - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - # Pinned to the same major and spelling as every other setup-node in this - # repo, and for a measured reason rather than tidiness. The first draft of - # this file used `actions/setup-node@v5` with `node-version-file`, and the - # job died IN THIS STEP, before the script ever ran: - # - # ##[error]Unable to locate executable file: pnpm. - # - # v5 defaults `package-manager-cache: true` (the run log echoes it as an - # input), which reads `packageManager: pnpm@...` out of package.json and - # shells out to pnpm to locate the store to cache. This job installs no - # package manager on purpose — the script is dependency-free and imports - # one sibling module — so pnpm is not on PATH and the action hard-errors. - # The failure is worth naming because nothing in the step that failed - # mentions pnpm: it is an implicit default of the action, invisible in the - # workflow source, and the misleading first read is that the `run:` line - # below invoked a package manager. It does not, and never did. - # - # The check-links workflow's ADR-links job is the known-good shape this - # now matches exactly: checkout, setup-node, one `node scripts/check-*.mjs` - # call, no install and no corepack. It is green on this repo today. - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version-file: .nvmrc - - # The body reaches the script through `env:`, never through `${{ }}` - # inside the `run:` script. An expression interpolated into a shell line - # is substituted before bash ever sees it, so a PR body is arbitrary - # attacker-controlled text landing in a command; through `env:` it is - # inert data. The script's self-test pins this spelling. - # - # `PR_NUMBER` is not only for the message: GitHub renders a null body as - # an EMPTY value, so it is the witness that separates "this PR has an - # empty body" (a real, clean verdict) from "this step was handed nothing" - # (a wiring failure, which exits 2 and says so). - # - # No install step: the script imports one sibling module and reads no - # workspace package, so `node` on the pinned runtime is the whole - # toolchain it needs. - - name: A PR body may not close the card it is only part of - env: - PR_BODY: ${{ github.event.pull_request.body }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: node scripts/check-partof-closing-keyword.mjs diff --git a/.github/workflows/pr-body-guards.yml b/.github/workflows/pr-body-guards.yml new file mode 100644 index 00000000000..6e5110a9bfd --- /dev/null +++ b/.github/workflows/pr-body-guards.yml @@ -0,0 +1,498 @@ +# ══════════════════════════════════════════════════════════════════════════════ +# PR-body guards — four pull-request-scoped blocking checks in ONE workflow, ONE +# job, FOUR steps (#26071, ruling A step (iii)). Formerly four files, each one job +# with one gate step: +# +# closing-target-claim-guard.yml → step `The card this PR closes must claim this branch` +# duplicate-fix-guard.yml → step `No other open PR may claim the same issue` +# partof-closing-keyword-guard.yml → step `Part-of PR must not also close its card` +# single-claim-path-guard.yml → step `No other open PR may claim the same single-writer path` +# +# Each retired file's header is carried whole as the comment block above its step, +# and each gate step is the retired step verbatim but for its `name:` (the retired +# JOB's display name, which was its check-run name) and an `if:`. The rules, the +# wording of every failure and the exit contracts live in the scripts the steps +# run (the duplicate-fix guard is an inline github-script, as it was); this file is +# the invocation. +# +# What one job changes, and what it keeps: +# +# - ONE check run, `PR-body guards (…)`, where there were four. Each guard's +# verdict is its STEP, and every step runs even when an earlier one failed +# (`if: ${{ !cancelled() }}`), so one run still shows all four verdicts and a +# PR red on two guards is red on two steps, not on the first alone. None of +# the four was a required context (`REQUIRED_CONTEXTS` names only `ci.yml`, +# `lint.yml` and `governed-surface-guard.yml`), so no required context moves. +# - The retired files' run history stays under their retired names (GitHub +# keeps a deleted workflow's runs; `GET /actions/workflows/FILE.yml` answers +# `state: deleted`). New history lands under `PR-Body Guards`. +# - `merge_group`: only the closing-target claim guard took the queue leg (its +# script reads the pull request the queue ref NAMES). It keeps it. The other +# three steps are gated to the `pull_request` leg, because a merge-queue event +# carries no pull request and therefore no body or file list for them to judge +# — exactly the reason their retired files subscribed no `merge_group:` at all. +# - No checkout for the step that needs none: the duplicate-fix guard is an +# inline github-script and runs FIRST, before the one checkout + setup-node +# pair the three script-backed guards share. +# - `permissions:` is the union of the four retired grants, all read; one +# `concurrency:` group per pull request; `timeout-minutes` is the largest of +# the four (10), with the three measured siblings' walls recorded at their +# steps. +# +# Three self-tests read THIS FILE by name and pin its shape — the wiring batteries +# of `scripts/check-closing-target-claim.mjs`, `scripts/check-partof-closing-keyword.mjs` +# and `scripts/check-single-claim-paths.mjs` — and `scripts/check-closing-keyword-parity.mjs` +# extracts the duplicate-fix guard's keyword parser from the github-script step +# below. Renaming this file, a gate step, or the env spelling of a step reddens +# `pnpm check:closing-target-claim`, `check:partof-closing-keyword`, +# `check:single-claim-paths` or the parity gate, by design. +# ══════════════════════════════════════════════════════════════════════════════ +name: PR-Body Guards + +# The trigger set is the one all four retired files carried, and every activity +# type in it is load-bearing for at least one guard: +# +# `edited`: three guards' whole input is the PR BODY (the closing-target claim, +# the duplicate-fix and the Part-of keyword guards), and GitHub does not +# re-deliver a `pull_request` event when a body changes under any other activity +# type — while `rerun_failed_jobs` replays the FROZEN payload, so a re-run of a +# fixed body stays red forever. With `edited` subscribed, rewording the sentence +# or dropping the closing keyword fires a fresh event carrying the fresh body and +# the check goes green with no push and no re-run. The single-claim path guard +# keeps it for the same recovery: closing the earlier of two colliding PRs must +# let the surviving one go green without a push. +# +# `synchronize`: the single-claim path guard's input is the PR's CHANGED FILES, +# which move on every push, and the closing-target guard's is half the head +# BRANCH — a push that changes what the PR contains must be re-judged on the +# commit that changed it. +# +# `merge_group`: the closing-target claim guard's leg, and ITS ALONE. A +# merge-queue event carries no pull request — but the queue REF NAMES its pull +# request (`gh-readonly-queue//pr--`); that script reads the number +# with the governed queue guard's own shipped parser and then reads the same PR +# body and the same live comment threads, so the queue build judges the SAME +# pull request by the SAME rule. The other three steps have nothing to judge on +# a queue build and are gated to the `pull_request` leg (see their `if:`). +# +# ⚠️ In a MULTI-PR group the queue ref names only the LAST pull request; the +# script header states that limit. Every member of the group passed the +# `pull_request` leg to be armed at all. +on: + pull_request: + types: [opened, edited, reopened, synchronize] + merge_group: + +# ⛔ NO `paths:` filter, on either leg. A skipped job counts as SUCCESS in branch +# protection, so a path filter would hand a green check to the very PR the +# filter mis-scoped — and three of these gates' input is a PR BODY, which no path +# filter can see at all. (`merge_group` has no paths support either, so a filter +# would also make the two legs disagree about what they cover.) + +# Read-only, and that is the whole grant — the UNION of the four retired files' +# blocks, every scope `read`. These gates report; none closes a PR, comments, +# edits a body, or writes a label. +# +# `contents: read` is not optional padding: naming a `permissions:` block at all +# sets every scope NOT listed to `none`, and this job checks the repo out to get +# at three of the scripts; with only the pull-requests scope the job dies in the +# checkout step, before any gate runs. `issues: read` is what the closing-target +# guard's card comment threads need — the claim record is an ISSUE comment, and +# the pull-requests scope does not reach it. `pull-requests: read` is what the +# duplicate-fix and single-claim guards' reads of OTHER open PRs need, and what +# the merge_group leg needs to read the body of the pull request its queue ref +# names. +permissions: + contents: read + issues: read + pull-requests: read + +# A newer run for the same pull request supersedes this one, so the older run is +# cancelled. On the merge_group leg `github.ref` is the queue ref, unique per +# queue build, so no other PR's build is ever cancelled. +concurrency: + group: pr-body-guards-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + pr-body-guards: + # ⚠️ THE STEP NAMES BELOW ARE PINNED, not this job's. Each gate step is named + # as the retired job was — the literal `CHECK_CONTEXT_NAME` in + # `scripts/check-closing-target-claim.mjs`, and the sibling-gate string the + # single-claim self-test looks for — and those self-tests read THIS FILE and + # fail if a step name drifts. The check run itself is this job's display + # name, one for all four guards. + # + # ⛔ Making this a required context is NOT this PR's step. Branch protection + # takes a `REQUIRED_CONTEXTS` row PLUS the Settings → Rulesets entry, in one + # sitting, and the settings half is the maintainer's. Either half alone is + # an outage. What the merge_group leg above buys today is that the claim + # question is asked on the queue build; what it buys the day someone does + # make this required is that it cannot deadlock the queue, which a workflow + # with no queue leg always does. + name: PR-body guards (claim · duplicate · Part-of · single-writer path) + runs-on: ubuntu-latest + # The largest of the four retired budgets (closing-target 10; the other three + # 5, each with a measured p99 under a minute — recorded at their steps). The + # four gates now run in sequence after ONE checkout + setup-node, so the sum + # of the measured walls stays well inside this. + timeout-minutes: 10 + steps: + # ══ `duplicate-fix-guard` — formerly `.github/workflows/duplicate-fix-guard.yml` (`Duplicate Fix Guard`) ══ + # + # The retired file's header, carried whole; the step at the end of this block + # is its gate step, verbatim but for its `name:` (now the retired JOB's display + # name, so the verdict reads in the step list as it read on the Checks tab) and + # the `if:` that keeps every guard's verdict visible on one run. + # + # GitHub happily lets any number of open PRs declare `Fixes #N` for the same + # issue. On 2026-08-02 that cost a full duplicate implementation: #4555 and + # #4559 both declared `Fixes #4551`, both ran the whole gate suite green, and + # the duplication sat machine-detectable from the moment the second PR opened + # (03:08) until a human noticed it (08:52). All agents here share one GitHub + # identity, so the issue's assignee could not warn the second author either — + # "assigned to os-zhuang" reads the same whether it is you or another session. + # Full post-mortem: #4588. + # + # This gate makes the second PR red at open time. First come, first served — + # the EARLIER open PR (lower number) keeps its claim and stays green; the + # later one fails with a pointer to it. That matches the pm-dispatch claim + # convention ("first claim comment wins") already in .claude/skills/. + # + # Scope: same-repo references only (bare `#N` and qualified `#N`). + # Cross-repo references are the cross-repo-issue-closer's territory, and a + # duplicate across repos cannot be resolved by failing one side's CI anyway. + # + # Gated to the pull_request leg: a merge-queue event carries no pull request, + # so there is no body to read declared issues from. No checkout precedes this + # step — it is an inline github-script and needs none. + # + # Run wall of the retired job over its 100 most recent completed runs to + # 2026-10-27: p99 0.3 min, max 0.8. + - name: No other open PR may claim the same issue + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + uses: actions/github-script@v9 + with: + script: | + const pr = context.payload.pull_request; + const thisRepo = `${context.repo.owner}/${context.repo.repo}`; + + // GitHub's own closing-keyword set. The optional colon is part of + // GitHub's accepted syntax (`Fixes: #123`). Two same-repo forms: + // bare `#N` and qualified `owner/repo#N` naming THIS repo. + const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved'; + const pattern = new RegExp( + `\\b(?:${KEYWORDS}):?\\s+(?:([\\w.-]+)\\/([\\w.-]+))?#(\\d+)\\b`, + 'gi', + ); + + // Strip fenced blocks and inline code spans before matching. + // GitHub's own closing-keyword parser ignores code formatting, and + // this guard must not be stricter than the linker it protects: + // this very PR's first run counted its own DISCUSSION of + // `Fixes #4551` (in backticks, describing the incident) as a + // declaration. Benign there — #4551 is closed — but a prose + // mention of an issue some other open PR really fixes would have + // been a spurious red. + const declaredIssues = (body) => { + const prose = (body || '') + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`[^`\n]*`/g, ' '); + const found = new Set(); + for (const [, owner, repo, number] of prose.matchAll(pattern)) { + // A qualified reference to ANOTHER repo is not ours to judge. + if (owner && `${owner}/${repo}`.toLowerCase() !== thisRepo.toLowerCase()) continue; + found.add(Number(number)); + } + return found; + }; + + const mine = declaredIssues(pr.body); + if (mine.size === 0) { + core.info('This PR declares no same-repo closing keywords — nothing to guard.'); + return; + } + core.info(`This PR declares: ${[...mine].map((n) => `#${n}`).join(', ')}`); + + // Branch-name convention (advisory, never red): a fix branch named + // `claude/issue--` is discoverable by the next session + // with one `git ls-remote --heads origin | grep -E 'issue-(-|$)'`. + // #4555 vs #4559 happened partly because the branches shared no + // token to grep. Both spellings delimit the number on the RIGHT: + // undelimited, `issue-186` matches `claude/issue-18611-x`, so the + // advisory stays silent for a branch naming a DIFFERENT card — + // the false negative. The `$` arm keeps the slug-less spelling + // `claude/issue-186` matching; `issue-` anchors the left, so + // `issue-1186` and `issue-0186` do not match card 186 either. + // Warning only — existing branches must not go red retroactively. + const branch = pr.head.ref; + if (![...mine].some((n) => new RegExp(`issue-${n}(-|$)`).test(branch))) { + core.warning( + `Branch \`${branch}\` does not name any declared issue. ` + + `Convention: claude/issue-- (e.g. claude/issue-${[...mine][0]}-short-slug) ` + + `so parallel sessions can discover in-flight work with git ls-remote.`, + ); + } + + // Drafts count: a draft PR is work in flight, which is exactly + // what the second session needs to see. + const openPrs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + const conflicts = []; + for (const other of openPrs) { + if (other.number === pr.number) continue; + const theirs = declaredIssues(other.body); + const shared = [...mine].filter((n) => theirs.has(n)); + if (shared.length > 0) conflicts.push({ other, shared }); + } + + if (conflicts.length === 0) { + core.info('No other open PR declares these issues.'); + return; + } + + // First come, first served: only the LATER PR goes red. Failing + // both would leave the original author red through no action of + // their own; failing the earlier one would reward racing. + const older = conflicts.filter((c) => c.other.number < pr.number); + const newer = conflicts.filter((c) => c.other.number > pr.number); + + for (const { other, shared } of newer) { + core.info( + `#${other.number} (newer) also declares ${shared.map((n) => `#${n}`).join(', ')} — ` + + `it will fail its own run of this guard; this PR keeps its claim.`, + ); + } + + if (older.length > 0) { + const lines = older.map(({ other, shared }) => + ` - ${shared.map((n) => `#${n}`).join(', ')} is already claimed by #${other.number} ` + + `(${other.html_url}, branch \`${other.head.ref}\`${other.draft ? ', draft' : ''})`, + ); + core.setFailed( + `Another open PR already declares a fix for the same issue(s):\n${lines.join('\n')}\n` + + `If this PR is the duplicate, close it and add anything it uniquely covers to the ` + + `earlier PR or a follow-up issue (that is what saved #4560 when #4559 was closed). ` + + `If the EARLIER one is abandoned, close it first — this check re-runs on 'edited' ` + + `and 'synchronize', and goes green once the conflict is gone.`, + ); + } + + # ── The one checkout + setup-node the three script-backed guards share ── + # + # Taken from the Part-of guard's file, whose setup-node comment recorded the + # measured incident the other two retired files cited; the closing-target and + # single-claim files carried the same two steps byte-for-byte. + # + - name: Checkout repository + if: ${{ !cancelled() }} + uses: actions/checkout@v7 + + # Pinned to the same major and spelling as every other setup-node in this + # repo, and for a measured reason rather than tidiness. The first draft of + # the Part-of guard's retired file used `actions/setup-node@v5` with + # `node-version-file`, and the job died IN THIS STEP, before the script + # ever ran: + # + # ##[error]Unable to locate executable file: pnpm. + # + # v5 defaults `package-manager-cache: true` (the run log echoes it as an + # input), which reads `packageManager: pnpm@...` out of package.json and + # shells out to pnpm to locate the store to cache. This job installs no + # package manager on purpose — the three scripts are dependency-free and + # import at most two sibling modules — so pnpm is not on PATH and the action + # hard-errors. + # The failure is worth naming because nothing in the step that failed + # mentions pnpm: it is an implicit default of the action, invisible in the + # workflow source, and the misleading first read is that the `run:` line + # below invoked a package manager. It does not, and never did. + # + # The check-links workflow's ADR-links job is the known-good shape this + # matches: checkout, setup-node, `node scripts/check-*.mjs` calls, no install + # and no corepack. It is green on this repo today. + - name: Setup Node.js + if: ${{ !cancelled() }} + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + + # ══ `closing-target-claim` — formerly `.github/workflows/closing-target-claim-guard.yml` (`Closing-Target Claim Guard`) ══ + # + # The retired file's header, carried whole; the step at the end of this block + # is its gate step, verbatim but for its `name:` (now the retired JOB's display + # name, so the verdict reads in the step list as it read on the Checks tab) and + # the `if:` that keeps every guard's verdict visible on one run. + # + # A pull request may close a card only while that card's own thread carries a + # `Claim:` naming this pull request's head branch. + # + # Every agent here shares ONE GitHub identity, so the assignee field is a + # presence bit and the `Claim:` comment is the identity record. Two guards + # already run at pull-request time — the Duplicate Fix Guard ("no other open PR + # may claim the same issue") and the Single-Claim Path Guard — and neither asks + # the question that decides ownership. That question WAS already asked, by row + # H46 of the half-state sweep, and asked AFTER THE FACT as a report-only patrol + # input: the cloud-repo incident (one card implemented 29 minutes apart by two + # seats, the second with no claim) and an objectstack card implemented twice in + # one morning were both visible to that predicate and stopped by nothing. A + # control that detects a defect it cannot prevent is a TIMING defect in an + # existing control, not an absent one — so this workflow runs the same + # predicate at the moment the merge can still be stopped. + # + # The rule, the imported predicates, the declined-number policy, the + # branch-rename remedy and the exit contract all live in + # `scripts/check-closing-target-claim.mjs`; that header is authoritative and + # this file is the invocation. + # + # Sibling shape, deliberately copied rather than reinvented: the Part-of + # Closing-Keyword Guard and the Single-Claim Path Guard are this workflow's + # other script-backed steps, and this shares their runtime pin and their + # `pull_request` trigger set. + # + # Runs on BOTH legs — not gated to pull_request, by design; see the `on:` + # comment and the step comment below. + # + # Everything reaches the script through `env:`, never through `${{ }}` + # inside the `run:` line. A PR body is arbitrary author-controlled text, + # and an expression interpolated into a shell line is substituted before + # bash ever sees it; through `env:` it is inert data. The script's + # self-test pins this spelling. + # + # On the merge_group leg every `github.event.pull_request.*` expression + # renders EMPTY and `MERGE_GROUP_HEAD_REF` carries the queue ref, which is + # what selects the leg inside the script. On the pull_request leg the + # reverse holds. Neither leg needs its own step. + # + # `PR_HEAD_REF` is not decoration: the head branch IS the question this + # gate asks, so a blank one exits NOT MEASURED rather than green. + # + # The token is required, not optional: the claim record is a comment on + # another issue, which cannot be answered from the event payload. + - name: The card this PR closes must claim this branch + if: ${{ !cancelled() }} + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/check-closing-target-claim.mjs + + # ══ `partof-closing-keyword` — formerly `.github/workflows/partof-closing-keyword-guard.yml` (`Part-of Closing-Keyword Guard`) ══ + # + # The retired file's header, carried whole; the step at the end of this block + # is its gate step, verbatim but for its `name:` (now the retired JOB's display + # name, so the verdict reads in the step list as it read on the Checks tab) and + # the `if:` that keeps every guard's verdict visible on one run. + # + # A PR that declares itself only `Part of #N` must not also tell GitHub to + # close `#N`. GitHub's closing-keyword parser matches the keyword plus the + # number and ignores the surrounding prose entirely — negations and modals + # included — so the sentence an author writes to PREVENT an auto-close is + # exactly what performs it on merge. That happened: a half-delivered card was + # closed `completed` two seconds after its PR merged, by its own warning + # sentence, and a closed card reads as finished, so it was found only by a + # post-merge inventory re-pull. The author wrote the warning correctly and + # still lost the card, which is why this is mechanical and not advice. + # + # The rule, the wording of the failure, and the code-stripping this depends on + # all live in `scripts/check-partof-closing-keyword.mjs` and the predicate it + # reuses; that header is authoritative, this file is the invocation. + # + # Sibling shape, deliberately copied rather than reinvented: the Duplicate Fix + # Guard is this workflow's other PR-body-scoped blocking check, and both take + # the same trigger set for the same measured reason. + # + # Gated to the pull_request leg, and that is not an oversight: a merge-queue + # event carries no pull request and therefore no body, so this check has + # nothing to judge there. A skipped STEP is not a green check: the job's + # verdict on a queue build is the closing-target step's alone. + # + # Run wall of the retired job over its 100 most recent completed runs to + # 2026-10-27: p99 0.8 min, max 0.8. + # + # The body reaches the script through `env:`, never through `${{ }}` + # inside the `run:` script. An expression interpolated into a shell line + # is substituted before bash ever sees it, so a PR body is arbitrary + # attacker-controlled text landing in a command; through `env:` it is + # inert data. The script's self-test pins this spelling. + # + # `PR_NUMBER` is not only for the message: GitHub renders a null body as + # an EMPTY value, so it is the witness that separates "this PR has an + # empty body" (a real, clean verdict) from "this step was handed nothing" + # (a wiring failure, which exits 2 and says so). + # + # No install step: the script imports one sibling module and reads no + # workspace package, so `node` on the pinned runtime is the whole + # toolchain it needs. + - name: Part-of PR must not also close its card + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + env: + PR_BODY: ${{ github.event.pull_request.body }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: node scripts/check-partof-closing-keyword.mjs + + # ══ `single-claim-path` — formerly `.github/workflows/single-claim-path-guard.yml` (`Single-Claim Path Guard`) ══ + # + # The retired file's header, carried whole; the step at the end of this block + # is its gate step, verbatim but for its `name:` (now the retired JOB's display + # name, so the verdict reads in the step list as it read on the Checks tab) and + # the `if:` that keeps every guard's verdict visible on one run. + # + # Two PRs reached green independently while shipping ONE physical change: both + # rewrote the objectui pin from the same old sha to the same new sha and both + # added the same generated changeset — a byte-identical hunk under two card + # numbers. The first entered the merge queue; the second was green and one flip + # from enqueueing behind it, where it would have conflicted on both files. + # + # The repo's Duplicate Fix Guard is not broken and is not touched by this step. + # It asks whether two open PRs claim the same CARD, and it answered correctly: + # these claimed two genuinely different, independently filed cards, neither a + # duplicate of the other as written. The duplication existed only in the diff. + # This step adds the second question — do two open PRs write the same + # at-most-one-writer path — and the two gates run side by side, as steps + # of one job. + # + # The rule, the declared path list, the measured false-positive rates that + # forced a declared list instead of repo-wide diff intersection, and the wording + # of the failure all live in `scripts/check-single-claim-paths.mjs`; that header + # is authoritative and this file is the invocation. + # + # Sibling shape, deliberately copied rather than reinvented: the Part-of + # Closing-Keyword Guard is this workflow's other single-script PR-scoped + # blocking check, and this shares its runtime pin and its trigger set. + # + # Gated to the pull_request leg, and not by oversight: a merge-queue event + # carries no pull request, so this check has nothing to judge there. + # + # Run wall of the retired job over its 100 most recent completed runs to + # 2026-10-27: p99 0.8 min, max 0.9. + # + # Everything reaches the script through `env:`, never through `${{ }}` + # inside the `run:` line. Nothing here is attacker-controlled text today + # — a PR number is a number — but the spelling is the one the sibling + # guard's self-test pins, and keeping both gates identical on this point + # is what stops a later edit from introducing the unsafe form by analogy. + # + # `PR_NUMBER` is also the witness that separates a real verdict from an + # unwired run: with it absent the script exits 2 and says it judged + # nothing, rather than exiting 0 and reading as "no violations". + # + # The token is required, not optional: this gate's question is about OTHER + # open PRs, which cannot be answered from the event payload. The read is + # short-circuited on the cheap side — when this PR touches none of the + # declared paths, which is ~99.7% of runs by measurement, the job makes a + # single API call and stops. + - name: No other open PR may claim the same single-writer path + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/check-single-claim-paths.mjs diff --git a/.github/workflows/single-claim-path-guard.yml b/.github/workflows/single-claim-path-guard.yml deleted file mode 100644 index bb9010364a7..00000000000 --- a/.github/workflows/single-claim-path-guard.yml +++ /dev/null @@ -1,111 +0,0 @@ -# Two PRs reached green independently while shipping ONE physical change: both -# rewrote the objectui pin from the same old sha to the same new sha and both -# added the same generated changeset — a byte-identical hunk under two card -# numbers. The first entered the merge queue; the second was green and one flip -# from enqueueing behind it, where it would have conflicted on both files. -# -# The repo's Duplicate Fix Guard is not broken and is not touched by this file. -# It asks whether two open PRs claim the same CARD, and it answered correctly: -# these claimed two genuinely different, independently filed cards, neither a -# duplicate of the other as written. The duplication existed only in the diff. -# This workflow adds the second question — do two open PRs write the same -# at-most-one-writer path — and the two gates run side by side. -# -# The rule, the declared path list, the measured false-positive rates that -# forced a declared list instead of repo-wide diff intersection, and the wording -# of the failure all live in `scripts/check-single-claim-paths.mjs`; that header -# is authoritative and this file is the invocation. -# -# Sibling shape, deliberately copied rather than reinvented: the Part-of -# Closing-Keyword Guard is one of this repo's other single-script PR-scoped -# blocking checks, and this takes its job shape, its runtime pin and its trigger -# set. -name: Single-Claim Path Guard - -# `synchronize` is the load-bearing one here, and that is the difference from -# the two body-scoped guards this otherwise copies. Their whole input is the PR -# body, so `edited` is what they need; this one's input is the PR's CHANGED -# FILES, which move on every push. A PR that adds a claim on a listed path in -# its second commit must be judged on that commit, and one that drops the path -# again must be able to go green without being closed and reopened. -# -# `edited` is kept anyway and is not decoration: the failure text tells a reader -# to close one of the two PRs, and closing the earlier one must let the later -# one recover. That recovery arrives as an event on the SURVIVING PR only if it -# is touched — so `edited` is the cheap, no-push way back to green, exactly as -# in the sibling guards. -# -# No `merge_group:` trigger, and not by oversight: a merge-queue event carries -# no pull request, so this check has nothing to judge there. That also keeps it -# out of the required-context registry, whose entries must report on queue -# builds. Branch protection is a settings change no agent seat can make; this -# publishes a check run, and whether it becomes REQUIRED carries a maintainer -# ruling. The Duplicate Fix Guard sits in exactly the same position. -on: - pull_request: - types: [opened, edited, reopened, synchronize] - -# Read-only, and that is the whole grant. This gate reports; it never closes a -# PR, comments, or edits a body. The one incident where a duplicate PR was -# closed, it was closed by a human who spotted by eye what no gate could see — -# and a gate that closed PRs by itself would be a far more expensive mistake -# than the one it prevents. -# -# `contents: read` is not optional padding: naming a `permissions:` block at all -# sets every scope NOT listed to `none`, and this job checks the repo out to get -# at the script. With only the pull-requests scope here the job dies in the -# checkout step, before the gate ever runs — a gate that is red for a reason -# that has nothing to do with what it checks. -permissions: - contents: read - pull-requests: read - -concurrency: - group: single-claim-path-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - single-claim-path: - name: No other open PR may claim the same single-writer path - runs-on: ubuntu-latest - # Run wall over the 100 most recent completed runs to 2026-10-27: p99 0.8 min, max - # 0.9. - timeout-minutes: 5 - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - # Pinned to the same major and spelling as every other setup-node in this - # repo, for the measured reason recorded in the sibling guard's header: - # a setup-node major whose package-manager-cache default is on reads - # `packageManager` out of package.json and shells out to pnpm to find its - # store, killing the job in the SETUP step with "Unable to locate - # executable file: pnpm" — before the script runs, and naming a tool the - # workflow source never mentions. This job installs no package manager on - # purpose: the script is dependency-free and imports nothing. - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version-file: .nvmrc - - # Everything reaches the script through `env:`, never through `${{ }}` - # inside the `run:` line. Nothing here is attacker-controlled text today - # — a PR number is a number — but the spelling is the one the sibling - # guard's self-test pins, and keeping both gates identical on this point - # is what stops a later edit from introducing the unsafe form by analogy. - # - # `PR_NUMBER` is also the witness that separates a real verdict from an - # unwired run: with it absent the script exits 2 and says it judged - # nothing, rather than exiting 0 and reading as "no violations". - # - # The token is required, not optional: this gate's question is about OTHER - # open PRs, which cannot be answered from the event payload. The read is - # short-circuited on the cheap side — when this PR touches none of the - # declared paths, which is ~99.7% of runs by measurement, the job makes a - # single API call and stops. - - name: At most one open PR may modify a declared single-writer path - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node scripts/check-single-claim-paths.mjs diff --git a/scripts/check-closing-keyword-parity.mjs b/scripts/check-closing-keyword-parity.mjs index 9540403cdbd..af11ed28a95 100644 --- a/scripts/check-closing-keyword-parity.mjs +++ b/scripts/check-closing-keyword-parity.mjs @@ -17,12 +17,13 @@ * * - `.github/workflows/cross-repo-issue-closer.yml` decides whether an issue * in ANOTHER repository gets closed; - * - `.github/workflows/duplicate-fix-guard.yml` decides whether a PR goes RED - * for claiming an issue another open PR already claimed; + * - the duplicate-fix guard step of `.github/workflows/pr-body-guards.yml` + * (formerly `duplicate-fix-guard.yml`) decides whether a PR goes RED for + * claiming an issue another open PR already claimed; * - `closingKeywordRe()` in `scripts/pm/check-half-states.mjs` (H7, the - * blocking gate behind `.github/workflows/partof-closing-keyword-guard.yml`) - * decides whether a `Part of #N` PR is about to close the card it is only - * part of. + * blocking gate behind the Part-of closing-keyword guard step of that same + * workflow, formerly `partof-closing-keyword-guard.yml`) decides whether a + * `Part of #N` PR is about to close the card it is only part of. * * They are three spellings of one judgement, and they HAVE drifted. Measured on * 2026-08-20, `Fixes: objectstack-ai/objectui#456` matched the duplicate-fix @@ -38,9 +39,10 @@ * that diverged live inside `actions/github-script` blocks, and neither workflow * checks this repository out: the closer deliberately never does (it runs on * `pull_request_target`, and its header makes the no-checkout posture an - * invariant rather than an accident), and the duplicate-fix guard has no - * checkout step at all -- its whole job is one github-script step. `require()` - * of a repo file therefore resolves to nothing in either. Giving both a checkout + * invariant rather than an accident), and the duplicate-fix guard runs BEFORE + * its job's checkout -- it is one github-script step, deliberately first of the + * four (#26071). `require()` of a repo file therefore resolves to nothing in + * either. Giving both a checkout * to share ten characters of regex would buy the import by spending the closer's * stated security posture, which is the worse trade. * @@ -58,8 +60,8 @@ * parsers: not "do the three agree" but "which of them binds what in THIS * text". It is a pre-flight instrument for a PR body, runnable BEFORE the PR * exists -- otherwise the only enforcement that reads a body is the blocking - * gate behind partof-closing-keyword-guard.yml, which fires after the body is - * already written. + * gate behind the Part-of closing-keyword guard step of pr-body-guards.yml, + * which fires after the body is already written. * * It adds NO grammar. It calls `loadParsers()` and runs whatever that returns, * because a fourth spelling transcribed into a body checker is precisely the @@ -118,7 +120,7 @@ * That specimen settles two things at once: the colon binds (GitHub parsed * `fixed: #10240` as a closing declaration), and the surrounding prose does not * (the sentence said the issue was NOT fixed and it was closed anyway, which is - * the property partof-closing-keyword-guard.yml is built on). + * the property the Part-of closing-keyword guard is built on). * * ## Deliberately NOT asserted * @@ -180,8 +182,8 @@ const PARSERS = [ }, { id: 'duplicate-fix-guard', - file: '.github/workflows/duplicate-fix-guard.yml', - job: 'duplicate-fix-guard', + file: '.github/workflows/pr-body-guards.yml', + job: 'pr-body-guards', kind: 'workflow', scope: 'same-repo', }, @@ -555,7 +557,7 @@ const MUTATIONS = [ { id: 'X2', what: 'the duplicate-fix guard drops the optional colon', - file: '.github/workflows/duplicate-fix-guard.yml', + file: '.github/workflows/pr-body-guards.yml', from: '}):?\\\\s+(?:', to: '})\\\\s+(?:', expect: 'duplicate-fix-guard', diff --git a/scripts/check-closing-target-claim.mjs b/scripts/check-closing-target-claim.mjs index 5aedd0a6750..42291636426 100644 --- a/scripts/check-closing-target-claim.mjs +++ b/scripts/check-closing-target-claim.mjs @@ -403,14 +403,16 @@ function batteryFloorFailures() { const ROOT = new URL('..', import.meta.url).pathname; /** The wiring that gives this gate a pull request to judge. */ -const WIRING_WORKFLOW = '.github/workflows/closing-target-claim-guard.yml'; +const WIRING_WORKFLOW = '.github/workflows/pr-body-guards.yml'; /** - * ⚠️ THIS LITERAL IS THE CHECK-RUN NAME branch protection would pin, and it is - * duplicated in the workflow's `name:` — deliberately, and pinned in both - * directions by the self-test below. Renaming a job silently detaches a - * required context, and this is the cheap half of the two-step that makes that - * impossible to do by accident. + * ⚠️ THIS LITERAL IS THE GATE STEP'S `name:` in the wiring workflow — the name + * its job carried as a check run until the four PR-body guards became the four + * steps of one job (#26071) — duplicated there deliberately, and pinned in both + * directions by the self-test below. The check-run name branch protection + * would pin is now that one job's, shared by all four guards; renaming either + * silently detaches a required context, and this is the cheap half of the + * two-step that makes that impossible to do by accident. */ export const CHECK_CONTEXT_NAME = 'The card this PR closes must claim this branch'; @@ -1245,14 +1247,17 @@ async function selfTest() { const wiring = existsSync(wiringPath) ? readFileSync(wiringPath, 'utf8') : ''; t('the wiring workflow exists', wiring !== '', true); t('the wiring workflow runs this script', wiring.includes('node scripts/check-closing-target-claim.mjs'), true); - t('the wiring declares the check-run name this file pins', wiring.includes(CHECK_CONTEXT_NAME), true); + // Comment-stripped, deliberately: the one-job workflow's header QUOTES every + // step name in prose, so a raw-text read would stay green with the step + // renamed — a phantom pin. Only an executable `name:` line satisfies this. + const wiringCommands = wiring.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n'); + t('the wiring names the gate step as this file pins it (the retired check-run name), on an executable line', wiringCommands.includes(CHECK_CONTEXT_NAME), true); t('the wiring subscribes to body edits, so dropping the keyword can go green', /types:\s*\[[^\]]*\bedited\b[^\]]*\]/.test(wiring), true); t('the wiring subscribes to `synchronize`, so a re-pushed branch is re-judged', /types:\s*\[[^\]]*\bsynchronize\b[^\]]*\]/.test(wiring), true); t('the wiring takes the merge_group leg — the queue claim is wiring, not prose', /^\s{2}merge_group:\s*$/m.test(wiring), true); t('the wiring passes the head ref, without which nothing can be judged', /PR_HEAD_REF:/.test(wiring), true); t('the wiring passes the merge-queue head ref', /MERGE_GROUP_HEAD_REF:/.test(wiring), true); t('everything reaches the script through `env:`, never through shell interpolation', /env:[\s\S]{0,400}?PR_BODY:/.test(wiring), true); - const wiringCommands = wiring.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n'); t('the guard job invokes no package manager (it needs node and nothing else)', /\b(pnpm|corepack|yarn|npm)\b/.test(wiringCommands), false); // --- A claim its own session later YIELDED is not the card's claim. The diff --git a/scripts/check-partof-closing-keyword.mjs b/scripts/check-partof-closing-keyword.mjs index bd5f8ab12fa..c75978cd43b 100644 --- a/scripts/check-partof-closing-keyword.mjs +++ b/scripts/check-partof-closing-keyword.mjs @@ -361,7 +361,7 @@ const ROOT = new URL('..', import.meta.url).pathname; const PREDICATE_SOURCE = 'scripts/pm/check-half-states.mjs'; /** The wiring that gives this gate a PR to judge. */ -const WIRING_WORKFLOW = '.github/workflows/partof-closing-keyword-guard.yml'; +const WIRING_WORKFLOW = '.github/workflows/pr-body-guards.yml'; export const EXIT_CLEAN = 0; export const EXIT_CONTRADICTION = 1; diff --git a/scripts/check-single-claim-paths.mjs b/scripts/check-single-claim-paths.mjs index 8850395d785..a9f246bef48 100644 --- a/scripts/check-single-claim-paths.mjs +++ b/scripts/check-single-claim-paths.mjs @@ -299,7 +299,7 @@ const ROOT = new URL('..', import.meta.url).pathname; const SELF_PATH = fileURLToPath(import.meta.url); /** The wiring that gives this gate a PR to judge. */ -const WIRING_WORKFLOW = '.github/workflows/single-claim-path-guard.yml'; +const WIRING_WORKFLOW = '.github/workflows/pr-body-guards.yml'; export const EXIT_CLEAN = 0; export const EXIT_CONFLICT = 1; @@ -924,11 +924,17 @@ function selfTest() { t('the guard job invokes no package manager (it needs node and nothing else)', /\b(pnpm|corepack|yarn|npm)\b/.test(wiringLines), false); // --- The older gate must still be there, unweakened. This gate ADDS a - // second question; it does not replace the card-keyed one. - const siblingPath = join(ROOT, '.github/workflows/duplicate-fix-guard.yml'); + // second question; it does not replace the card-keyed one. Since #26071 the + // card-keyed gate is a step of the SAME workflow, so this reads the same + // file as the wiring battery above and looks for that step's name. + const siblingPath = join(ROOT, '.github/workflows/pr-body-guards.yml'); const sibling = existsSync(siblingPath) ? readFileSync(siblingPath, 'utf8') : ''; + // Comment-stripped for the same reason as the permission reads above: that + // file's header quotes the step name in prose, and prose must not satisfy + // a pin on an executable `name:` line. + const siblingLines = sibling.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n'); t('the card-keyed duplicate gate still exists', sibling !== '', true); - t('...and still asks its own question', sibling.includes('No other open PR may claim the same issue'), true); + t('...and still asks its own question, as a step of that file', siblingLines.includes('No other open PR may claim the same issue'), true); // --- A transport failure is a PREREQUISITE, never the accusation this // gate reserves for a real earlier claim. The live read is the only part diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 53dac95db02..a581f392a93 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -1612,7 +1612,8 @@ export function selfTest({ tier = 'full' } = {}) { // ── The step's `env:` is the OTHER carrier of a workflow value (#15761) ──── // - // `partof-closing-keyword-guard.yml` passes the whole input through `env:` + // `pr-body-guards.yml` (the Part-of closing-keyword guard's step; formerly + // `partof-closing-keyword-guard.yml`) passes the whole input through `env:` // and leaves the argv bare, so the argv-shaped classifier scored // `node scripts/check-partof-closing-keyword.mjs` as a command a dev can // paste — one whose only possible outcome here is the gate's own exit 2 @@ -1648,7 +1649,7 @@ export function selfTest({ tier = 'full' } = {}) { ' NESTED: ${{ github.sha }}', ' run: node scripts/check-nested-env.mjs', ].join('\n'); - const envInvs = extractCheckInvocations(envWf, 'partof-closing-keyword-guard.yml'); + const envInvs = extractCheckInvocations(envWf, 'pr-body-guards.yml'); const envOf = (name) => (envInvs.find((i) => i.script === name)?.envVariables ?? null); // (a) The specimen: the step's `env:` names ride the invocation, in order. t( @@ -13761,7 +13762,7 @@ export function selfTest({ tier = 'full' } = {}) { // a runnable command, and running it here exits 2 with the gate's own // "NOT WIRED" refusal. { - const envLive = runCli(['--commands', '.github/workflows/partof-closing-keyword-guard.yml']); + const envLive = runCli(['--commands', '.github/workflows/pr-body-guards.yml']); const envLiveOut = (envLive.stdout ?? '').split('\n'); const envLiveErr = envLive.stderr ?? ''; t(