@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.
- package/_skeletons/plugin-template/.github/workflows/release-guards.yml +75 -0
- package/_skeletons/plugin-template/scripts/check-closing-keywords.mjs +615 -0
- package/_skeletons/sibling-template/.github/workflows/release-guards.yml +75 -0
- package/_skeletons/sibling-template/scripts/check-closing-keywords.mjs +615 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|