Skip to content

Commit ba0a846

Browse files
claude[bot]claude
andauthored
ci: at most one open PR may modify a declared single-writer path (#9598)
* ci: at most one open PR may modify a declared single-writer path Two PRs reached green independently 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. The card-keyed Duplicate Fix Guard answered correctly and is untouched: the two PRs claimed two genuinely different, independently filed cards. The duplication existed only in the diff. This adds the second question and the two gates run side by side. The key is a declared list, not repo-wide diff intersection, and that is measured rather than assumed. Over the 300 most recent PRs, pairs whose open windows overlapped and which shared a changed path: 68 for the repo-wide key (top collisions: the lock file 33, one plugin manifest 21, the root manifest 15), 0 for the declared list. A repo-wide key would be ~68 false accusations per 300 PRs, each naming two authors who did nothing wrong. Cost is bounded by asking the cheap question first: the gate lists this PR's own files, intersects with the declared list, and returns after one paginated call when the intersection is empty. 1 of those 300 PRs touched a listed path, so the branch that walks other open PRs runs on ~0.3% of runs. First come, first served, unchanged from the sibling gate: the earlier PR keeps its claim and the later one goes red naming it by number, URL and branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja * test: the wiring permission assertions must read executable lines only Found by this gate's own ablation sweep. Revoking `contents: read` from the guard workflow -- which would kill the job in its checkout step, before the gate judges anything -- left the self-test GREEN, because the comment explaining why that scope is needed contains those same two words and the assertion scanned the whole file. A phantom check: green because of the prose describing the thing it was meant to verify. Both permission assertions now scan the comment-stripped workflow, as the package-manager assertion beside them already did, and the ablation reddens with the string still present in the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f902d0f commit ba0a846

4 files changed

Lines changed: 692 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,29 @@ jobs:
224224
- name: Part-of closing-keyword guard self-test
225225
run: pnpm check:partof-closing-keyword
226226

227+
# Single-claim path guard self-test (#9402). Same split as the step above
228+
# and for the same reason: the guard is a PR-scoped blocking check in its
229+
# own workflow, because its question is about OTHER open PRs and needs a
230+
# pull request plus an API read that this job has neither of. What runs
231+
# HERE is the half that needs no PR — the verdict layer, the exit-code
232+
# contract, the short-circuit that makes the gate affordable, and the
233+
# declared path list's own invariants.
234+
#
235+
# That last one is the reason this step is worth its second: the declared
236+
# list IS the key, so the realistic way this gate turns into noise is a
237+
# careless append to it. The self-test rejects an entry with no stated
238+
# reason, rejects a duplicate, and pins that the three measured
239+
# high-collision paths (the lock file, the root manifest, one plugin
240+
# manifest — 33, 15 and 21 concurrent pairs in 300 PRs) stay OUT of it.
241+
#
242+
# It also pins the WIRING (the guard workflow still invokes the script,
243+
# still subscribes to `synchronize` — without which a claim added in a
244+
# second commit is never judged — and still passes the token), so
245+
# unwiring the gate reddens here rather than going quiet.
246+
# Dependency-free, reads two files; ~0.1s.
247+
- name: Single-claim path guard self-test
248+
run: pnpm check:single-claim-paths
249+
227250
# PM half-state sweeper self-test (#8528). `scripts/pm/check-half-states.mjs`
228251
# carried a 79-case --self-test — the H1..H7 predicates, the seat-sticker
229252
# parser, the transport classifier and its measured container classes —
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Two PRs reached green independently while shipping ONE physical change: both
2+
# rewrote the objectui pin from the same old sha to the same new sha and both
3+
# added the same generated changeset — a byte-identical hunk under two card
4+
# numbers. The first entered the merge queue; the second was green and one flip
5+
# from enqueueing behind it, where it would have conflicted on both files.
6+
#
7+
# The repo's Duplicate Fix Guard is not broken and is not touched by this file.
8+
# It asks whether two open PRs claim the same CARD, and it answered correctly:
9+
# these claimed two genuinely different, independently filed cards, neither a
10+
# duplicate of the other as written. The duplication existed only in the diff.
11+
# This workflow adds the second question — do two open PRs write the same
12+
# at-most-one-writer path — and the two gates run side by side.
13+
#
14+
# The rule, the declared path list, the measured false-positive rates that
15+
# forced a declared list instead of repo-wide diff intersection, and the wording
16+
# of the failure all live in `scripts/check-single-claim-paths.mjs`; that header
17+
# is authoritative and this file is the invocation.
18+
#
19+
# Sibling shape, deliberately copied rather than reinvented: the Part-of
20+
# Closing-Keyword Guard is this repo's other single-script PR-scoped blocking
21+
# check, and this takes its job shape, its runtime pin and its trigger set.
22+
name: Single-Claim Path Guard
23+
24+
# `synchronize` is the load-bearing one here, and that is the difference from
25+
# the two body-scoped guards this otherwise copies. Their whole input is the PR
26+
# body, so `edited` is what they need; this one's input is the PR's CHANGED
27+
# FILES, which move on every push. A PR that adds a claim on a listed path in
28+
# its second commit must be judged on that commit, and one that drops the path
29+
# again must be able to go green without being closed and reopened.
30+
#
31+
# `edited` is kept anyway and is not decoration: the failure text tells a reader
32+
# to close one of the two PRs, and closing the earlier one must let the later
33+
# one recover. That recovery arrives as an event on the SURVIVING PR only if it
34+
# is touched — so `edited` is the cheap, no-push way back to green, exactly as
35+
# in the sibling guards.
36+
#
37+
# No `merge_group:` trigger, and not by oversight: a merge-queue event carries
38+
# no pull request, so this check has nothing to judge there. That also keeps it
39+
# out of the required-context registry, whose entries must report on queue
40+
# builds. Branch protection is a settings change no agent seat can make; this
41+
# publishes a check run, and whether it becomes REQUIRED carries a maintainer
42+
# ruling. The Duplicate Fix Guard sits in exactly the same position.
43+
on:
44+
pull_request:
45+
types: [opened, edited, reopened, synchronize]
46+
47+
# Read-only, and that is the whole grant. This gate reports; it never closes a
48+
# PR, comments, or edits a body. The one incident where a duplicate PR was
49+
# closed, it was closed by a human who spotted by eye what no gate could see —
50+
# and a gate that closed PRs by itself would be a far more expensive mistake
51+
# than the one it prevents.
52+
#
53+
# `contents: read` is not optional padding: naming a `permissions:` block at all
54+
# sets every scope NOT listed to `none`, and this job checks the repo out to get
55+
# at the script. With only the pull-requests scope here the job dies in the
56+
# checkout step, before the gate ever runs — a gate that is red for a reason
57+
# that has nothing to do with what it checks.
58+
permissions:
59+
contents: read
60+
pull-requests: read
61+
62+
concurrency:
63+
group: single-claim-path-${{ github.event.pull_request.number }}
64+
cancel-in-progress: true
65+
66+
jobs:
67+
single-claim-path:
68+
name: No other open PR may claim the same single-writer path
69+
runs-on: ubuntu-latest
70+
steps:
71+
- name: Checkout repository
72+
uses: actions/checkout@v7
73+
74+
# Pinned to the same major and spelling as every other setup-node in this
75+
# repo, for the measured reason recorded in the sibling guard's header:
76+
# a setup-node major whose package-manager-cache default is on reads
77+
# `packageManager` out of package.json and shells out to pnpm to find its
78+
# store, killing the job in the SETUP step with "Unable to locate
79+
# executable file: pnpm" — before the script runs, and naming a tool the
80+
# workflow source never mentions. This job installs no package manager on
81+
# purpose: the script is dependency-free and imports nothing.
82+
- name: Setup Node.js
83+
uses: actions/setup-node@v7
84+
with:
85+
node-version: '22'
86+
87+
# Everything reaches the script through `env:`, never through `${{ }}`
88+
# inside the `run:` line. Nothing here is attacker-controlled text today
89+
# — a PR number is a number — but the spelling is the one the sibling
90+
# guard's self-test pins, and keeping both gates identical on this point
91+
# is what stops a later edit from introducing the unsafe form by analogy.
92+
#
93+
# `PR_NUMBER` is also the witness that separates a real verdict from an
94+
# unwired run: with it absent the script exits 2 and says it judged
95+
# nothing, rather than exiting 0 and reading as "no violations".
96+
#
97+
# The token is required, not optional: this gate's question is about OTHER
98+
# open PRs, which cannot be answered from the event payload. The read is
99+
# short-circuited on the cheap side — when this PR touches none of the
100+
# declared paths, which is ~99.7% of runs by measurement, the job makes a
101+
# single API call and stops.
102+
- name: At most one open PR may modify a declared single-writer path
103+
env:
104+
PR_NUMBER: ${{ github.event.pull_request.number }}
105+
GITHUB_REPOSITORY: ${{ github.repository }}
106+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
107+
run: node scripts/check-single-claim-paths.mjs

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test",
5555
"check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test",
5656
"check:partof-closing-keyword": "node scripts/check-partof-closing-keyword.mjs --self-test",
57+
"check:single-claim-paths": "node scripts/check-single-claim-paths.mjs --self-test",
5758
"check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs",
5859
"check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs",
5960
"check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs",

0 commit comments

Comments
 (0)