@biffo/cli 0.256.11 → 0.258.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ name: Release Guards
2
+
3
+ # The closing-keyword guards, for a satellite repo (biffo-template#1395).
4
+ #
5
+ # ## Why a satellite needs this at all
6
+ #
7
+ # `Closes #N` closes the issue the instant a PR merges — before it is deployed
8
+ # and before anyone has seen it work. And GitHub's linker has no concept of
9
+ # negation, so a body saying *"do not close #N"* closes #N.
10
+ #
11
+ # Both halves have now bitten this estate. The negated form closed
12
+ # tabsii-platform#76, the guard was written, and it closed tabsii-crm#320 a
13
+ # second time — in a satellite, where the guard did not exist. It lived only in
14
+ # `biffo-template` and in instances, because `biffo core upgrade` reaches
15
+ # instances only. Nothing was stale or broken; four repos simply had no route to
16
+ # it.
17
+ #
18
+ # ## What this is NOT
19
+ #
20
+ # The template's own `release-guards.yml` also runs the core-ownership guard,
21
+ # the practices-corpus monotonicity check and a release-subject check. None of
22
+ # those apply here — a satellite has no `core-manifest.json` and no practices
23
+ # corpus — so this is the closing-keyword step alone rather than a copy carrying
24
+ # three steps that would fail or no-op.
25
+ #
26
+ # ## `edited` is load-bearing
27
+ #
28
+ # Both of this guard's documented remedies are body edits: swap `Closes` for
29
+ # `Refs`, or add a `Verified-on-deploy:` line. Without `edited` in the trigger,
30
+ # neither can turn the check green on its own and the PR sits on a red required
31
+ # check with no path forward (biffo-template#1319).
32
+
33
+ on:
34
+ pull_request:
35
+ branches: [main, dev, staging]
36
+ types: [opened, synchronize, reopened, edited]
37
+
38
+ concurrency:
39
+ group: ${{ github.workflow }}-${{ github.ref }}
40
+ cancel-in-progress: true
41
+
42
+ env:
43
+ NODE_VERSION: '22'
44
+
45
+ jobs:
46
+ release-guards:
47
+ name: Release Guards
48
+ runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
49
+ # A self-hosted spot runner reclaimed mid-job hangs rather than fails, and
50
+ # without a timeout it burns the 360-minute default before anyone is told.
51
+ timeout-minutes: 20
52
+ # `pull-requests: read` is what lets the script read the PR's CURRENT body
53
+ # via `gh pr view` rather than the frozen `pull_request` event payload. That
54
+ # frozen value is why both remedies above used to be unable to turn the
55
+ # check green (biffo-template#1174). Declared explicitly rather than relying
56
+ # on default token permissions, which are not guaranteed to include it.
57
+ permissions:
58
+ contents: read
59
+ pull-requests: read
60
+ steps:
61
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
62
+ with:
63
+ fetch-depth: 0
64
+ - uses: actions/setup-node@v4
65
+ with:
66
+ node-version: ${{ env.NODE_VERSION }}
67
+ # Bare node: the script has no dependencies, so this needs no install and
68
+ # cannot be broken by one failing.
69
+ - name: Closing keywords
70
+ run: node scripts/check-closing-keywords.mjs
71
+ env:
72
+ GITHUB_BASE_REF: ${{ github.base_ref }}
73
+ GH_TOKEN: ${{ github.token }}
74
+ PR_NUMBER: ${{ github.event.pull_request.number }}
75
+ GH_REPO: ${{ github.repository }}
@@ -0,0 +1,615 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Two guards over a PR's closing keywords, asking different questions — and,
4
+ * since #1334, applied to every document GitHub actually honours, not just
5
+ * the PR body.
6
+ *
7
+ * 1. Refuse `Closes #N` on a change whose behaviour only shows up once
8
+ * deployed — a path-scoped check, documented immediately below.
9
+ * 2. Refuse a NEGATED closing keyword anywhere, on any path — see
10
+ * `negatedClosingReferences`. GitHub's linker has no concept of negation,
11
+ * so `Does not close #N` closes #N.
12
+ *
13
+ * ── Three documents, not one (#1334, #1362) ──────────────────────────────
14
+ *
15
+ * GitHub does not read only the PR body. A closing keyword in the PR body
16
+ * shows up as a live "closes #N" link while the PR is open; a closing
17
+ * keyword in a **commit message** is honoured too — and for a squash merge,
18
+ * this repo's default constructs the squash commit's message from the
19
+ * individual commits, not from the PR body. #1332 was opened with
20
+ * `Closes #1331` and Release Guards correctly refused it (a workflow-only
21
+ * change, deploy-only path). The PR body was corrected to `Refs #1331`, the
22
+ * guard re-ran reading `PR_BODY`, and it passed — because the guard had only
23
+ * ever read the body. The first commit's message still said `Closes #1331`,
24
+ * that text reached the squash-merge commit unchanged, and #1331 closed the
25
+ * instant the PR merged. The guard was right about what it read; GitHub read
26
+ * something else.
27
+ *
28
+ * So every check in this file runs against **all** of: the PR body, the PR
29
+ * title, and every commit's message (`messageHeadline` and `messageBody`
30
+ * both — a keyword can sit in either). One finding in any one of them is
31
+ * enough to trip the guard; see `documentsFor` and `assess`.
32
+ *
33
+ * ── 1. Closing keywords on deploy-only paths ─────────────────────────────
34
+ *
35
+ * GitHub closes an issue the moment a PR body carrying a closing keyword is
36
+ * merged. For most changes that is right and convenient. For a change whose
37
+ * correctness cannot be observed until it is running somewhere, it closes the
38
+ * issue at the exact moment the least is known about it — the suite is green,
39
+ * nothing has been deployed, and nobody has looked.
40
+ *
41
+ * This is not a theoretical tidiness rule. It has cost this estate repeatedly:
42
+ *
43
+ * - #275: portal navigation landing on the raw RSC payload was diagnosed,
44
+ * "fixed", shipped with a drift guard and closed. On a wrong cause. It
45
+ * survived a teardown and redeploy before a human clicked the link.
46
+ * - tabsii-platform#429/#436: two independent ORM/DDL column mismatches,
47
+ * each green in a lane that builds its schema from the same models it is
48
+ * checking, each found by a 500 on a live click-through.
49
+ * - tabsii-platform#511 (2026-08-02): `Closes #511` auto-closed it on merge,
50
+ * ten minutes before the deploy that proved anything. The evidence had to
51
+ * be added afterwards, as a comment on an already-closed issue.
52
+ *
53
+ * The rule this encodes is AGENTS.md's, verbatim: *do not close an issue you
54
+ * have not seen fixed*. Use `Refs #N`, verify against reality, then close by
55
+ * hand with what you saw.
56
+ *
57
+ * ## What counts as "only shows up once deployed"
58
+ *
59
+ * A path list, deliberately short. Every entry is somewhere this estate has
60
+ * actually been bitten, not everywhere a bug could hide:
61
+ *
62
+ * - `infra/`, `modules/cloud/` — Terraform. `terraform validate` says the
63
+ * HCL parses, never that the deployed resource behaves.
64
+ * - `.github/workflows/` — a workflow is only really run by running it.
65
+ * - `db/imports/` — applied by the importer at deploy time, against a real
66
+ * database, in an order no unit test reproduces.
67
+ * - `apps/portal/` — auth flows, client-side routing and CDN behaviour, the
68
+ * exact trio behind #275, #1104 and #1106.
69
+ * - `apps/frontend/` — the same trio, under the name a SIBLING gives it.
70
+ * One list serves both flavours rather than a per-flavour copy: a sibling
71
+ * has no `apps/portal/` and this repo has no `apps/frontend/`, so each
72
+ * entry is simply inert where it does not apply. Two copies of this list
73
+ * would be two places for it to drift, which is the defect class this
74
+ * estate has paid for most often.
75
+ *
76
+ * Application code, the CLI, and `services/api/src/` are all absent on
77
+ * purpose. A pure function with a failing-first test is genuinely proven by
78
+ * that test, and a guard that fires on every PR teaches people to bypass it.
79
+ *
80
+ * ## The escape hatch, and why it is a trailer
81
+ *
82
+ * A `Verified-on-deploy:` trailer in the PR body allows the closing keyword.
83
+ * It exists for the honest case — a fix already confirmed on a running
84
+ * environment, being landed after the fact — and it asks for the evidence in
85
+ * the same breath, so the claim lands in the PR body where a reviewer sees it
86
+ * rather than in someone's memory.
87
+ *
88
+ * ## Reading the body live (#1174)
89
+ *
90
+ * In CI the body is read live via the GitHub API (`resolveBody`,
91
+ * `fetchPrBodyViaGh`), not from `github.event.pull_request.body`. That value
92
+ * is frozen at the moment the `pull_request` event fired, so both of this
93
+ * guard's own documented remedies — edit the body to add `Refs #N`, or add a
94
+ * `Verified-on-deploy:` line — were unable to ever turn the check green: an
95
+ * edit does not re-trigger CI, and a re-run of the job replays the same
96
+ * stale payload. Verified stale on #1172. See `resolveBody` for the fallback
97
+ * to a direct `PR_BODY` (local runs and every test in this suite) and why an
98
+ * unreadable live body fails the guard rather than passing it.
99
+ */
100
+
101
+ /** Closing keywords GitHub actually acts on, per its own documentation. */
102
+ const CLOSING_KEYWORDS = [
103
+ 'close',
104
+ 'closes',
105
+ 'closed',
106
+ 'fix',
107
+ 'fixes',
108
+ 'fixed',
109
+ 'resolve',
110
+ 'resolves',
111
+ 'resolved',
112
+ ]
113
+
114
+ export const VERIFIED_TRAILER = 'Verified-on-deploy:'
115
+
116
+ /** Paths whose behaviour a green suite does not evidence. See the module docstring. */
117
+ export const DEPLOY_ONLY_PREFIXES = [
118
+ 'infra/',
119
+ 'modules/cloud/',
120
+ '.github/workflows/',
121
+ 'db/imports/',
122
+ 'apps/portal/',
123
+ 'apps/frontend/',
124
+ ]
125
+
126
+ /** An issue reference GitHub linkifies: `#12` or `owner/repo#12`. */
127
+ const REFERENCE = '(?:[\\w.-]+/[\\w.-]+)?#\\d+'
128
+
129
+ /**
130
+ * Blank out fenced code blocks and inline code spans, preserving line count.
131
+ *
132
+ * Not merely a courtesy: GitHub does not linkify `#12` inside backticks, so it
133
+ * does not close anything there either. Matching there would make these guards
134
+ * STRICTER than the behaviour they exist to model — and it is how the
135
+ * deploy-path guard first failed its own PR, whose body necessarily quotes the
136
+ * very pattern it forbids. The negation guard has the same problem in a
137
+ * sharper form: its failure message, and any PR discussing it, must be able to
138
+ * quote `does not close #N` without tripping it.
139
+ *
140
+ * Every non-newline character becomes a space rather than vanishing, so a
141
+ * match's offset still maps to the line the author wrote — that is what lets
142
+ * `negatedClosingReferences` name the offending line.
143
+ */
144
+ export function stripCode(body) {
145
+ const blank = (m) => m.replace(/[^\n]/g, ' ')
146
+ return body.replace(/```[\s\S]*?```/g, blank).replace(/`[^`\n]*`/g, blank)
147
+ }
148
+
149
+ /**
150
+ * The issue references a body would close on merge.
151
+ *
152
+ * Matches `Closes #12`, `fixes owner/repo#12` and the `Closes: #12` colon
153
+ * form. Ignores keywords inside code — see `stripCode`.
154
+ */
155
+ export function closingReferences(body) {
156
+ if (!body) return []
157
+ const withoutCode = stripCode(body)
158
+ const pattern = new RegExp(`\\b(${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`, 'gi')
159
+ return [...withoutCode.matchAll(pattern)].map((m) => m[2])
160
+ }
161
+
162
+ /**
163
+ * ── 2. Negated closing keywords, on every path ───────────────────────────
164
+ *
165
+ * A sentence that says a PR does NOT close an issue still closes it. GitHub's
166
+ * linker matches `close #N` and acts; it has no concept of the word before it.
167
+ *
168
+ * Four occurrences, three of them "fixed" by writing the rule down again:
169
+ *
170
+ * - tabsii-platform#76 — the original.
171
+ * - tabsii-crm#133 — `tabsii-crm#141`'s body carried
172
+ * `## Scope note — this PR alone does not close #133`. Its squash commit
173
+ * carried only `Refs #133`. The issue closed on merge anyway. The lesson
174
+ * recorded then: *keeping a denial out of the commit is not sufficient —
175
+ * GitHub's linker reads the PR description text on its own.*
176
+ * - #1238 / #1021 (2026-08-03) — `- **Does not close #1021.**` in the body,
177
+ * `Refs #1021` in the commit, #1021 closed by the squash-merge.
178
+ *
179
+ * The recorded fix each time was a *practice*: "never write a closing keyword
180
+ * in prose". Three occurrences produced a rule and no mechanism, and the
181
+ * fourth was authored with that rule available. That is the argument for a
182
+ * guard rather than another note (#1245).
183
+ *
184
+ * ## Why this fires on every path, unlike the check above
185
+ *
186
+ * The deploy-path check asks "is this closing an issue nothing has evidenced
187
+ * yet?", so what the PR touches is the whole question. This one asks "does the
188
+ * author's own prose contradict what GitHub is about to do?", which has
189
+ * nothing to do with the diff. #1238 touched `scripts/` and `cli/` and the
190
+ * deploy-path check correctly stayed silent while the issue closed anyway.
191
+ *
192
+ * ## Why the detection is safe to make blocking
193
+ *
194
+ * It is not inferring intent. It requires a negation *immediately* before a
195
+ * closing keyword *and* a linkified issue reference — there is no reading of
196
+ * `does not close #N` in which the author wants #N closed. Ordinary prose
197
+ * survives it: `the fail-open the tool exists to close` has no negation and no
198
+ * reference, and `this does not close it` has no `#N`, so GitHub would not
199
+ * close anything there and neither does this fire.
200
+ */
201
+ const NEGATIONS = [
202
+ // `not` covers "does not", "will not", "should not", "is not", "did not".
203
+ '\\bnot',
204
+ '\\bnever',
205
+ '\\bwithout',
206
+ '\\bcannot',
207
+ // The contracted forms, matched as a suffix so one alternative covers
208
+ // don't / doesn't / didn't / won't / can't / isn't / shouldn't.
209
+ "n['’]t",
210
+ ]
211
+
212
+ /**
213
+ * The negated closing references in a body, each with the line that carries
214
+ * it — a guard that says only "no" gets worked around.
215
+ *
216
+ * Returns `[{ reference, line, lineNumber }]`, in body order.
217
+ */
218
+ export function negatedClosingReferences(body) {
219
+ if (!body) return []
220
+ const text = stripCode(body)
221
+ const authored = body.split('\n')
222
+ const pattern = new RegExp(
223
+ `(?:${NEGATIONS.join('|')})\\s+(?:${CLOSING_KEYWORDS.join('|')})\\b:?\\s+(${REFERENCE})`,
224
+ 'gi',
225
+ )
226
+ return [...text.matchAll(pattern)].map((m) => {
227
+ // `stripCode` preserves newlines, so an offset into the blanked text still
228
+ // maps to the line the author actually wrote.
229
+ const lineNumber = text.slice(0, m.index).split('\n').length
230
+ return {
231
+ reference: m[1],
232
+ lineNumber,
233
+ line: (authored[lineNumber - 1] ?? m[0]).trim(),
234
+ }
235
+ })
236
+ }
237
+
238
+ /** Whether the author has claimed, in the body, to have verified this on a
239
+ * deployed environment. Requires something after the colon: a bare trailer is
240
+ * a box tick, not evidence. */
241
+ export function hasVerifiedTrailer(body) {
242
+ if (!body) return false
243
+ const line = body
244
+ .split('\n')
245
+ .find((l) => l.trim().toLowerCase().startsWith(VERIFIED_TRAILER.toLowerCase()))
246
+ if (line === undefined) return false
247
+ return line.slice(line.indexOf(':') + 1).trim().length > 0
248
+ }
249
+
250
+ /** The changed paths that fall under a deploy-only prefix. */
251
+ export function deployOnlyPaths(changedFiles) {
252
+ return changedFiles.filter((f) => DEPLOY_ONLY_PREFIXES.some((p) => f.startsWith(p)))
253
+ }
254
+
255
+ /**
256
+ * Every document GitHub honours a closing keyword in, tagged with a
257
+ * human-readable source so a failure can say exactly where it found the
258
+ * keyword (#1334: knowing only "the body passed" is what let the real bug
259
+ * through — the body WAS clean, the commit message was not).
260
+ *
261
+ * `commits` is the shape `gh pr view --json commits` returns: an array of
262
+ * `{ messageHeadline, messageBody }`. Both are scanned — a keyword can sit
263
+ * in either, and #1334's own repro had it in the headline.
264
+ */
265
+ export function documentsFor({ body, title, commits }) {
266
+ const docs = [{ source: 'the PR body', text: body }]
267
+ if (title) docs.push({ source: 'the PR title', text: title })
268
+ const list = commits ?? []
269
+ list.forEach((commit, i) => {
270
+ const label = list.length === 1 ? 'the commit message' : `commit ${i + 1}`
271
+ if (commit?.messageHeadline) {
272
+ docs.push({ source: `${label} (subject)`, text: commit.messageHeadline })
273
+ }
274
+ if (commit?.messageBody) {
275
+ docs.push({ source: `${label} (body)`, text: commit.messageBody })
276
+ }
277
+ })
278
+ return docs
279
+ }
280
+
281
+ /**
282
+ * The whole decision, pure so it is testable without a repo or a PR.
283
+ *
284
+ * Returns `{ ok }` on a pass, or a failure carrying `kind` plus exactly what
285
+ * tripped it — a guard that says only "no" gets worked around.
286
+ *
287
+ * `title` and `commits` are optional so every existing body-only caller (and
288
+ * test) keeps working unchanged — see `documentsFor`.
289
+ *
290
+ * The negation check runs FIRST and ignores `changedFiles` entirely. It is not
291
+ * a special case of the deploy-path check: a `Verified-on-deploy:` trailer
292
+ * cannot excuse it either, because the author is not claiming the issue is
293
+ * verified, they are saying it is not being closed at all.
294
+ */
295
+ export function assess({ body, title, commits, changedFiles }) {
296
+ const docs = documentsFor({ body, title, commits })
297
+
298
+ const negated = docs.flatMap((doc) =>
299
+ negatedClosingReferences(doc.text).map((n) => ({ ...n, source: doc.source })),
300
+ )
301
+ if (negated.length > 0) return { ok: false, kind: 'negated-keyword', negated }
302
+
303
+ const hits = docs
304
+ .map((doc) => ({ source: doc.source, references: closingReferences(doc.text) }))
305
+ .filter((h) => h.references.length > 0)
306
+ if (hits.length === 0) return { ok: true, reason: 'no-closing-keyword' }
307
+
308
+ const paths = deployOnlyPaths(changedFiles)
309
+ if (paths.length === 0) return { ok: true, reason: 'no-deploy-only-paths' }
310
+
311
+ if (hasVerifiedTrailer(body)) return { ok: true, reason: 'verified-trailer' }
312
+
313
+ const references = [...new Set(hits.flatMap((h) => h.references))]
314
+ return { ok: false, kind: 'deploy-only-path', references, paths, hits }
315
+ }
316
+
317
+ export function formatFailure(result) {
318
+ return result.kind === 'negated-keyword'
319
+ ? formatNegatedFailure(result)
320
+ : formatDeployOnlyFailure(result)
321
+ }
322
+
323
+ function formatNegatedFailure({ negated }) {
324
+ const refs = [...new Set(negated.map((n) => n.reference))]
325
+ return [
326
+ `This PR says it does NOT close ${refs.join(', ')}, and GitHub will`,
327
+ `close ${refs.length === 1 ? 'it' : 'them'} anyway on merge. Its linker matches the keyword and the`,
328
+ 'issue reference; it has no concept of the word "not" in front of them.',
329
+ '',
330
+ ...negated.map((n) => ` ${n.source}, line ${n.lineNumber}: ${n.line}`),
331
+ '',
332
+ 'This has now happened four times (tabsii-platform#76, tabsii-crm#133,',
333
+ '#1021 via #1238 — see #1245). Keeping the denial out of the commit',
334
+ 'message is not enough: GitHub reads the PR description on its own — and',
335
+ '(#1334) a commit message on its own, independent of the body.',
336
+ '',
337
+ 'Rewrite the line so no closing keyword sits in front of the reference:',
338
+ ...refs.map((r) => ` - \`Refs ${r}\`, or "leaves ${r} open"`),
339
+ '',
340
+ 'If the offending text is in the PR body or title, edit it — this guard',
341
+ 'reads both live, so an edit alone turns the check green with no new',
342
+ 'commit (#1174, #1189). If it is in a COMMIT message, the commit itself',
343
+ 'must change (amend/reword and force-push) — the guard reads the commits',
344
+ 'live too, but the commit message that will actually reach the merge',
345
+ 'cannot be edited from the PR page.',
346
+ ].join('\n')
347
+ }
348
+
349
+ function formatDeployOnlyFailure({ references, paths, hits }) {
350
+ const shown = paths.slice(0, 10)
351
+ const more = paths.length - shown.length
352
+ return [
353
+ `This PR would close ${references.join(', ')} on merge — found in:`,
354
+ '',
355
+ ...(hits ?? []).map((h) => ` - ${h.source}: ${h.references.join(', ')}`),
356
+ '',
357
+ 'and it changes paths whose behaviour a green suite does not evidence:',
358
+ '',
359
+ ...shown.map((p) => ` - ${p}`),
360
+ ...(more > 0 ? [` …and ${more} more`] : []),
361
+ '',
362
+ 'GitHub closes the issue the moment this merges — before it is deployed,',
363
+ 'and before anyone has seen it work. AGENTS.md: do not close an issue you',
364
+ 'have not seen fixed.',
365
+ '',
366
+ 'Either:',
367
+ ' - write `Refs #N` instead, deploy, verify by the route the reporter',
368
+ ' used, then close the issue by hand with what you saw; or',
369
+ ` - if you have ALREADY confirmed this on a running environment, add a`,
370
+ ` \`${VERIFIED_TRAILER} <what you saw, and where>\` line to the PR body.`,
371
+ '',
372
+ // Both remedies are body edits, and a body edit does NOT re-trigger this
373
+ // workflow -- `pull_request` uses the default types, which exclude
374
+ // `edited`. The body IS read live (#1174/#1180), so a re-run genuinely
375
+ // re-evaluates; without saying so, the obvious next move is to wait for a
376
+ // re-check that never comes, or to push an empty commit to force one.
377
+ // I did the latter on #1304 while this very message was on screen.
378
+ //
379
+ // If the keyword found above is in a COMMIT rather than the body/title,
380
+ // a body edit does not touch it at all — the commit itself has to be
381
+ // reworded (amend/rebase) and force-pushed, since that text is what
382
+ // reaches the squash-merge commit GitHub actually reads (#1334).
383
+ 'If the match above is in the PR body or title, edit it, then RE-RUN this',
384
+ 'check — do not push an empty commit. Both are read live, so a re-run',
385
+ 'genuinely re-evaluates:',
386
+ '',
387
+ ' gh run rerun <run-id> --failed',
388
+ '',
389
+ 'If the match is in a COMMIT message, editing the PR changes nothing:',
390
+ 'reword the commit (`git commit --amend` or an interactive rebase) and',
391
+ 'force-push the branch — the pushed commit message is what this guard,',
392
+ 'and GitHub itself, will read.',
393
+ '',
394
+ 'The trailer must start the line: a `Verified-on-deploy:` inside backticks',
395
+ 'or a bullet is not a trailer and will not be seen.',
396
+ ].join('\n')
397
+ }
398
+
399
+ /**
400
+ * Fetch a PR's CURRENT body via the GitHub CLI. Broken out from
401
+ * `resolveBody` so tests can inject a fake instead of shelling out to `gh`
402
+ * (which needs a token and a network in real CI).
403
+ */
404
+ export async function fetchPrBodyViaGh({ GH_TOKEN, PR_NUMBER, GH_REPO }) {
405
+ const { execFileSync } = await import('node:child_process')
406
+ return execFileSync(
407
+ 'gh',
408
+ ['pr', 'view', String(PR_NUMBER), '--repo', GH_REPO, '--json', 'body', '--jq', '.body'],
409
+ { encoding: 'utf8', env: { ...process.env, GH_TOKEN } },
410
+ ).trim()
411
+ }
412
+
413
+ /**
414
+ * Resolve the PR body to assess. Two paths, not interchangeable — see #1174.
415
+ *
416
+ * - `PR_BODY` set (including deliberately empty): used as-is, no network
417
+ * involved. This is the local-run and test path — every existing test
418
+ * constructs a body this way, and it must keep working with no `gh` CLI
419
+ * and no token.
420
+ * - `PR_BODY` unset, `GH_TOKEN`/`PR_NUMBER`/`GH_REPO` all set: the CI path.
421
+ * `github.event.pull_request.body` is frozen at the moment the
422
+ * `pull_request` event fired, so neither editing the PR body nor
423
+ * re-running the job can ever pick up a later edit from that payload
424
+ * (verified stale on #1172). Fetching live makes the guard see the PR body
425
+ * as it is right now, including on a bare re-run with no new event.
426
+ *
427
+ * A failed live fetch is deliberately NOT treated as "no body" — that would
428
+ * make an API outage, a missing token, or a permissions refusal silently pass
429
+ * every PR, which is the exact `class:fail-open` shape #1174 is filed under.
430
+ * It throws instead; the caller must fail the check, not swallow it.
431
+ */
432
+ export async function resolveBody({ env = process.env, fetchLiveBody = fetchPrBodyViaGh } = {}) {
433
+ if (env.PR_BODY !== undefined) return env.PR_BODY
434
+
435
+ const { GH_TOKEN, PR_NUMBER, GH_REPO } = env
436
+ const trio = [GH_TOKEN, PR_NUMBER, GH_REPO]
437
+ if (trio.some(Boolean) && !trio.every(Boolean)) {
438
+ // Only some of the three are set: a misconfigured workflow, not "not a
439
+ // PR". Falling through to an empty body here would be the same fail-open
440
+ // shape as swallowing a fetch error, just one step earlier.
441
+ throw new Error(
442
+ 'GH_TOKEN, PR_NUMBER and GH_REPO must all be set together for the live PR-body fetch; got only some of them.',
443
+ )
444
+ }
445
+ if (!trio.every(Boolean)) return ''
446
+
447
+ try {
448
+ return await fetchLiveBody({ GH_TOKEN, PR_NUMBER, GH_REPO })
449
+ } catch (err) {
450
+ throw new Error(
451
+ `could not fetch the live body of PR #${PR_NUMBER} in ${GH_REPO}: ${err?.message ?? err}`,
452
+ )
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Fetch a PR's CURRENT title via the GitHub CLI. Same split as
458
+ * `fetchPrBodyViaGh` so tests can inject a fake.
459
+ */
460
+ export async function fetchPrTitleViaGh({ GH_TOKEN, PR_NUMBER, GH_REPO }) {
461
+ const { execFileSync } = await import('node:child_process')
462
+ return execFileSync(
463
+ 'gh',
464
+ ['pr', 'view', String(PR_NUMBER), '--repo', GH_REPO, '--json', 'title', '--jq', '.title'],
465
+ { encoding: 'utf8', env: { ...process.env, GH_TOKEN } },
466
+ ).trim()
467
+ }
468
+
469
+ /**
470
+ * Resolve the PR title to assess — the second of the three documents GitHub
471
+ * honours (#1334). Same three-path shape as `resolveBody`, deliberately: a
472
+ * frozen `github.event.pull_request.title` was #1187/#1189's bug for the
473
+ * unrelated release-subject guard, and there is no reason to reintroduce it
474
+ * here by copying the field instead of the pattern.
475
+ *
476
+ * - `PR_TITLE` set (including deliberately empty): used as-is, no network.
477
+ * - `PR_TITLE` unset, `GH_TOKEN`/`PR_NUMBER`/`GH_REPO` all set: live fetch.
478
+ * - Neither: not a PR — empty title, nothing to scan.
479
+ *
480
+ * Fails CLOSED on a half-configured trio or a failed live fetch, same
481
+ * reasoning as `resolveBody` — silently falling back to "no title" would be
482
+ * the `class:fail-open` shape #1174 exists to prevent.
483
+ */
484
+ export async function resolveTitle({ env = process.env, fetchLiveTitle = fetchPrTitleViaGh } = {}) {
485
+ if (env.PR_TITLE !== undefined) return env.PR_TITLE
486
+
487
+ const { GH_TOKEN, PR_NUMBER, GH_REPO } = env
488
+ const trio = [GH_TOKEN, PR_NUMBER, GH_REPO]
489
+ if (trio.some(Boolean) && !trio.every(Boolean)) {
490
+ throw new Error(
491
+ 'GH_TOKEN, PR_NUMBER and GH_REPO must all be set together for the live PR-title fetch; got only some of them.',
492
+ )
493
+ }
494
+ if (!trio.every(Boolean)) return ''
495
+
496
+ try {
497
+ return await fetchLiveTitle({ GH_TOKEN, PR_NUMBER, GH_REPO })
498
+ } catch (err) {
499
+ throw new Error(
500
+ `could not fetch the live title of PR #${PR_NUMBER} in ${GH_REPO}: ${err?.message ?? err}`,
501
+ )
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Fetch a PR's commits via the GitHub CLI: `{ messageHeadline, messageBody }`
507
+ * per commit, exactly the shape `gh pr view --json commits` returns. Broken
508
+ * out so tests can inject a fake, same as the body/title fetchers.
509
+ */
510
+ export async function fetchPrCommitsViaGh({ GH_TOKEN, PR_NUMBER, GH_REPO }) {
511
+ const { execFileSync } = await import('node:child_process')
512
+ const raw = execFileSync(
513
+ 'gh',
514
+ ['pr', 'view', String(PR_NUMBER), '--repo', GH_REPO, '--json', 'commits', '--jq', '.commits'],
515
+ { encoding: 'utf8', env: { ...process.env, GH_TOKEN } },
516
+ ).trim()
517
+ return raw ? JSON.parse(raw) : []
518
+ }
519
+
520
+ /**
521
+ * Resolve the PR's commits to assess — the third document, and the one
522
+ * #1334 is actually about: GitHub builds this repo's squash-merge commit
523
+ * message from the individual commit messages, not from the PR body, so a
524
+ * closing keyword left there survives a body edit that looks like a fix.
525
+ *
526
+ * Same three-path shape as `resolveBody`/`resolveTitle`:
527
+ *
528
+ * - `PR_COMMITS` set (including `''`, read as no commits): a JSON array of
529
+ * `{ messageHeadline, messageBody }`, used as-is, no network — the
530
+ * local-run and test path.
531
+ * - `PR_COMMITS` unset, `GH_TOKEN`/`PR_NUMBER`/`GH_REPO` all set: live
532
+ * fetch, so a re-run sees the commits as they are right now (an amend +
533
+ * force-push), not as they were when the PR was opened.
534
+ * - Neither: not a PR — no commits to scan.
535
+ *
536
+ * Fails CLOSED on a half-configured trio or a failed fetch, same as the
537
+ * other two resolvers and for the same reason: a silent empty-commits
538
+ * fallback here is indistinguishable from "nothing to find" and would let
539
+ * an API outage pass every PR — the exact shape #1174 is filed under.
540
+ */
541
+ export async function resolveCommits({
542
+ env = process.env,
543
+ fetchLiveCommits = fetchPrCommitsViaGh,
544
+ } = {}) {
545
+ if (env.PR_COMMITS !== undefined) return env.PR_COMMITS === '' ? [] : JSON.parse(env.PR_COMMITS)
546
+
547
+ const { GH_TOKEN, PR_NUMBER, GH_REPO } = env
548
+ const trio = [GH_TOKEN, PR_NUMBER, GH_REPO]
549
+ if (trio.some(Boolean) && !trio.every(Boolean)) {
550
+ throw new Error(
551
+ 'GH_TOKEN, PR_NUMBER and GH_REPO must all be set together for the live PR-commits fetch; got only some of them.',
552
+ )
553
+ }
554
+ if (!trio.every(Boolean)) return []
555
+
556
+ try {
557
+ return await fetchLiveCommits({ GH_TOKEN, PR_NUMBER, GH_REPO })
558
+ } catch (err) {
559
+ throw new Error(
560
+ `could not fetch the commits of PR #${PR_NUMBER} in ${GH_REPO}: ${err?.message ?? err}`,
561
+ )
562
+ }
563
+ }
564
+
565
+ // ── CLI ───────────────────────────────────────────────────────────────────
566
+ // Bare node, no install, matching practices-monotonic.mjs — so this runs in
567
+ // the Release Guards job without depending on the pnpm install step.
568
+ if (import.meta.url === `file://${process.argv[1]}`) {
569
+ const { execSync } = await import('node:child_process')
570
+
571
+ const base = process.env.GITHUB_BASE_REF
572
+
573
+ if (!base) {
574
+ console.log('✓ closing-keyword guard: skipped — no GITHUB_BASE_REF (not a pull request).')
575
+ process.exit(0)
576
+ }
577
+
578
+ let body, title, commits
579
+ try {
580
+ // All three read live where a token is available (#1174, and #1334 for
581
+ // commits specifically) — a re-run genuinely re-evaluates the PR/commits
582
+ // as they are now, not as they were when the workflow event fired.
583
+ body = await resolveBody()
584
+ title = await resolveTitle()
585
+ commits = await resolveCommits()
586
+ } catch (err) {
587
+ // Fail closed (#1174): an unreadable body/title/commits is an error,
588
+ // never a silent "no closing keyword found".
589
+ console.error(`✘ closing-keyword guard: ${err.message}`)
590
+ process.exit(1)
591
+ }
592
+
593
+ let changedFiles = []
594
+ try {
595
+ changedFiles = execSync(`git diff --name-only origin/${base}...HEAD`, { encoding: 'utf8' })
596
+ .split('\n')
597
+ .map((l) => l.trim())
598
+ .filter(Boolean)
599
+ } catch (err) {
600
+ // A guard that cannot see its input must say so rather than passing. The
601
+ // estate's most repeated defect is a zero that means "could not look".
602
+ console.error(`✘ closing-keyword guard: could not diff against origin/${base}.`)
603
+ console.error(String(err?.stderr ?? err?.message ?? err))
604
+ process.exit(1)
605
+ }
606
+
607
+ const result = assess({ body, title, commits, changedFiles })
608
+ if (result.ok) {
609
+ console.log(`✓ closing-keyword guard: ${result.reason}.`)
610
+ process.exit(0)
611
+ }
612
+
613
+ console.error(formatFailure(result))
614
+ process.exit(1)
615
+ }