Skip to content

Commit 0374bcb

Browse files
baozhoutaoclaude
andauthored
ci(partof-guard): the closing-keyword gate reads every commit message on the PR (#16371)
The gate judged one of the two surfaces GitHub's reference parser acts on. It read the PR body; it never read the commit messages, and this repo squash-merges, so the message that lands on the default branch is assembled at merge time by concatenating them. That text is written by nobody and reviewed by nobody: it carries every trailer its inputs carried, and it can contradict itself where none of its parts did. The specimen is the squash of PR 16247, commit fc3fb7c, an ancestor of the default branch: three bullets, a closing trailer for a card inside the first bullet's body, and a third bullet retracting a claim the first still makes. No body-side rule could have seen it -- the contradictory text existed in no body -- and the sweep's H23, which patrols this surface after merge, binds narrower and is silent on it: H23 reports only the Part-of plus closing-keyword contradiction, and this specimen declares no Part-of at all. So the gate now enforces a second, strictly wider rule: no commit on the PR may carry a card-relation trailer at all. Width is what makes it enforceable -- "trailers that would contradict each other once concatenated" is a property of an assembly that does not exist until the merge button, while "no trailer in any commit" is a property of one commit. An assembly cannot manufacture what none of its inputs contain. The relation extractors are the sweep's, imported at `markdown: false` and not re-spelled: a fourth spelling of the closing-keyword grammar would be a fourth thing for the parity gate to hold in step, and the commit-message reading is a measured contract of that sweep rather than this gate's call. The workflow gathers the list and hands it over as a file path, so the judging path stays HTTP-free by construction. The endpoint is read rather than `git log base..head` walked: it returns exactly the set GitHub will squash, while the walk needs a merge base this depth-1 checkout does not have and would report another author's landed trailers as this PR's. An absent, malformed or empty commit list exits 2 and says which rule judged nothing. A wiring that forgot the commits has not seen a clean commit history, and the two must never print the same line. Self-test batteries 9 -> 15, cases 28 -> 66, with the real squash message as the regression fixture. Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 65ec530 commit 0374bcb

2 files changed

Lines changed: 549 additions & 28 deletions

File tree

.github/workflows/partof-closing-keyword-guard.yml

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,14 @@ on:
3333
pull_request:
3434
types: [opened, edited, reopened, synchronize]
3535

36+
# `contents: read` checks the repo out to get at the script. `pull-requests:
37+
# read` is what the commit-list gather below needs, and naming a `permissions:`
38+
# block at all sets every scope NOT listed to `none`, so both must be spelled.
39+
# Read-only is the whole grant: this gate reports, and never closes a PR,
40+
# comments, or edits a body.
3641
permissions:
3742
contents: read
43+
pull-requests: read
3844

3945
concurrency:
4046
group: partof-closing-keyword-${{ github.event.pull_request.number }}
@@ -87,8 +93,47 @@ jobs:
8793
# No install step: the script imports one sibling module and reads no
8894
# workspace package, so `node` on the pinned runtime is the whole
8995
# toolchain it needs.
90-
- name: A Part-of PR body may not carry a closing keyword for the same card
96+
# RULE 2's input. The script judges it but never fetches it: the judging
97+
# path stays HTTP-free, and the gather is a step of its own so that a
98+
# network failure reads as a failed gather rather than as a verdict about
99+
# somebody's PR.
100+
#
101+
# The endpoint is chosen over `git log base..head` deliberately. It
102+
# returns exactly the set GitHub will squash. The git walk needs the merge
103+
# base present to exclude what is already on the default branch, and the
104+
# checkout above is depth 1 — so on a branch that has merged `main` back
105+
# in, the walk cannot exclude those commits and would report another
106+
# author's landed trailers as this PR's. Deepening until the merge base
107+
# appears is unbounded, and `fetch-depth: 0` clones the whole repository
108+
# to read a handful of messages.
109+
#
110+
# `--paginate` is load-bearing: without it a PR over one page silently
111+
# loses its later commits, and a rule that read half the commits would
112+
# report the unread half as clean. `--jq` emits one JSON object per line,
113+
# and JSON escapes the newlines inside a commit message, so one row really
114+
# is one line. The messages go to a FILE rather than into the environment:
115+
# they are multi-line attacker-controlled text, and a path is inert where
116+
# a body of prose is not.
117+
#
118+
# No pipeline here, on purpose. A `run:` block executes as `bash -e`
119+
# WITHOUT pipefail, so `gh ... | jq ...` would take jq's exit code and a
120+
# failed gather would reach the script as an empty file. It is a single
121+
# redirect, so a failing `gh` fails the step; and if it ever did produce an
122+
# empty file, the script reads zero rows as a failed gather, not as a PR
123+
# with no commits.
124+
- name: Gather the PR's commit messages
125+
env:
126+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
127+
PR_NUMBER: ${{ github.event.pull_request.number }}
128+
REPO: ${{ github.repository }}
129+
run: >
130+
gh api --paginate "/repos/$REPO/pulls/$PR_NUMBER/commits"
131+
--jq '.[] | {sha: .sha, message: .commit.message}'
132+
> "$RUNNER_TEMP/pr-commits.jsonl"
133+
134+
- name: A PR body may not close the card it is only part of, and no commit may carry a card trailer
91135
env:
92136
PR_BODY: ${{ github.event.pull_request.body }}
93137
PR_NUMBER: ${{ github.event.pull_request.number }}
138+
PR_COMMITS_FILE: ${{ runner.temp }}/pr-commits.jsonl
94139
run: node scripts/check-partof-closing-keyword.mjs

0 commit comments

Comments
 (0)