mandrel-platform 1.0.0 → 1.1.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/package.json +1 -1
- package/scripts/audit-check.mjs +112 -12
- package/scripts/audit-check.test.mjs +195 -0
- package/scripts/check-cancelled-provenance.test.mjs +986 -0
- package/scripts/check-ci-required-aggregator.test.mjs +169 -13
- package/scripts/check-destructive-migration.test.mjs +37 -0
- package/scripts/check-docs-staleness.mjs +15 -2
- package/scripts/check-docs-staleness.test.mjs +114 -9
- package/scripts/check-fail-fast-attribution.test.mjs +390 -0
- package/scripts/job-cleanup-hook.test.mjs +234 -0
- package/scripts/platform-sync.mjs +89 -15
- package/scripts/platform-sync.test.mjs +125 -0
- package/templates/runbooks/runner-provisioning.md +12 -3
- package/templates/runner/.env.example +8 -2
- package/templates/runner/job-cleanup.sh +49 -20
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-fail-fast-attribution.test.mjs — regression guard for fail-fast
|
|
4
|
+
* cancellation attribution in `.github/workflows/pr-quality.yml` (Story #331).
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS
|
|
7
|
+
* ---------------
|
|
8
|
+
* Opt-in `fail-fast` (Story #223) cancels the whole run on the first tier
|
|
9
|
+
* failure. GitHub records a bare `cancelled` conclusion on every sibling tier
|
|
10
|
+
* — with no pointer to the tier that actually failed. A job-status scan then
|
|
11
|
+
* reads N red jobs and blames the wrong one: in swarm-os run 30168441137 the
|
|
12
|
+
* Accessibility tier failed on a transient build flake, fail-fast cancelled
|
|
13
|
+
* Typecheck and Lint & format (both of which had already passed their own work
|
|
14
|
+
* step), and triage landed on Typecheck.
|
|
15
|
+
*
|
|
16
|
+
* The fix writes the attribution at two workflow surfaces, and this suite pins
|
|
17
|
+
* both against regression:
|
|
18
|
+
*
|
|
19
|
+
* 1. TRIGGER SIDE (`&cancel-on-failure`) — the authoritative record. It runs
|
|
20
|
+
* in a job whose conclusion is `failure`, never `cancelled`, so it is not
|
|
21
|
+
* subject to the runner's cancellation grace budget. It emits a run-level
|
|
22
|
+
* `::error title=fail-fast::` annotation and a job-summary block naming
|
|
23
|
+
* the tier, BEFORE requesting the cancel — so the record survives even
|
|
24
|
+
* when the cancel call itself fails.
|
|
25
|
+
*
|
|
26
|
+
* 2. COLLATERAL SIDE (`&explain-cancellation`) — best effort. Fires only on
|
|
27
|
+
* `cancelled()`, states that this tier did not itself fail, and resolves
|
|
28
|
+
* the real culprit from the Actions API. Loud-but-non-fatal on the cancel
|
|
29
|
+
* step's terms: every lookup failure degrades to a generic message and
|
|
30
|
+
* exits 0, and `timeout-minutes: 1` keeps the lookup from eating the
|
|
31
|
+
* grace budget the tier's own artifact uploads need.
|
|
32
|
+
*
|
|
33
|
+
* It also pins the negative space that makes this change safe to ship to
|
|
34
|
+
* consumers: no new permission surface. GitHub validates a called workflow's
|
|
35
|
+
* declared JOB permissions against the caller's grant at compile time,
|
|
36
|
+
* ignoring the job's `if:` gate — so a job-level `permissions:` block here
|
|
37
|
+
* would `startup_failure` every consumer that has not widened its caller
|
|
38
|
+
* token, whether or not they enable fail-fast.
|
|
39
|
+
*
|
|
40
|
+
* Run: node --test scripts/check-fail-fast-attribution.test.mjs
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import assert from "node:assert/strict";
|
|
44
|
+
import { test } from "node:test";
|
|
45
|
+
import {
|
|
46
|
+
readFileSync,
|
|
47
|
+
writeFileSync,
|
|
48
|
+
mkdtempSync,
|
|
49
|
+
mkdirSync,
|
|
50
|
+
rmSync,
|
|
51
|
+
chmodSync,
|
|
52
|
+
} from "node:fs";
|
|
53
|
+
import { spawnSync } from "node:child_process";
|
|
54
|
+
import { join, resolve, dirname } from "node:path";
|
|
55
|
+
import { fileURLToPath } from "node:url";
|
|
56
|
+
import { tmpdir } from "node:os";
|
|
57
|
+
|
|
58
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
59
|
+
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
60
|
+
const source = readFileSync(join(repoRoot, WORKFLOW), "utf8");
|
|
61
|
+
const lines = source.split("\n");
|
|
62
|
+
|
|
63
|
+
const EXPLAIN = "explain-cancellation";
|
|
64
|
+
const CANCEL = "cancel-on-failure";
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Indentation-based extraction (dependency-free, mirroring the approach in
|
|
68
|
+
// check-ci-required-aggregator.test.mjs). Steps are ` - ` entries at
|
|
69
|
+
// 6-space indent; jobs are ` <id>:` at 2-space indent.
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/** The step block introduced by ` - &<anchor>`, through its last line. */
|
|
73
|
+
function extractAnchoredStep(anchor) {
|
|
74
|
+
const start = lines.findIndex((l) => l === ` - &${anchor}`);
|
|
75
|
+
assert.notEqual(start, -1, `anchor \`&${anchor}\` not found in ${WORKFLOW}`);
|
|
76
|
+
let end = lines.length;
|
|
77
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
78
|
+
if (/^\s*$/.test(lines[i])) continue;
|
|
79
|
+
if (lines[i].match(/^(\s*)/)[1].length <= 6) {
|
|
80
|
+
end = i;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return lines.slice(start, end).join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The dedented body of a step's `run: |` block scalar. */
|
|
88
|
+
function extractRunScript(step) {
|
|
89
|
+
const stepLines = step.split("\n");
|
|
90
|
+
const start = stepLines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
|
|
91
|
+
assert.notEqual(start, -1, "`run: |` block not found");
|
|
92
|
+
const runIndent = stepLines[start].match(/^(\s*)/)[1].length;
|
|
93
|
+
const body = [];
|
|
94
|
+
for (let i = start + 1; i < stepLines.length; i++) {
|
|
95
|
+
if (/^\s*$/.test(stepLines[i])) {
|
|
96
|
+
body.push("");
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (stepLines[i].match(/^(\s*)/)[1].length <= runIndent) break;
|
|
100
|
+
body.push(stepLines[i].slice(runIndent + 2));
|
|
101
|
+
}
|
|
102
|
+
return body.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every job in the workflow, as `{ id, stepRefs }` where `stepRefs` is the
|
|
107
|
+
* ordered list of anchor/alias names used as step entries (`- &x` / `- *x`).
|
|
108
|
+
* A plain `- name: …` step contributes nothing — only the aliased ones matter
|
|
109
|
+
* for the ordering invariant this suite pins.
|
|
110
|
+
*/
|
|
111
|
+
function extractJobs() {
|
|
112
|
+
const jobsStart = lines.findIndex((l) => l === "jobs:");
|
|
113
|
+
assert.notEqual(jobsStart, -1, "`jobs:` not found");
|
|
114
|
+
const jobs = [];
|
|
115
|
+
let current = null;
|
|
116
|
+
for (let i = jobsStart + 1; i < lines.length; i++) {
|
|
117
|
+
const jobHeader = lines[i].match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
|
|
118
|
+
if (jobHeader) {
|
|
119
|
+
current = { id: jobHeader[1], stepRefs: [] };
|
|
120
|
+
jobs.push(current);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const stepRef = lines[i].match(/^ {6}- [&*]([A-Za-z0-9_-]+)\s*$/);
|
|
124
|
+
if (stepRef && current) current.stepRefs.push(stepRef[1]);
|
|
125
|
+
}
|
|
126
|
+
assert.ok(jobs.length > 0, "no jobs parsed");
|
|
127
|
+
return jobs;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const explainStep = extractAnchoredStep(EXPLAIN);
|
|
131
|
+
const cancelStep = extractAnchoredStep(CANCEL);
|
|
132
|
+
const jobs = extractJobs();
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// 1. STRUCTURE — the two attribution surfaces exist, are correctly gated, and
|
|
136
|
+
// are wired into every tier job that participates in fail-fast.
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
test("the collateral explainer fires only on cancellation under fail-fast", () => {
|
|
140
|
+
assert.match(
|
|
141
|
+
explainStep,
|
|
142
|
+
/^\s+if:\s+\$\{\{\s*cancelled\(\)\s*&&\s*inputs\.fail-fast\s*\}\}\s*$/m,
|
|
143
|
+
"`&explain-cancellation` must be gated `cancelled() && inputs.fail-fast` — " +
|
|
144
|
+
"an `always()` gate would fire it on the green path"
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("the trigger-side record fires only on this tier's own failure", () => {
|
|
149
|
+
assert.match(
|
|
150
|
+
cancelStep,
|
|
151
|
+
/^\s+if:\s+\$\{\{\s*failure\(\)\s*&&\s*inputs\.fail-fast\s*\}\}\s*$/m,
|
|
152
|
+
"`&cancel-on-failure` must stay gated `failure() && inputs.fail-fast` — " +
|
|
153
|
+
"the two attribution gates must remain mutually exclusive"
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("the collateral explainer is time-bounded", () => {
|
|
158
|
+
// A cancelled job runs its remaining cancelled()/always() steps inside a
|
|
159
|
+
// bounded runner grace period. An unbounded API lookup here would compete
|
|
160
|
+
// with the tier's own artifact uploads for that budget.
|
|
161
|
+
assert.match(
|
|
162
|
+
explainStep,
|
|
163
|
+
/^\s+timeout-minutes:\s+1\s*$/m,
|
|
164
|
+
"`&explain-cancellation` must declare `timeout-minutes: 1`"
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("every fail-fast tier job carries both attribution steps", () => {
|
|
169
|
+
const participating = jobs.filter((j) => j.stepRefs.includes(CANCEL));
|
|
170
|
+
assert.ok(
|
|
171
|
+
participating.length >= 8,
|
|
172
|
+
`expected every tier job to alias \`${CANCEL}\`; found ${participating.length}`
|
|
173
|
+
);
|
|
174
|
+
for (const job of participating) {
|
|
175
|
+
assert.ok(
|
|
176
|
+
job.stepRefs.includes(EXPLAIN),
|
|
177
|
+
`job \`${job.id}\` aliases \`${CANCEL}\` but not \`${EXPLAIN}\` — a ` +
|
|
178
|
+
`cancelled ${job.id} would report a bare 'cancelled' with no upstream pointer`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("the explainer immediately precedes the cancel step, which stays last", () => {
|
|
184
|
+
for (const job of jobs.filter((j) => j.stepRefs.includes(CANCEL))) {
|
|
185
|
+
const cancelIdx = job.stepRefs.indexOf(CANCEL);
|
|
186
|
+
const explainIdx = job.stepRefs.indexOf(EXPLAIN);
|
|
187
|
+
assert.equal(
|
|
188
|
+
explainIdx,
|
|
189
|
+
cancelIdx - 1,
|
|
190
|
+
`job \`${job.id}\`: \`${EXPLAIN}\` must sit immediately before \`${CANCEL}\``
|
|
191
|
+
);
|
|
192
|
+
assert.equal(
|
|
193
|
+
cancelIdx,
|
|
194
|
+
job.stepRefs.length - 1,
|
|
195
|
+
`job \`${job.id}\`: \`${CANCEL}\` must remain the LAST step, so ` +
|
|
196
|
+
`\`if: always()\` uploads finish before the run-wide cancel signal lands`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// 2. PERMISSION SURFACE — unchanged. `gh run view` needs only `actions: read`,
|
|
203
|
+
// already covered by the workflow-level `actions: write`.
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Job-level permission blocks, as `{ job: [scope, …] }`. Three jobs already
|
|
208
|
+
* declare one on `main` (a job-level block REPLACES the workflow-level one, so
|
|
209
|
+
* each has to re-declare the fail-fast cancel grant). This suite pins that
|
|
210
|
+
* inventory rather than forbidding it outright: the invariant that matters is
|
|
211
|
+
* that fail-fast attribution introduced no NEW grant.
|
|
212
|
+
*/
|
|
213
|
+
function extractJobPermissions() {
|
|
214
|
+
const found = {};
|
|
215
|
+
let job = null;
|
|
216
|
+
for (let i = 0; i < lines.length; i++) {
|
|
217
|
+
const header = lines[i].match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
|
|
218
|
+
if (header) job = header[1];
|
|
219
|
+
if (!/^ {4}permissions:/.test(lines[i])) continue;
|
|
220
|
+
const scopes = [];
|
|
221
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
222
|
+
if (/^\s*#/.test(lines[j])) continue;
|
|
223
|
+
const scope = lines[j].match(/^ {6}([a-z-]+):\s*(\S+)\s*$/);
|
|
224
|
+
if (!scope) break;
|
|
225
|
+
scopes.push(`${scope[1]}: ${scope[2]}`);
|
|
226
|
+
}
|
|
227
|
+
found[job] = scopes;
|
|
228
|
+
}
|
|
229
|
+
return found;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
test("no job declares a permission beyond the pre-existing inventory", () => {
|
|
233
|
+
// A job-level `permissions:` block is validated against the CALLER's grant
|
|
234
|
+
// at compile time, ignoring the job's `if:` gate — a new or widened one here
|
|
235
|
+
// startup_failures EVERY consumer that has not widened its caller token,
|
|
236
|
+
// including consumers that never enable the tier. `gh run view` (the
|
|
237
|
+
// collateral explainer's culprit lookup) needs only `actions: read`, which
|
|
238
|
+
// the existing `actions: write` already covers, so nothing had to change.
|
|
239
|
+
assert.deepEqual(extractJobPermissions(), {
|
|
240
|
+
"migration-guard": ["contents: read", "actions: write", "pull-requests: read"],
|
|
241
|
+
security: ["contents: read", "actions: write"],
|
|
242
|
+
"osv-scan": ["contents: read", "actions: write"],
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("the workflow-level permission grant is unchanged", () => {
|
|
247
|
+
assert.match(
|
|
248
|
+
source,
|
|
249
|
+
/^permissions:\n {2}contents: read\n {2}actions: write\n/m,
|
|
250
|
+
"the attribution steps must not widen the declared permission surface"
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// 3. SEMANTICS — the two run scripts, executed under real bash against a
|
|
256
|
+
// stubbed `gh` on PATH.
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Execute a step's run script with a stubbed `gh` (and `curl`) shadowing any
|
|
261
|
+
* real binaries, so the scripts' branches are exercised without network I/O.
|
|
262
|
+
*/
|
|
263
|
+
function runStep(script, { ghExit = 0, ghStdout = "", env = {} } = {}) {
|
|
264
|
+
const dir = mkdtempSync(join(tmpdir(), "fail-fast-attr-"));
|
|
265
|
+
try {
|
|
266
|
+
const bin = join(dir, "bin");
|
|
267
|
+
mkdirSync(bin);
|
|
268
|
+
for (const cmd of ["gh", "curl"]) {
|
|
269
|
+
const stub = join(bin, cmd);
|
|
270
|
+
writeFileSync(
|
|
271
|
+
stub,
|
|
272
|
+
`#!/usr/bin/env bash\nprintf '%s' ${JSON.stringify(ghStdout)}\nexit ${ghExit}\n`
|
|
273
|
+
);
|
|
274
|
+
chmodSync(stub, 0o755);
|
|
275
|
+
}
|
|
276
|
+
const summary = join(dir, "summary.md");
|
|
277
|
+
writeFileSync(summary, "");
|
|
278
|
+
const file = join(dir, "step.sh");
|
|
279
|
+
writeFileSync(file, script);
|
|
280
|
+
const r = spawnSync("bash", [file], {
|
|
281
|
+
encoding: "utf8",
|
|
282
|
+
env: {
|
|
283
|
+
...process.env,
|
|
284
|
+
PATH: `${bin}:${process.env.PATH}`,
|
|
285
|
+
GITHUB_STEP_SUMMARY: summary,
|
|
286
|
+
GITHUB_API_URL: "https://api.github.invalid",
|
|
287
|
+
GH_TOKEN: "stub-token",
|
|
288
|
+
RUN_ID: "30168441137",
|
|
289
|
+
REPO: "Beestera/swarm-os",
|
|
290
|
+
TIER_ID: "typecheck",
|
|
291
|
+
...env,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
return { ...r, summary: readFileSync(summary, "utf8") };
|
|
295
|
+
} finally {
|
|
296
|
+
rmSync(dir, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const cancelScript = extractRunScript(cancelStep);
|
|
301
|
+
const explainScript = extractRunScript(explainStep);
|
|
302
|
+
|
|
303
|
+
test("trigger side: names the failing tier in a run-level annotation", () => {
|
|
304
|
+
const r = runStep(cancelScript, { env: { TIER_ID: "e2e" } });
|
|
305
|
+
assert.equal(r.status, 0, r.stderr);
|
|
306
|
+
assert.match(
|
|
307
|
+
r.stdout,
|
|
308
|
+
/::error title=fail-fast::/,
|
|
309
|
+
"the trigger must emit a run-level annotation, not just a log line"
|
|
310
|
+
);
|
|
311
|
+
assert.match(r.stdout, /e2e/, "the annotation must name the tier that failed");
|
|
312
|
+
assert.match(r.stdout, /collateral/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("trigger side: writes the attribution to the job summary", () => {
|
|
316
|
+
const r = runStep(cancelScript, { env: { TIER_ID: "e2e" } });
|
|
317
|
+
assert.match(r.summary, /fail-fast triggered by/);
|
|
318
|
+
assert.match(r.summary, /e2e/);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("trigger side: records the attribution even when the cancel call fails", () => {
|
|
322
|
+
// A caller whose token lacks `actions: write` cannot cancel — the record of
|
|
323
|
+
// WHICH tier failed must still land, which is why it is written first.
|
|
324
|
+
const r = runStep(cancelScript, { ghExit: 1, ghStdout: "000" });
|
|
325
|
+
assert.equal(r.status, 0, "the cancel step stays non-fatal");
|
|
326
|
+
assert.match(r.stdout, /::error title=fail-fast::/);
|
|
327
|
+
assert.match(r.summary, /fail-fast triggered by/);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("collateral side: names the real culprit when the lookup succeeds", () => {
|
|
331
|
+
const r = runStep(explainScript, { ghStdout: "Accessibility (2/3)" });
|
|
332
|
+
assert.equal(r.status, 0, r.stderr);
|
|
333
|
+
assert.match(r.stdout, /::notice title=fail-fast collateral::/);
|
|
334
|
+
assert.match(r.stdout, /Accessibility \(2\/3\)/);
|
|
335
|
+
assert.match(r.summary, /Accessibility \(2\/3\)/);
|
|
336
|
+
assert.match(r.summary, /did \*\*not\*\* fail/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("collateral side: degrades to a generic message when the lookup fails", () => {
|
|
340
|
+
const r = runStep(explainScript, { ghExit: 1 });
|
|
341
|
+
assert.equal(
|
|
342
|
+
r.status,
|
|
343
|
+
0,
|
|
344
|
+
"a failed culprit lookup must never fail the step — it is best-effort"
|
|
345
|
+
);
|
|
346
|
+
assert.match(r.stdout, /::notice title=fail-fast collateral::/);
|
|
347
|
+
assert.match(r.stdout, /could not be resolved/);
|
|
348
|
+
assert.match(r.summary, /could not be resolved/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("collateral side: still explains itself when gh is absent entirely", () => {
|
|
352
|
+
// Self-hosted runners are not guaranteed to ship the gh CLI.
|
|
353
|
+
const dir = mkdtempSync(join(tmpdir(), "fail-fast-nogh-"));
|
|
354
|
+
try {
|
|
355
|
+
const empty = join(dir, "bin");
|
|
356
|
+
mkdirSync(empty);
|
|
357
|
+
const summary = join(dir, "summary.md");
|
|
358
|
+
writeFileSync(summary, "");
|
|
359
|
+
const file = join(dir, "step.sh");
|
|
360
|
+
writeFileSync(file, explainScript);
|
|
361
|
+
// Resolve bash absolutely — PATH is deliberately emptied for the child so
|
|
362
|
+
// `command -v gh` finds nothing.
|
|
363
|
+
const r = spawnSync("/bin/bash", [file], {
|
|
364
|
+
encoding: "utf8",
|
|
365
|
+
env: {
|
|
366
|
+
PATH: empty,
|
|
367
|
+
GITHUB_STEP_SUMMARY: summary,
|
|
368
|
+
RUN_ID: "1",
|
|
369
|
+
REPO: "o/r",
|
|
370
|
+
TIER_ID: "lint",
|
|
371
|
+
GH_TOKEN: "stub-token",
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
assert.equal(r.status, 0, r.stderr);
|
|
375
|
+
assert.match(r.stdout, /lint/);
|
|
376
|
+
assert.match(readFileSync(summary, "utf8"), /could not be resolved/);
|
|
377
|
+
} finally {
|
|
378
|
+
rmSync(dir, { recursive: true, force: true });
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("collateral side: never claims this tier failed", () => {
|
|
383
|
+
const r = runStep(explainScript, { ghStdout: "Accessibility (2/3)" });
|
|
384
|
+
assert.doesNotMatch(
|
|
385
|
+
r.stdout,
|
|
386
|
+
/::error/,
|
|
387
|
+
"a collateral cancel must not emit a failure annotation — that is what " +
|
|
388
|
+
"made cancelled siblings indistinguishable from the real failure"
|
|
389
|
+
);
|
|
390
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* job-cleanup-hook.test.mjs — node:test suite for the ACTIONS_RUNNER_HOOK_JOB_STARTED
|
|
4
|
+
* hook shipped at `templates/runner/job-cleanup.sh` (Story #345).
|
|
5
|
+
*
|
|
6
|
+
* The hook runs INSIDE the job's clock on every job of every persistent
|
|
7
|
+
* self-hosted runner, so its cost is charged to `Set up runner` and a slow
|
|
8
|
+
* hook kills jobs against their own `timeout-minutes`. Issue #343 is exactly
|
|
9
|
+
* that failure: the hook enumerated the shared OS temp root, which had reached
|
|
10
|
+
* 841,690 entries on the swarm-os host, and `Set up runner` reached 5m29s.
|
|
11
|
+
*
|
|
12
|
+
* The load-bearing property this suite pins is therefore a NEGATIVE one — the
|
|
13
|
+
* hook must never read the shared temp root — and it is asserted two ways,
|
|
14
|
+
* because neither alone is sufficient:
|
|
15
|
+
*
|
|
16
|
+
* 1. Behaviourally: a decoy shared temp root is planted with entries that
|
|
17
|
+
* match the old sweep's patterns, and the hook must leave every one of
|
|
18
|
+
* them alone. This catches a sweep that still deletes there.
|
|
19
|
+
* 2. Structurally: the script text must contain no enumeration rooted at the
|
|
20
|
+
* shared temp root. This catches a sweep that reads the directory but
|
|
21
|
+
* happens to delete nothing — the exact shape of the #343 stall, which
|
|
22
|
+
* was pure cost with no observable effect.
|
|
23
|
+
*
|
|
24
|
+
* A wall-clock assertion was deliberately NOT used for (2): the cost is a
|
|
25
|
+
* function of host churn, so on a clean dev machine a full enumeration of a
|
|
26
|
+
* small decoy root is fast and would pass. Timing here would be a test that
|
|
27
|
+
* only fails on the machine that least needs it.
|
|
28
|
+
*
|
|
29
|
+
* The suite executes the real script with env fixtures, following the
|
|
30
|
+
* `scripts/resolve-diff-range.test.mjs` precedent for shell-under-test.
|
|
31
|
+
*
|
|
32
|
+
* Run: node --test scripts/job-cleanup-hook.test.mjs
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import assert from "node:assert/strict";
|
|
36
|
+
import { execFileSync } from "node:child_process";
|
|
37
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, chmodSync, existsSync } from "node:fs";
|
|
38
|
+
import { tmpdir } from "node:os";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
import { dirname, join } from "node:path";
|
|
41
|
+
import { test, after } from "node:test";
|
|
42
|
+
|
|
43
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
const SCRIPT = join(HERE, "..", "templates", "runner", "job-cleanup.sh");
|
|
45
|
+
|
|
46
|
+
/** Sandboxes created by the suite, torn down in `after`. */
|
|
47
|
+
const SANDBOXES = [];
|
|
48
|
+
|
|
49
|
+
after(() => {
|
|
50
|
+
for (const dir of SANDBOXES) {
|
|
51
|
+
// Restore any permissions the unwritable-temp case removed, or the
|
|
52
|
+
// recursive delete cannot descend.
|
|
53
|
+
try {
|
|
54
|
+
chmodSync(join(dir, "runner", "_work", "_temp"), 0o755);
|
|
55
|
+
} catch {
|
|
56
|
+
/* not every sandbox has one */
|
|
57
|
+
}
|
|
58
|
+
rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a sandbox holding a synthetic runner root and a decoy shared temp root.
|
|
64
|
+
*
|
|
65
|
+
* @param {{ sharedEntries?: number }} [opts]
|
|
66
|
+
* @returns {{ root: string, runnerDir: string, runnerTmp: string, sharedTmp: string }}
|
|
67
|
+
*/
|
|
68
|
+
function makeSandbox({ sharedEntries = 0 } = {}) {
|
|
69
|
+
const root = mkdtempSync(join(tmpdir(), "job-cleanup-test-"));
|
|
70
|
+
SANDBOXES.push(root);
|
|
71
|
+
|
|
72
|
+
const runnerDir = join(root, "runner");
|
|
73
|
+
const runnerTmp = join(runnerDir, "_work", "_temp");
|
|
74
|
+
const sharedTmp = join(root, "shared-tmp");
|
|
75
|
+
mkdirSync(runnerTmp, { recursive: true });
|
|
76
|
+
mkdirSync(join(runnerDir, "_work", "_tool"), { recursive: true });
|
|
77
|
+
mkdirSync(sharedTmp, { recursive: true });
|
|
78
|
+
|
|
79
|
+
// The decoy shared root carries entries matching BOTH the retired sweep's
|
|
80
|
+
// patterns and the current runner-scoped ones, so a sweep that kept the old
|
|
81
|
+
// root or reused the new globs against it is caught either way.
|
|
82
|
+
writeFileSync(join(sharedTmp, "gitleaks.tmp"), "decoy");
|
|
83
|
+
mkdirSync(join(sharedTmp, "gitleaks-8.30.1"), { recursive: true });
|
|
84
|
+
mkdirSync(join(sharedTmp, "gitleaks.AbCdEf"), { recursive: true });
|
|
85
|
+
mkdirSync(join(sharedTmp, "osv-scanner.AbCdEf"), { recursive: true });
|
|
86
|
+
for (let i = 0; i < sharedEntries; i += 1) {
|
|
87
|
+
writeFileSync(join(sharedTmp, `unrelated-${i}.tmp`), "x");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { root, runnerDir, runnerTmp, sharedTmp };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run the hook against a sandbox.
|
|
95
|
+
*
|
|
96
|
+
* @param {{ runnerDir: string, sharedTmp: string }} sandbox
|
|
97
|
+
* @returns {{ status: number, stdout: string }}
|
|
98
|
+
*/
|
|
99
|
+
function runHook({ runnerDir, sharedTmp }) {
|
|
100
|
+
try {
|
|
101
|
+
const stdout = execFileSync("bash", [SCRIPT], {
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
env: { ...process.env, RUNNER_DIR: runnerDir, TMPDIR: sharedTmp },
|
|
104
|
+
timeout: 60_000,
|
|
105
|
+
});
|
|
106
|
+
return { status: 0, stdout };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { status: err.status ?? 1, stdout: err.stdout ?? "" };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test("removes this runner's own stale tool-download and pnpm-shim leftovers", () => {
|
|
113
|
+
const sandbox = makeSandbox();
|
|
114
|
+
const { runnerTmp } = sandbox;
|
|
115
|
+
|
|
116
|
+
// Every artifact the platform's actions now extract into runner.temp.
|
|
117
|
+
const owned = [
|
|
118
|
+
join(runnerTmp, "pnpm"),
|
|
119
|
+
join(runnerTmp, "setup-pnpm"),
|
|
120
|
+
join(runnerTmp, "gitleaks.AbCdEf"),
|
|
121
|
+
join(runnerTmp, "osv-scanner.AbCdEf"),
|
|
122
|
+
join(runnerTmp, "semgrep.AbCdEf"),
|
|
123
|
+
];
|
|
124
|
+
for (const dir of owned) {
|
|
125
|
+
mkdirSync(dir, { recursive: true });
|
|
126
|
+
writeFileSync(join(dir, "leftover"), "stale");
|
|
127
|
+
}
|
|
128
|
+
writeFileSync(join(runnerTmp, "gh-api-err.AbCdEf"), "stale");
|
|
129
|
+
|
|
130
|
+
const { status } = runHook(sandbox);
|
|
131
|
+
assert.equal(status, 0, "the hook must never fail a job");
|
|
132
|
+
|
|
133
|
+
for (const dir of owned) {
|
|
134
|
+
assert.equal(existsSync(dir), false, `runner-owned leftover not swept: ${dir}`);
|
|
135
|
+
}
|
|
136
|
+
assert.equal(existsSync(join(runnerTmp, "gh-api-err.AbCdEf")), false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("leaves the shared temp root untouched", () => {
|
|
140
|
+
// The decoy is deliberately SMALL. An earlier draft planted 5,000 entries to
|
|
141
|
+
// evoke the 841,690 seen on the swarm-os host, but that bought no signal: the
|
|
142
|
+
// assertion below is on effect (nothing deleted), which is count-independent,
|
|
143
|
+
// and the companion structural test — not a stopwatch — is what pins the
|
|
144
|
+
// cost property. Planting alone cost ~87s on a dev Mac whose endpoint
|
|
145
|
+
// security scans every write (~17ms/file), which would have made this the
|
|
146
|
+
// slowest test in the suite by two orders of magnitude, and a flaky one.
|
|
147
|
+
//
|
|
148
|
+
// What actually matters is pattern COVERAGE, and makeSandbox plants every
|
|
149
|
+
// shape — both the retired sweep's names and the current runner-scoped
|
|
150
|
+
// globs — so a sweep pointed back at the shared root is caught by the first
|
|
151
|
+
// entry it would match.
|
|
152
|
+
const sandbox = makeSandbox({ sharedEntries: 20 });
|
|
153
|
+
const { sharedTmp } = sandbox;
|
|
154
|
+
|
|
155
|
+
const before = readdirSync(sharedTmp).sort();
|
|
156
|
+
const { status } = runHook(sandbox);
|
|
157
|
+
|
|
158
|
+
assert.equal(status, 0);
|
|
159
|
+
assert.deepEqual(
|
|
160
|
+
readdirSync(sharedTmp).sort(),
|
|
161
|
+
before,
|
|
162
|
+
"the hook deleted from the shared temp root — it must only sweep runner-owned paths",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("the script text contains no enumeration rooted at the shared temp root", () => {
|
|
167
|
+
const source = readFileSync(SCRIPT, "utf8");
|
|
168
|
+
|
|
169
|
+
// Assert the PROPERTY (the hook cannot reach the shared root at all), not
|
|
170
|
+
// one syntactic form of violating it. Pinning a specific `find "${TMP…"`
|
|
171
|
+
// shape would miss an unbraced `$TMPDIR`, an `ls | grep`, a `for f in
|
|
172
|
+
// "${TMP}"/…` loop, or a renamed intermediate variable — all of which
|
|
173
|
+
// reintroduce the unbounded read this Story removed.
|
|
174
|
+
//
|
|
175
|
+
// Every one of those must name the shared root to reach it, and the hook has
|
|
176
|
+
// no legitimate use for it: RUNNER_TMP derives from RUNNER_DIR. So the
|
|
177
|
+
// absence of the name is both necessary and sufficient, and it stays a true
|
|
178
|
+
// invariant rather than a regex chasing syntax.
|
|
179
|
+
//
|
|
180
|
+
// Comments are stripped first: the header documents the #343 incident by
|
|
181
|
+
// name, and that prose is the reason the next maintainer will not re-add the
|
|
182
|
+
// sweep. Asserting over raw text would force the fix to delete its own
|
|
183
|
+
// rationale.
|
|
184
|
+
const code = source
|
|
185
|
+
.split("\n")
|
|
186
|
+
.filter((line) => !/^\s*#/.test(line))
|
|
187
|
+
.join("\n");
|
|
188
|
+
|
|
189
|
+
assert.equal(
|
|
190
|
+
/TMPDIR/.test(code),
|
|
191
|
+
false,
|
|
192
|
+
"the hook references the shared temp root — its cost must scale with runner-owned state only",
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// Second half of the property: no directory enumeration, anywhere. Naming
|
|
196
|
+
// the shared root is one way to reintroduce unbounded cost; a `find` rooted
|
|
197
|
+
// at an intermediate variable is another, and it would not have to mention
|
|
198
|
+
// TMPDIR on the same line. The hook has no legitimate need to enumerate —
|
|
199
|
+
// it addresses known paths under RUNNER_TMP directly — so the absence of
|
|
200
|
+
// `find` is a stronger and more durable invariant than any pattern match
|
|
201
|
+
// over its arguments.
|
|
202
|
+
assert.equal(
|
|
203
|
+
/\bfind\b/.test(code),
|
|
204
|
+
false,
|
|
205
|
+
"the hook enumerates a directory — address known runner-owned paths directly instead",
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// The age gate existed solely to make deleting from the SHARED root safe.
|
|
209
|
+
// Runner-scoped paths are unreachable by a co-resident runner, so a
|
|
210
|
+
// surviving knob would be dead configuration the runbook still promises.
|
|
211
|
+
assert.equal(
|
|
212
|
+
source.includes("JOB_CLEANUP_STALE_MINUTES"),
|
|
213
|
+
false,
|
|
214
|
+
"the retired age-gate knob is still referenced",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("exits 0 when the runner directory does not exist", () => {
|
|
219
|
+
const sandbox = makeSandbox();
|
|
220
|
+
const { status } = runHook({
|
|
221
|
+
runnerDir: join(sandbox.root, "no-such-runner"),
|
|
222
|
+
sharedTmp: sandbox.sharedTmp,
|
|
223
|
+
});
|
|
224
|
+
assert.equal(status, 0, "a missing runner root must degrade to a no-op, never fail the job");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("exits 0 when the runner temp is unwritable", () => {
|
|
228
|
+
const sandbox = makeSandbox();
|
|
229
|
+
mkdirSync(join(sandbox.runnerTmp, "gitleaks.AbCdEf"), { recursive: true });
|
|
230
|
+
chmodSync(sandbox.runnerTmp, 0o500);
|
|
231
|
+
|
|
232
|
+
const { status } = runHook(sandbox);
|
|
233
|
+
assert.equal(status, 0, "an unwritable runner temp must not fail the job");
|
|
234
|
+
});
|