-
Notifications
You must be signed in to change notification settings - Fork 10
295 lines (280 loc) · 15.3 KB
/
Copy pathrelease-coverage-patrol.yml
File metadata and controls
295 lines (280 loc) · 15.3 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
name: Release-Coverage Patrol
# The standing caller for `scripts/check-release-section-coverage.mjs` (#10803).
#
# ## Why a workflow, when the gate ALREADY runs on every PR
#
# Unlike `half-state-patrol.yml` and `required-set-patrol.yml`, this check does
# not need a runner for TRANSPORT reasons. It is fully offline — it reads
# `packages/spec/CHANGELOG.md` and `content/docs/releases/**` straight out of the
# tree, opens no socket, and runs inside `lint.yml` on every pull request today.
# Measured, not assumed: a network-shaped scan of the script (`fetch(`, `http`,
# `api.github.com`, `execSync`, `child_process`, `octokit`, `GITHUB_TOKEN`)
# matches ZERO lines, against 4 matches each in those two patrols' workflows.
#
# So the reason for a standing caller here is AUDIENCE, not reachability, and it
# was measured on a live gap rather than argued (#10803):
#
# 17.2.0's CHANGELOG heading landed 2026-08-23T06:29:35Z. Over the next 40
# hours, 262 pull requests landed on `main`. Every one of their `lint.yml`
# runs emitted the two advisory `::warning::` annotations. Commits touching
# `content/docs/releases/` in that window: ZERO. Open PRs authoring the
# section: ZERO. #10268's acceptance criterion is "visible within a day of a
# minor publishing" — already exceeded, with the advisory in place and firing.
#
# Three structural reasons that reading is not bad luck, all verified against a
# real PR head sha through the check-runs API:
#
# 1. The annotation is registered at `path=.github`, line 19/20 — the WORKFLOW
# file, because the gate emits `::warning::` with no `file=` parameter. It
# therefore cannot appear on `content/docs/releases/v17.mdx` in the
# Files-changed tab, which is the only place a reviewer reads by default.
# 2. It rides a GREEN required check. That PR head carried 31 check runs, 0 of
# them failing, which GitHub collapses to "All checks have passed". It is
# step 74 of 106 in that job.
# 3. It is addressed to whoever opened the next PR. That author did not create
# the debt and does not own the remedy. A finding every PR author sees and
# no PR author owns is the shape that stays unfixed for 25 days.
#
# Volume is NOT the problem and this file does not pretend otherwise: those two
# annotations were the only `##[warning]` lines in a 4004-line job log, and 2 of
# only 5 annotations across all 31 checks. The advisory is quiet and correct. It
# is simply pointed at the wrong reader, and no amount of shouting on a PR fixes
# that. This workflow adds the reader; it does not replace the advisory.
#
# ## Why this calls `--strict`, and why it still cannot go red
#
# `--strict` promotes findings to exit 1 "for a caller that OWNS the remedy". It
# had no standing caller at all, which is the defect #10803 records. It has one
# here — as a PREDICATE, never as a verdict.
#
# The two runs below are not redundant, and the order is load-bearing. The gate
# deliberately returns 1 for BOTH "findings, under --strict" and "the instrument
# is broken", so `--strict` alone is ambiguous and could not be branched on:
#
# run 1, ADVISORY — exit 0 means the instrument READ the corpus (findings or
# not); non-zero means it could not, and that is fatal in
# every mode by the gate's own design. This run also
# produces the authored, human-readable finding text.
# run 2, --STRICT — with the instrument already proven healthy by run 1,
# exit 1 now means exactly one thing: findings exist.
#
# That is what lets the patrol branch on a machine-readable boolean instead of
# grepping prose, while the JOB stays green whenever findings exist. A `--strict`
# run stays red for as long as the prose is unwritten — 25 days, in the 16.1.0
# case — and a chronically red scheduled job is how people learn to skim red,
# which `prerelease-pin-watch.yml`'s header warns about directly. Findings land
# in the anchor issue; they never colour this job.
#
# ## What lands where
#
# One pinned ANCHOR ISSUE, rewritten in place every run (`ANCHOR_ISSUE` below) —
# `half-state-patrol.yml`'s shape and its argument, which applies verbatim here:
# never a comment per run, because a per-run comment stream is a second tracker
# nobody prunes and GitHub's edit history is already the archive this needs.
#
# It fits this finding particularly well. The population is tiny — 5 in-scope GA
# minors exist in total (16.0.0, 16.1.0, 17.0.0, 17.1.0, 17.2.0) — so the anchor
# is quiet almost always, and the remedy is owner-shaped: one person writes one
# curated section. An issue can be assigned. An annotation cannot.
#
# The `Swept` timestamp is the patrol's heartbeat and is refreshed even when the
# findings are unchanged: a timestamp that stops advancing is how a reader learns
# the standing caller died. The run must not "optimize away" that no-op edit.
#
# ## Report-only, and the one thing that is NOT report-only
#
# Findings never fail anything. This job never writes release prose — it cannot,
# and the gate's header argues why at length: a curated section is a judgement
# about what is user-facing, read out of 69 package changelogs, and this gate
# GENERATES NOTHING.
#
# The job DOES fail when the sweep could not run, or when its report could not be
# delivered. That is not a gate on the release pages; it is the patrol reporting
# its own death. Silent non-delivery would leave a stale anchor body that reads
# exactly like a clean corpus — "could not read the input" must never look like
# "input is clean" (#4690), with a heartbeat on it. Failing costs nobody a PR:
# this workflow gates no branch and blocks no queue.
on:
# Nightly. #10268's acceptance criterion is "visible within a day of a minor
# publishing", so the cadence is a day, not a week. Offset from
# half-state-patrol.yml's `37 1,7,13,19` so two patrols never contend for the
# same runner minute.
schedule:
- cron: '19 4 * * *'
workflow_dispatch: {}
# Changes to the patrol itself get exercised before they merge — the same
# posture half-state-patrol.yml keeps. On a pull_request run the sweep still
# executes (that is the point: the flags, the exit-code split and the rendering
# are proven on a real runner), but the anchor write is skipped and the
# rendered body goes to the run's step summary instead. A PR must never rewrite
# the board's pinned view.
pull_request:
paths:
- 'scripts/check-release-section-coverage.mjs'
- '.github/workflows/release-coverage-patrol.yml'
# Least privilege: this job reads the repo and writes exactly one issue BODY.
# `issues: write` is the narrowest scope GitHub offers for that edit; the job
# never uses it for labels, comments, assignees or state, and the gate it calls
# is read-only against the filesystem by construction.
permissions:
contents: read
issues: write
# One patrol at a time. A scheduled run overlapping a manual dispatch would have
# two runs racing to rewrite the same body, and the loser's findings would vanish
# with no trace but an edit-history entry.
concurrency:
group: release-coverage-patrol
cancel-in-progress: false
env:
# The pinned anchor issue whose body this workflow owns.
#
# Resolution: the repository variable `RELEASE_COVERAGE_ANCHOR_ISSUE` if set,
# else this repo's own pinned number, else EMPTY — and empty makes the job
# refuse to write rather than guess. The literal is guarded by the repository
# name on purpose: an unguarded fallback would let a verbatim copy in a sibling
# repo rewrite ITS #11879 — some unrelated card — with this repo's findings,
# silently, every night. A number is only meaningful in the repo it was minted
# in.
#
# TO ROTATE (here): open a new `tracking`-labeled issue, put its number below,
# and note the handover in the OLD issue's body before closing it (its edit
# history is the archive and does not travel).
# TO ADOPT (a sibling repo): change NOTHING here — set the repository variable.
#
# ⚠️ Folded scalar, and every continuation line sits at the SAME indent on
# purpose: a more-indented line in a `>-` block keeps its newline literally,
# which would hand the expression parser a multi-line string instead of one
# expression.
ANCHOR_ISSUE: >-
${{ vars.RELEASE_COVERAGE_ANCHOR_ISSUE
|| (github.repository == 'objectstack-ai/objectstack' && '11879')
|| '' }}
jobs:
patrol:
name: Release section-coverage sweep
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
# No `pnpm install`: the gate imports `node:fs` and one local module.
# Installing the workspace here would buy nothing and would give a
# scheduled patrol a lockfile it could fail on.
#
# No `--self-test` either. lint.yml runs it on every pull request, so this
# job would be re-proving a property already proven on the commit it just
# checked out — and two node invocations in one `run:` block is precisely
# the shape `check:step-collector` exists to refuse, because the first
# one's failure is masked by the second.
- name: Sweep the corpus
id: sweep
run: |
# ── run 1: ADVISORY. Exit code answers "did the instrument READ the
# corpus", which is the only thing that may colour this job.
set +e
node scripts/check-release-section-coverage.mjs \
> "$RUNNER_TEMP/report.txt" 2> "$RUNNER_TEMP/report.err"
advisory_code=$?
set -e
# Captured with NO pipe in between. `cmd | tail` would report the
# PIPE's status — `tail` essentially never fails, so a healthy and a
# broken instrument would both read as 0.
echo "advisory_code=$advisory_code" >> "$GITHUB_OUTPUT"
echo "advisory run exited $advisory_code"
# ── run 2: --STRICT, used ONLY as a findings predicate. Meaningful
# solely because run 1 has already established the instrument is
# healthy; the gate returns 1 for a broken instrument too.
strict_code=0
if [ "$advisory_code" -eq 0 ]; then
set +e
node scripts/check-release-section-coverage.mjs --strict \
> "$RUNNER_TEMP/strict.txt" 2> "$RUNNER_TEMP/strict.err"
strict_code=$?
set -e
echo "strict run exited $strict_code"
else
echo "instrument is broken — skipping the --strict predicate"
fi
echo "strict_code=$strict_code" >> "$GITHUB_OUTPUT"
cat "$RUNNER_TEMP/report.err" >&2 || true
- name: Resolve the anchor issue
# An install with no anchor configured has nowhere to land its report,
# and the ONLY safe behaviour is to say so loudly. The two alternatives
# are both the failure this file exists to prevent: guessing a number
# would rewrite an unrelated card in this repo, and skipping the write
# quietly would leave a patrol that runs, finds, and tells nobody —
# indistinguishable from a clean corpus.
#
# Placed AFTER the sweep so the run summary still carries the findings,
# and skipped on a pull_request run, which never writes an anchor.
if: github.event_name != 'pull_request'
run: |
if [ -z "${ANCHOR_ISSUE//[[:space:]]/}" ]; then
echo "::error::No anchor issue configured for ${{ github.repository }}. The sweep RAN (see the run summary) but has nowhere to land. Open a \`tracking\`-labeled anchor issue in this repo and set the repository variable RELEASE_COVERAGE_ANCHOR_ISSUE to its number (Settings -> Secrets and variables -> Actions -> Variables)."
exit 1
fi
case "$ANCHOR_ISSUE" in
*[!0-9]*|'') echo "::error::RELEASE_COVERAGE_ANCHOR_ISSUE is '$ANCHOR_ISSUE', which is not an issue number."; exit 1 ;;
esac
echo "anchor: #$ANCHOR_ISSUE in ${{ github.repository }}"
- name: Render the report
id: render
env:
ADVISORY_CODE: ${{ steps.sweep.outputs.advisory_code }}
STRICT_CODE: ${{ steps.sweep.outputs.strict_code }}
PROVENANCE: >-
run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
· commit `${{ github.sha }}` · trigger `${{ github.event_name }}`
# Runs its own `--self-test` first (#11150). That leg is not ceremony
# here: the renderer's assertions pin `verdict()` — the mapping from the
# sweep's two exit codes onto did-not-run / findings / clean — and a
# broken mapping still renders a body. The live run below would stay
# exit 0 and publish a WRONG report, including the one reading #4690
# forbids, a broken instrument rendered as a clean corpus. Only the live
# run is redirected; the self-test's own verdict goes to the step log.
run: |
node scripts/render-release-coverage-anchor.mjs --self-test
node scripts/render-release-coverage-anchor.mjs > "$RUNNER_TEMP/body.md"
# The rendered body is the product of the run, so it goes to the step
# summary on EVERY trigger — including the pull_request runs that must not
# touch the anchor. A reader of this workflow's own PR sees exactly what
# would have landed.
- name: Publish the report to the run summary
run: cat "$RUNNER_TEMP/body.md" >> "$GITHUB_STEP_SUMMARY"
- name: Update the pinned anchor issue
# A pull_request run proves the sweep; it must not touch the board.
if: github.event_name != 'pull_request'
uses: actions/github-script@v9
with:
# Delivery is retried, never assumed: this single PATCH is the entire
# product of the run, and a transient answer from the issues endpoint
# would otherwise discard a completed sweep.
retries: 3
script: |
const fs = require('fs');
const path = require('path');
const anchor = Number(process.env.ANCHOR_ISSUE);
const body = fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'body.md'), 'utf8');
if (!body.trim()) {
throw new Error('the renderer produced an empty body — refusing to blank the anchor');
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: anchor,
body,
});
core.info(`anchor #${anchor} updated (${body.length} chars)`);
# LAST, so a completed sweep always lands its report before the patrol
# raises the alarm about itself — "land the truth, then raise the alarm".
# This is the ONLY step that may redden the job, and findings can never
# reach it: it reads the instrument's verdict, not the corpus's.
- name: Fail if the sweep could not run
if: steps.sweep.outputs.advisory_code != '0'
run: |
echo "::error::check-release-section-coverage could not read the corpus (exit ${{ steps.sweep.outputs.advisory_code }}). The patrol is DOWN — findings, if any, were NOT judged this run. A sweep that could not run must never read as a clean corpus."
exit 1