diff --git a/.github/workflows/refresh-entry.yml b/.github/workflows/refresh-entry.yml index 96588fb..9c2ab2b 100644 --- a/.github/workflows/refresh-entry.yml +++ b/.github/workflows/refresh-entry.yml @@ -145,12 +145,14 @@ jobs: ENTRY_SLUG: ${{ steps.refresh.outputs.slug }} ENTRY_FILE: ${{ steps.refresh.outputs.file }} ENTRY_CHANGES: ${{ steps.refresh.outputs.changes }} + ENTRY_VERIFIED_KEY: ${{ steps.refresh.outputs.verified_key }} with: script: | const { owner, repo } = context.repo; const slug = process.env.ENTRY_SLUG; const file = process.env.ENTRY_FILE; const changes = String(process.env.ENTRY_CHANGES || ''); + const verifiedKey = process.env.ENTRY_VERIFIED_KEY || 'verified'; const branch = context.payload.repository?.default_branch || 'main'; await github.rest.issues.createComment({ @@ -164,7 +166,7 @@ jobs: '', '> ' + changes.split('\n').join('\n> '), '', - `Maintainers: the entry is [\`${file}\`](https://github.com/${owner}/${repo}/edit/${branch}/${file}). Apply what applies, set \`verified:\` to the day you checked, and close this issue with the pull request.` + `Maintainers: the entry is [\`${file}\`](https://github.com/${owner}/${repo}/edit/${branch}/${file}). Apply what applies, set \`${verifiedKey}:\` to the day you checked, and close this issue with the pull request.` ].join('\n') }); diff --git a/.github/workflows/verification-sweep.yml b/.github/workflows/verification-sweep.yml index 817a4d5..9b4b042 100644 --- a/.github/workflows/verification-sweep.yml +++ b/.github/workflows/verification-sweep.yml @@ -72,8 +72,14 @@ jobs: - name: Install npm dependencies run: npm ci --ignore-scripts + # The issue bodies travel through a file, not a step output: an output is + # re-exported below as one environment entry, which Linux caps near + # 128 KiB, and two hundred issue bodies can pass that. An env entry that + # large stops the runner from launching the step that reads it. - name: Find unconfirmed entries id: sweep + env: + SWEEP_ISSUES_FILE: ${{ runner.temp }}/sweep-issues.json run: node scripts/verification_sweep.mjs # Runs whatever the count is: with nothing stale there may still be issues @@ -82,14 +88,18 @@ jobs: - name: Open, refresh and close the entry refresh issues uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - SWEEP_ISSUES: ${{ steps.sweep.outputs.issues }} + SWEEP_ISSUES_FILE: ${{ runner.temp }}/sweep-issues.json SWEEP_SLUGS: ${{ steps.sweep.outputs.slugs }} SWEEP_COUNT: ${{ steps.sweep.outputs.count }} SWEEP_MAX_NEW: ${{ steps.sweep.outputs.max_new }} with: script: | + const fs = require('fs'); const { owner, repo } = context.repo; - const wanted = JSON.parse(process.env.SWEEP_ISSUES || '[]'); + const issuesFile = process.env.SWEEP_ISSUES_FILE; + const wanted = issuesFile && fs.existsSync(issuesFile) + ? JSON.parse(fs.readFileSync(issuesFile, 'utf8')) + : []; const staleSlugs = new Set(String(process.env.SWEEP_SLUGS || '').split('\n').filter(Boolean)); const staleCount = Number(process.env.SWEEP_COUNT || '0'); const maxNew = Number(process.env.SWEEP_MAX_NEW || '20'); diff --git a/scripts/refresh_entry_from_issue.mjs b/scripts/refresh_entry_from_issue.mjs index 0cb9c77..cb710f7 100644 --- a/scripts/refresh_entry_from_issue.mjs +++ b/scripts/refresh_entry_from_issue.mjs @@ -197,6 +197,9 @@ function main() { setOutput('slug', form.slug); setOutput('file', relative); setOutput('changes', form.changes); + // The guidance comment names the date field to stamp, which a catalog can + // rename via entry.verified_key — hand the resolved name to the workflow. + setOutput('verified_key', verifiedKey); // Same branch name a "yes" answer on this issue would have used, so a // submitter who answers "yes" and then edits the issue to "no" leaves the // workflow a name to find and close that stale confirmation with. diff --git a/scripts/verification_sweep.mjs b/scripts/verification_sweep.mjs index ff91ccd..ad5957e 100644 --- a/scripts/verification_sweep.mjs +++ b/scripts/verification_sweep.mjs @@ -4,7 +4,8 @@ * one issue per entry asking the people who can answer. * * Usage: node scripts/verification_sweep.mjs [--today YYYY-MM-DD] [--json] - * Outputs (Actions): `count`, `issues`, `max_new` — see + * Outputs (Actions): `count`, `slugs`, `max_new`; the issue bodies are written + * to the file named by $SWEEP_ISSUES_FILE — see * .github/workflows/verification-sweep.yml * * An entry is "confirmed" on the newest of its `verified`, `updated` and @@ -50,12 +51,14 @@ export const DEFAULT_MAX_NEW_ISSUES = 20; * How many issue bodies one run hands to the workflow. * * A separate concern from the cap above, and a much larger number: this one is - * a size guard, not a courtesy. Every body travels through `$GITHUB_OUTPUT`, - * which is not an unbounded channel, so a catalog with a thousand overdue - * entries must not turn the monthly job permanently red. The list of stale - * slugs is emitted in full regardless (it is cheap, and the workflow needs all - * of it to decide which issues to close), so nothing beyond this is mistaken - * for fresh — it is only deferred, and the run summary says so. + * a size guard, not a courtesy. The bodies travel through the file named by + * `$SWEEP_ISSUES_FILE` — never a step output copied into one environment + * entry, which Linux caps near 128 KiB and which, exceeded, stops the runner + * from even launching the step that reads it — so the cap is no longer a + * transport ceiling, just a bound on how much JSON one run builds. The list of + * stale slugs is emitted in full regardless (it is cheap, and the workflow + * needs all of it to decide which issues to close), so nothing beyond this is + * mistaken for fresh — it is only deferred, and the run summary says so. */ export const MAX_ISSUE_PAYLOAD = 200; /** The template of the per-entry dedupe marker; also parsed by the workflow. */ @@ -399,7 +402,11 @@ function main() { } setOutput('count', String(stale.length)); - setOutput('issues', JSON.stringify(issues)); + // The bodies go through a file: two hundred of them can pass 128 KiB, and an + // environment entry that large stops the runner from launching the step that + // would read it. See the MAX_ISSUE_PAYLOAD note above. + const issuesFile = process.env.SWEEP_ISSUES_FILE; + if (issuesFile) fs.writeFileSync(issuesFile, JSON.stringify(issues)); setOutput('slugs', stale.map((entry) => entry.slug).join('\n')); setOutput('max_new', String(maxNew)); } diff --git a/test/scripts/release_workflows.test.mjs b/test/scripts/release_workflows.test.mjs index b8d2be4..be397ce 100644 --- a/test/scripts/release_workflows.test.mjs +++ b/test/scripts/release_workflows.test.mjs @@ -967,3 +967,22 @@ test('protected-main automation stays reviewable and generated PRs can satisfy r assert.match(codeql, /'Analyze ruby'/u); assert.match(workflow('performance.yml'), /\['scale', 'Performance and scale \(dispatch\)'\]/u); }); + +test('the sweep hands issue bodies to its reader through a file, not an environment entry', () => { + // steps.sweep.outputs.issues copied into `env:` becomes a single Linux + // environment entry, capped near 128 KiB — two hundred issue bodies can pass + // that, and the runner then cannot launch the step that reads it. + const sweep = workflow('verification-sweep.yml'); + assert.doesNotMatch(sweep, /steps\.sweep\.outputs\.issues/u); + const named = sweep.match(/SWEEP_ISSUES_FILE: \$\{\{ runner\.temp \}\}\/sweep-issues\.json/gu) ?? []; + assert.equal(named.length, 2, 'writer and reader must name the same issues file'); +}); + +test('the changes guidance names the configured verification key, not a literal', () => { + // A catalog that renames `verified` via entry.verified_key must not be told + // to stamp a field its sweep does not read. + const refresh = workflow('refresh-entry.yml'); + assert.match(refresh, /ENTRY_VERIFIED_KEY: \$\{\{ steps\.refresh\.outputs\.verified_key \}\}/u); + assert.match(refresh, /set \\`\$\{verifiedKey\}:\\` to the day you checked/u); + assert.doesNotMatch(refresh, /set \\`verified:\\` to the day you checked/u); +}); diff --git a/test/scripts/verification_sweep.test.mjs b/test/scripts/verification_sweep.test.mjs index 6e24519..fe440f3 100644 --- a/test/scripts/verification_sweep.test.mjs +++ b/test/scripts/verification_sweep.test.mjs @@ -6,10 +6,12 @@ * functions produce. */ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { DEFAULT_MAX_NEW_ISSUES, @@ -404,3 +406,34 @@ test('a custom verified_key is honoured end to end: staleEntries stops nagging o const withSchemaKeys = staleEntries(entries, todayDay, 365, verificationKeys); assert.equal(withSchemaKeys.length, 0); }); + +/* ------------------------------------------------------------------- main */ + +test('the issue bodies land in $SWEEP_ISSUES_FILE, never in $GITHUB_OUTPUT', () => { + // Two hundred bodies can pass 128 KiB, and a step output re-exported as one + // environment entry that large stops the runner from launching the step + // that reads it — so the bodies must bypass $GITHUB_OUTPUT entirely. + const { root, write } = repo(); + write('_data/schema.yml', 'entry:\n path: "catalog"\nfields: []\n'); + write('catalog/thing/index.md', '---\ntitle: "A thing"\npublished: 2020-01-01\n---\n\nBody.\n'); + const issuesFile = path.join(root, 'sweep-issues.json'); + const outputFile = path.join(root, 'github-output.txt'); + fs.writeFileSync(outputFile, ''); + + const script = fileURLToPath(new URL('../../scripts/verification_sweep.mjs', import.meta.url)); + execFileSync(process.execPath, [script, '--today', '2026-08-17'], { + cwd: root, + env: { ...process.env, SWEEP_ISSUES_FILE: issuesFile, GITHUB_OUTPUT: outputFile }, + }); + + const raw = fs.readFileSync(issuesFile, 'utf8'); + const issues = JSON.parse(raw); + assert.ok(Array.isArray(issues)); + assert.equal(issues.length, 1); + assert.match(raw, /refresh-entry: thing/); + + const outputs = fs.readFileSync(outputFile, 'utf8'); + assert.match(outputs, /^count<