Repository navigation
498 lines (478 loc) · 28.7 KB
/
Copy pathpr-body-guards.yml
File metadata and controls
498 lines (478 loc) · 28.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
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/<base>/pr-<N>-<sha>`); 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 `<this repo>#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-<n>-<slug>` is discoverable by the next session
// with one `git ls-remote --heads origin | grep -E 'issue-<n>(-|$)'`.
// #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-<n>-<slug> (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