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.
@@ -0,0 +1,986 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-cancelled-provenance.test.mjs — regression guard for provenance-aware
4
+ * handling of a cancelled tier in the `ci-required` aggregator (Story #333).
5
+ *
6
+ * WHY THIS EXISTS
7
+ * ---------------
8
+ * The aggregator failed the required gate on ANY `needs.*.result == 'cancelled'`
9
+ * with no statement of cause, so three very different situations were
10
+ * indistinguishable at the gate:
11
+ *
12
+ * - a sibling tier genuinely failed and fail-fast cancelled the rest;
13
+ * - a self-hosted runner's provisioning hook hung and the job was cancelled
14
+ * having never executed a step (Beestera/swarm-os#928: a job sat ~6min with
15
+ * its `Checkout` step SKIPPED, then died — no test signal at all);
16
+ * - a newer push superseded the run via the concurrency groups.
17
+ *
18
+ * There is no cancellation-reason field to read back — verified 2026-07-25,
19
+ * `gh run view --json` exposes only attempt/conclusion/createdAt/databaseId/
20
+ * displayTitle/event/headBranch/headSha/jobs/name/number/startedAt/status/
21
+ * updatedAt/url/workflowDatabaseId/workflowName, and
22
+ * `GET /repos/{owner}/{repo}/actions/runs/{id}` carries none either. Provenance
23
+ * is therefore INFERRED from observable run state, and this suite pins both the
24
+ * inference and the gate policy it feeds.
25
+ *
26
+ * The load-bearing negative: classification can never turn a red gate green.
27
+ * Every lookup failure yields `unknown`, and `unknown` neutralizes nothing —
28
+ * so the worst case is exactly today's behaviour. A `never-started` (infra)
29
+ * cancel is likewise never neutralized under any policy: that tier produced no
30
+ * signal, and passing on a tier that never ran is a vacuous pass.
31
+ *
32
+ * TIMED-OUT + TIMEOUT HEADROOM (Story #342)
33
+ * -----------------------------------------
34
+ * A fifth cause was missing: GitHub killing a job that exceeded its own
35
+ * `timeout-minutes`. It reported as `stopped-mid-step`, or as `never-started`
36
+ * when the kill landed before a step completed — both of which point triage at
37
+ * the runner fleet when the fix is a number in the workflow. swarm-os run
38
+ * 30179418666 cost a full forensic misdiagnosis to exactly that.
39
+ *
40
+ * The ceilings themselves were also unreachable by callers, which is what made
41
+ * the class necessary: GitHub charges the pre-job `Set up runner` wait against
42
+ * the same clock, so on a saturated self-hosted pool a hardcoded 5-minute
43
+ * aggregator budget passes by luck. This suite therefore pins BOTH halves —
44
+ * the `tier-timeouts` override surface and the `timed-out` inference —
45
+ * plus the negative that makes them shippable: pr-quality.yml declares no new
46
+ * permission scope, because a reusable workflow's permissions are validated
47
+ * against the caller's grant at compile time and a new scope breaks every
48
+ * consumer (Story #292's `pull-requests: read` is the precedent).
49
+ *
50
+ * Run: node --test scripts/check-cancelled-provenance.test.mjs
51
+ */
52
+
53
+ import assert from "node:assert/strict";
54
+ import { test } from "node:test";
55
+ import {
56
+ readFileSync,
57
+ mkdtempSync,
58
+ mkdirSync,
59
+ writeFileSync,
60
+ chmodSync,
61
+ rmSync,
62
+ } from "node:fs";
63
+ import { execFileSync, spawnSync } from "node:child_process";
64
+ import { join, resolve, dirname } from "node:path";
65
+ import { fileURLToPath } from "node:url";
66
+ import { tmpdir } from "node:os";
67
+
68
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
69
+ const WORKFLOW = ".github/workflows/pr-quality.yml";
70
+ const RUN_ID = "30179418666";
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Extract the aggregator's run script (same approach as the sibling suite).
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function extractRunScript() {
77
+ const lines = readFileSync(join(repoRoot, WORKFLOW), "utf8").split("\n");
78
+ const start = lines.findIndex((l) => l === " ci-required:");
79
+ assert.notEqual(start, -1, "`ci-required` job not found");
80
+ const runIdx = lines.findIndex(
81
+ (l, i) => i > start && /^\s+run:\s*\|\s*$/.test(l)
82
+ );
83
+ assert.notEqual(runIdx, -1, "`run: |` block not found");
84
+ const runIndent = lines[runIdx].match(/^(\s*)/)[1].length;
85
+ const body = [];
86
+ for (let i = runIdx + 1; i < lines.length; i++) {
87
+ if (/^\s*$/.test(lines[i])) {
88
+ body.push("");
89
+ continue;
90
+ }
91
+ if (lines[i].match(/^(\s*)/)[1].length <= runIndent) break;
92
+ body.push(lines[i].slice(runIndent + 2));
93
+ }
94
+ return body.join("\n");
95
+ }
96
+
97
+ const script = extractRunScript();
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Actions-API fixtures. `steps[].conclusion` is what separates a job that ran
101
+ // from one that never started.
102
+ // ---------------------------------------------------------------------------
103
+
104
+ const ranSteps = [
105
+ { name: "Set up job", conclusion: "success" },
106
+ { name: "Checkout", conclusion: "success" },
107
+ { name: "Typecheck", conclusion: "success" },
108
+ ];
109
+
110
+ /** The swarm-os#928 shape: queued, then cancelled, having run nothing. */
111
+ const neverStartedSteps = [
112
+ { name: "Set up job", conclusion: "skipped" },
113
+ { name: "Checkout", conclusion: "skipped" },
114
+ ];
115
+
116
+ function runFixture(jobs, { createdAt = "2026-07-25T10:00:00Z" } = {}) {
117
+ return {
118
+ jobs,
119
+ workflowDatabaseId: 4242,
120
+ headBranch: "story-333",
121
+ createdAt,
122
+ };
123
+ }
124
+
125
+ // pr-quality.yml's own base budgets — what the workflow passes as
126
+ // TIER_TIMEOUT_BASES. The caller's override map (TIER_TIMEOUT_OVERRIDES) is
127
+ // unioned in by the script, so tests that care about an override state it.
128
+ const CEILINGS = "[5, 10, 15, 20, 45]";
129
+
130
+ /**
131
+ * A job with a wall duration, in seconds. Duration is the ONLY signal that
132
+ * separates a timeout from any other cancel, so every timed-out fixture is
133
+ * built by stating it explicitly rather than by hand-writing timestamps.
134
+ */
135
+ function timedJob(name, conclusion, steps, durationSeconds) {
136
+ const startedAt = "2026-07-25T10:00:00Z";
137
+ // Whole-second ISO-8601 with no fractional part — the exact shape
138
+ // `gh run view --json jobs` emits for startedAt/completedAt. jq's
139
+ // `fromdateiso8601` rejects a `.000` fraction, so a fixture carrying one
140
+ // would exercise the degradation path instead of the classifier.
141
+ const completedAt = new Date(Date.parse(startedAt) + durationSeconds * 1000)
142
+ .toISOString()
143
+ .replace(/\.\d+Z$/, "Z");
144
+ return { name, conclusion, steps, startedAt, completedAt };
145
+ }
146
+
147
+ const FIXTURES = {
148
+ // A sibling genuinely failed; the rest are fail-fast collateral.
149
+ failFast: runFixture([
150
+ { name: "Accessibility (2/3)", conclusion: "failure", steps: ranSteps },
151
+ { name: "Typecheck", conclusion: "cancelled", steps: ranSteps },
152
+ { name: "Lint & format", conclusion: "cancelled", steps: ranSteps },
153
+ ]),
154
+ // Nothing failed; one job never executed a step. Infra hang.
155
+ neverStarted: runFixture([
156
+ { name: "Unit (1/2)", conclusion: "success", steps: ranSteps },
157
+ { name: "Detect web-*", conclusion: "cancelled", steps: neverStartedSteps },
158
+ ]),
159
+ // Nothing failed, every cancelled job had started — a concurrency cancel.
160
+ superseded: runFixture([
161
+ { name: "Unit (1/2)", conclusion: "success", steps: ranSteps },
162
+ { name: "E2E / Smoke (1/1)", conclusion: "cancelled", steps: ranSteps },
163
+ ]),
164
+ // Same shape, but the cancelled job is the ONLY non-skipped one — nothing
165
+ // actually passed, so neutralizing would be a vacuous pass.
166
+ supersededNoPass: runFixture([
167
+ { name: "E2E / Smoke (1/1)", conclusion: "cancelled", steps: ranSteps },
168
+ ]),
169
+ };
170
+
171
+ const NEWER_RUNS = [
172
+ { databaseId: 30179418666, createdAt: "2026-07-25T10:00:00Z" },
173
+ { databaseId: 30179999999, createdAt: "2026-07-25T10:31:00Z" },
174
+ ];
175
+ const NO_NEWER_RUNS = [
176
+ { databaseId: 30179418666, createdAt: "2026-07-25T10:00:00Z" },
177
+ ];
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Harness: run the extracted script with a stubbed `gh` that answers
181
+ // `run view` and `run list` from the fixtures above.
182
+ // ---------------------------------------------------------------------------
183
+
184
+ function jqAvailable() {
185
+ try {
186
+ execFileSync("jq", ["--version"], { stdio: "ignore" });
187
+ return true;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ const semantics = {
194
+ skip: jqAvailable() ? false : "jq not available on this host",
195
+ };
196
+
197
+ function runAggregator(
198
+ needsResults,
199
+ {
200
+ policy = "strict",
201
+ runJson = null,
202
+ runList = null,
203
+ ghFails = false,
204
+ ceilings = CEILINGS,
205
+ overrides = "{}",
206
+ } = {}
207
+ ) {
208
+ const dir = mkdtempSync(join(tmpdir(), "cancelled-provenance-"));
209
+ try {
210
+ const bin = join(dir, "bin");
211
+ mkdirSync(bin);
212
+
213
+ // The stub dispatches on `gh run view` vs `gh run list`, so a test can
214
+ // pin the classification inputs without any network access.
215
+ const viewFile = join(dir, "view.json");
216
+ const listFile = join(dir, "list.json");
217
+ writeFileSync(viewFile, runJson ? JSON.stringify(runJson) : "");
218
+ writeFileSync(listFile, runList ? JSON.stringify(runList) : "");
219
+ writeFileSync(
220
+ join(bin, "gh"),
221
+ ghFails
222
+ ? "#!/usr/bin/env bash\nexit 1\n"
223
+ : [
224
+ "#!/usr/bin/env bash",
225
+ 'if [ "$1" = "run" ] && [ "$2" = "view" ]; then',
226
+ ` cat ${JSON.stringify(viewFile)}`,
227
+ ' exit 0',
228
+ 'fi',
229
+ 'if [ "$1" = "run" ] && [ "$2" = "list" ]; then',
230
+ ` cat ${JSON.stringify(listFile)}`,
231
+ ' exit 0',
232
+ 'fi',
233
+ "exit 1",
234
+ "",
235
+ ].join("\n")
236
+ );
237
+ chmodSync(join(bin, "gh"), 0o755);
238
+
239
+ const summary = join(dir, "summary.md");
240
+ writeFileSync(summary, "");
241
+ const file = join(dir, "aggregate.sh");
242
+ writeFileSync(file, script);
243
+
244
+ const needsJson = Object.fromEntries(
245
+ Object.entries(needsResults).map(([k, result]) => [
246
+ k,
247
+ { result, outputs: {} },
248
+ ])
249
+ );
250
+
251
+ const r = spawnSync("bash", [file], {
252
+ encoding: "utf8",
253
+ env: {
254
+ ...process.env,
255
+ PATH: `${bin}:${process.env.PATH}`,
256
+ NEEDS_JSON: JSON.stringify(needsJson),
257
+ CANCELLED_POLICY: policy,
258
+ TIER_TIMEOUT_BASES: ceilings,
259
+ TIER_TIMEOUT_OVERRIDES: overrides,
260
+ GH_TOKEN: "stub-token",
261
+ RUN_ID,
262
+ REPO: "Beestera/swarm-os",
263
+ GITHUB_STEP_SUMMARY: summary,
264
+ },
265
+ });
266
+ return { ...r, summary: readFileSync(summary, "utf8") };
267
+ } finally {
268
+ rmSync(dir, { recursive: true, force: true });
269
+ }
270
+ }
271
+
272
+ // A cancelled tier alongside a passing one — the shape every test below uses
273
+ // unless it needs something different.
274
+ const ONE_CANCELLED = { unit: "success", e2e: "cancelled" };
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // 1. CLASSIFICATION — each provenance class is inferred from run state.
278
+ // ---------------------------------------------------------------------------
279
+
280
+ test("classifies a run with a failed sibling as fail-fast", semantics, () => {
281
+ const r = runAggregator(ONE_CANCELLED, { runJson: FIXTURES.failFast });
282
+ assert.equal(r.status, 1);
283
+ assert.match(r.stderr, /Cancelled provenance: fail-fast/);
284
+ });
285
+
286
+ test(
287
+ "classifies a job that executed no step as never-started (infra)",
288
+ semantics,
289
+ () => {
290
+ const r = runAggregator(ONE_CANCELLED, { runJson: FIXTURES.neverStarted });
291
+ assert.equal(r.status, 1);
292
+ assert.match(r.stderr, /Cancelled provenance: never-started/);
293
+ // The swarm-os#928 job is named, so a triager sees WHICH job hung.
294
+ assert.match(r.stderr, /Detect web-\*: never-started/);
295
+ }
296
+ );
297
+
298
+ test("classifies a run with a newer sibling run as superseded", semantics, () => {
299
+ const r = runAggregator(ONE_CANCELLED, {
300
+ runJson: FIXTURES.superseded,
301
+ runList: NEWER_RUNS,
302
+ });
303
+ assert.match(r.stderr, /Cancelled provenance: superseded/);
304
+ });
305
+
306
+ test("stays unknown when no newer run exists", semantics, () => {
307
+ const r = runAggregator(ONE_CANCELLED, {
308
+ runJson: FIXTURES.superseded,
309
+ runList: NO_NEWER_RUNS,
310
+ });
311
+ assert.equal(r.status, 1);
312
+ assert.match(r.stderr, /Cancelled provenance: unknown/);
313
+ });
314
+
315
+ test("a failed sibling outranks a never-started job", semantics, () => {
316
+ // Precedence matters: a job cancelled while still queued has executed no
317
+ // step either, so checking `never-started` first would report an infra hang
318
+ // for every fail-fast run.
319
+ const r = runAggregator(ONE_CANCELLED, {
320
+ runJson: runFixture([
321
+ { name: "Accessibility (2/3)", conclusion: "failure", steps: ranSteps },
322
+ { name: "E2E / Smoke (1/1)", conclusion: "cancelled", steps: neverStartedSteps },
323
+ ]),
324
+ });
325
+ assert.match(r.stderr, /Cancelled provenance: fail-fast/);
326
+ assert.doesNotMatch(r.stderr, /provenance: never-started/);
327
+ });
328
+
329
+ test(
330
+ "classifies a job killed at its ceiling as timed-out, not stopped-mid-step",
331
+ semantics,
332
+ () => {
333
+ // It ran steps, so the old classifier called this `stopped-mid-step` —
334
+ // "something external stopped a running job", which is not what happened.
335
+ const r = runAggregator(ONE_CANCELLED, {
336
+ runJson: runFixture([
337
+ timedJob("Unit (1/2)", "success", ranSteps, 90),
338
+ timedJob("E2E / Smoke (1/1)", "cancelled", ranSteps, 45 * 60),
339
+ ]),
340
+ });
341
+ assert.equal(r.status, 1);
342
+ assert.match(r.stderr, /Cancelled provenance: timed-out/);
343
+ // The ceiling is named, so the operator knows WHICH number to raise.
344
+ assert.match(r.stderr, /E2E \/ Smoke \(1\/1\): timed-out \(hit its 45m ceiling\)/);
345
+ }
346
+ );
347
+
348
+ test(
349
+ "classifies a job killed at its ceiling before any step as timed-out, not never-started",
350
+ semantics,
351
+ () => {
352
+ // The swarm-os run 30179418666 shape: `Set up runner` ate the whole budget,
353
+ // so the job was killed with every step still SKIPPED. Reporting that as
354
+ // `never-started` asserts an infra provisioning hang and is what sent the
355
+ // consumer to file a runner investigation for a config fault.
356
+ const r = runAggregator(ONE_CANCELLED, {
357
+ runJson: runFixture([
358
+ timedJob("Unit (1/2)", "success", ranSteps, 90),
359
+ timedJob("Typecheck", "cancelled", neverStartedSteps, 15 * 60),
360
+ ]),
361
+ });
362
+ assert.equal(r.status, 1);
363
+ assert.match(r.stderr, /Cancelled provenance: timed-out/);
364
+ assert.match(r.stderr, /Typecheck: timed-out \(hit its 15m ceiling\)/);
365
+ assert.doesNotMatch(r.stderr, /provenance: never-started/);
366
+ }
367
+ );
368
+
369
+ test("a failed sibling outranks a timed-out job", semantics, () => {
370
+ // A fail-fast cancel can race a ceiling. The failing sibling is still the
371
+ // thing to triage, so `fail-fast` stays first in the precedence order.
372
+ const r = runAggregator(ONE_CANCELLED, {
373
+ runJson: runFixture([
374
+ timedJob("Accessibility (2/3)", "failure", ranSteps, 120),
375
+ timedJob("E2E / Smoke (1/1)", "cancelled", ranSteps, 45 * 60),
376
+ ]),
377
+ });
378
+ assert.match(r.stderr, /Cancelled provenance: fail-fast/);
379
+ assert.doesNotMatch(r.stderr, /provenance: timed-out/);
380
+ });
381
+
382
+ test(
383
+ "a cancel far from every ceiling keeps its existing classification",
384
+ semantics,
385
+ () => {
386
+ // Positive evidence only: a duration that matches no ceiling must not be
387
+ // relabelled. 30 minutes sits between the 20m and 45m tiers.
388
+ const r = runAggregator(ONE_CANCELLED, {
389
+ runJson: runFixture([
390
+ timedJob("Unit (1/2)", "success", ranSteps, 90),
391
+ timedJob("E2E / Smoke (1/1)", "cancelled", ranSteps, 30 * 60),
392
+ ]),
393
+ runList: NO_NEWER_RUNS,
394
+ });
395
+ assert.equal(r.status, 1);
396
+ assert.match(r.stderr, /E2E \/ Smoke \(1\/1\): stopped-mid-step/);
397
+ assert.doesNotMatch(r.stderr, /provenance: timed-out/);
398
+ }
399
+ );
400
+
401
+ test(
402
+ "a cancel just UNDER a ceiling is not claimed as a timeout",
403
+ semantics,
404
+ () => {
405
+ // GitHub never kills a job early, so a duration below the ceiling is
406
+ // positive evidence AGAINST a timeout — and the seconds just under one are
407
+ // exactly where an ordinary fail-fast or superseded cancel of a
408
+ // long-running job lands. Matching there would be a confident wrong answer;
409
+ // the classifier must prefer a missing label to a false one.
410
+ const r = runAggregator(ONE_CANCELLED, {
411
+ runJson: runFixture([
412
+ timedJob("Unit (1/2)", "success", ranSteps, 90),
413
+ timedJob("E2E / Smoke (1/1)", "cancelled", ranSteps, 45 * 60 - 20),
414
+ ]),
415
+ runList: NO_NEWER_RUNS,
416
+ });
417
+ assert.equal(r.status, 1);
418
+ assert.match(r.stderr, /E2E \/ Smoke \(1\/1\): stopped-mid-step/);
419
+ assert.doesNotMatch(r.stderr, /provenance: timed-out/);
420
+ }
421
+ );
422
+
423
+ test(
424
+ "a caller's override value is detectable as a ceiling",
425
+ semantics,
426
+ () => {
427
+ // The whole point of the override map: a caller that raises `e2e` to 57m
428
+ // must still get `timed-out` when that tier is killed at 57m. If only the
429
+ // platform's base list were consulted, every override would silently make
430
+ // its own tier undetectable — the failure mode would arrive exactly for the
431
+ // fleets that needed the input in the first place.
432
+ const jobs = [
433
+ timedJob("Unit (1/2)", "success", ranSteps, 90),
434
+ timedJob("E2E / Smoke (1/1)", "cancelled", ranSteps, 57 * 60),
435
+ ];
436
+ const overridden = runAggregator(ONE_CANCELLED, {
437
+ overrides: '{"e2e": 57}',
438
+ runJson: runFixture(jobs),
439
+ runList: NO_NEWER_RUNS,
440
+ });
441
+ assert.equal(overridden.status, 1);
442
+ assert.match(overridden.stderr, /Cancelled provenance: timed-out/);
443
+ assert.match(overridden.stderr, /E2E \/ Smoke \(1\/1\): timed-out \(hit its 57m ceiling\)/);
444
+
445
+ // Same run without the override — 57m matches no base budget, so the label
446
+ // is not claimed. This is what proves the override is what carried it.
447
+ const plain = runAggregator(ONE_CANCELLED, {
448
+ runJson: runFixture(jobs),
449
+ runList: NO_NEWER_RUNS,
450
+ });
451
+ assert.match(plain.stderr, /E2E \/ Smoke \(1\/1\): stopped-mid-step/);
452
+ }
453
+ );
454
+
455
+ test(
456
+ "a ceiling the set omits under-detects rather than mis-detects",
457
+ semantics,
458
+ () => {
459
+ // The set is per-WORKFLOW, so a run can contain a job governed by a
460
+ // ceiling the set does not list — most concretely ci.yml, whose `[5, 10]`
461
+ // omits the nested pr-quality tiers' 15/20/45m budgets. The direction of
462
+ // that gap is the load-bearing part: a job killed at an unlisted ceiling
463
+ // keeps its previous label (a lost `timed-out`, still red), never a
464
+ // confident wrong one. Both halves are asserted here, since only the pair
465
+ // rules out the mirror-image bug where a narrow set relabels everything.
466
+ const jobs = [
467
+ timedJob("Node-script checks", "success", ranSteps, 90),
468
+ timedJob("security / E2E / Smoke (1/1)", "cancelled", ranSteps, 45 * 60),
469
+ ];
470
+ const narrow = runAggregator(ONE_CANCELLED, {
471
+ ceilings: "[5, 10]",
472
+ runJson: runFixture(jobs),
473
+ runList: NO_NEWER_RUNS,
474
+ });
475
+ assert.equal(narrow.status, 1);
476
+ assert.match(narrow.stderr, /security \/ E2E \/ Smoke \(1\/1\): stopped-mid-step/);
477
+ assert.doesNotMatch(narrow.stderr, /provenance: timed-out/);
478
+
479
+ // Same job, same duration, against a set that DOES list 45 — proving the
480
+ // assertion above turns on the omission and not on the fixture.
481
+ const wide = runAggregator(ONE_CANCELLED, {
482
+ runJson: runFixture(jobs),
483
+ runList: NO_NEWER_RUNS,
484
+ });
485
+ assert.equal(wide.status, 1);
486
+ assert.match(wide.stderr, /Cancelled provenance: timed-out/);
487
+ }
488
+ );
489
+
490
+ test(
491
+ "an unusable ceiling set degrades to the pre-existing classification",
492
+ semantics,
493
+ () => {
494
+ // An absent or malformed TIER_TIMEOUT_MINUTES costs the label and nothing
495
+ // else — never a misclassification, and never a pass.
496
+ for (const ceilings of ["", "not json", '{"Unit": 20}']) {
497
+ const r = runAggregator(ONE_CANCELLED, {
498
+ ceilings,
499
+ runJson: runFixture([
500
+ timedJob("Unit (1/2)", "success", ranSteps, 90),
501
+ timedJob("Typecheck", "cancelled", neverStartedSteps, 15 * 60),
502
+ ]),
503
+ });
504
+ assert.equal(r.status, 1, `ceilings=${JSON.stringify(ceilings)}`);
505
+ assert.match(
506
+ r.stderr,
507
+ /Cancelled provenance: never-started/,
508
+ `ceilings=${JSON.stringify(ceilings)} should fall back, not throw`
509
+ );
510
+ }
511
+ }
512
+ );
513
+
514
+ test("classification is reported on the job summary too", semantics, () => {
515
+ const r = runAggregator(ONE_CANCELLED, { runJson: FIXTURES.neverStarted });
516
+ assert.match(r.summary, /Provenance: `never-started`/);
517
+ assert.match(r.summary, /INFRA fault/);
518
+ });
519
+
520
+ test(
521
+ "a timed-out summary names the tier, its ceiling, and the remediation input",
522
+ semantics,
523
+ () => {
524
+ // The whole value of the class is legibility: the operator must be able to
525
+ // read the summary and know to edit a number, not to open a runner ticket.
526
+ const r = runAggregator(ONE_CANCELLED, {
527
+ runJson: runFixture([
528
+ timedJob("Unit (1/2)", "success", ranSteps, 90),
529
+ timedJob("Typecheck", "cancelled", neverStartedSteps, 15 * 60),
530
+ ]),
531
+ });
532
+ assert.match(r.summary, /Provenance: `timed-out`/);
533
+ assert.match(r.summary, /Typecheck: timed-out \(hit its 15m ceiling\)/);
534
+ assert.match(r.summary, /CONFIG fault/);
535
+ assert.match(r.summary, /tier-timeouts/);
536
+ // It must not send the reader at the runner fleet — the misdiagnosis this
537
+ // class exists to prevent.
538
+ assert.match(r.summary, /do NOT escalate/);
539
+ }
540
+ );
541
+
542
+ test("a green run performs no provenance lookup at all", semantics, () => {
543
+ const r = runAggregator({ unit: "success", e2e: "success" });
544
+ assert.equal(r.status, 0, r.stderr);
545
+ assert.doesNotMatch(r.stderr, /provenance/);
546
+ });
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // 2. POLICY — strict is unchanged; provenance-aware neutralizes ONE class.
550
+ // ---------------------------------------------------------------------------
551
+
552
+ test("strict is the default and still fails every cancel", semantics, () => {
553
+ for (const [name, runJson] of Object.entries(FIXTURES)) {
554
+ const r = runAggregator(ONE_CANCELLED, {
555
+ policy: "strict",
556
+ runJson,
557
+ runList: NEWER_RUNS,
558
+ });
559
+ assert.equal(r.status, 1, `${name} should stay red under strict`);
560
+ }
561
+ });
562
+
563
+ test("an absent policy env var defaults to strict", semantics, () => {
564
+ const r = runAggregator(ONE_CANCELLED, {
565
+ policy: "",
566
+ runJson: FIXTURES.superseded,
567
+ runList: NEWER_RUNS,
568
+ });
569
+ assert.equal(r.status, 1);
570
+ });
571
+
572
+ test(
573
+ "provenance-aware neutralizes a superseded run's cancels",
574
+ semantics,
575
+ () => {
576
+ const r = runAggregator(ONE_CANCELLED, {
577
+ policy: "provenance-aware",
578
+ runJson: FIXTURES.superseded,
579
+ runList: NEWER_RUNS,
580
+ });
581
+ assert.equal(r.status, 0, r.stderr);
582
+ assert.match(r.stderr, /Superseded run/);
583
+ assert.match(r.summary, /neutral \(superseded run\)/);
584
+ }
585
+ );
586
+
587
+ test(
588
+ "provenance-aware keeps a never-started (infra) cancel red",
589
+ semantics,
590
+ () => {
591
+ // The painful case from swarm-os#928 — and deliberately NOT cleared. The
592
+ // tier produced no test signal, so a green here is a vacuous pass.
593
+ const r = runAggregator(ONE_CANCELLED, {
594
+ policy: "provenance-aware",
595
+ runJson: FIXTURES.neverStarted,
596
+ runList: NEWER_RUNS,
597
+ });
598
+ assert.equal(r.status, 1);
599
+ assert.match(r.summary, /never-started/);
600
+ }
601
+ );
602
+
603
+ test(
604
+ "provenance-aware keeps a timed-out cancel red even when a newer run exists",
605
+ semantics,
606
+ () => {
607
+ // `timed-out` carries the same verdict semantics as `never-started`: the
608
+ // tier produced no complete signal. A newer sibling run is present here
609
+ // precisely so the test proves the timeout is what holds the gate red —
610
+ // the superseded probe never gets to run.
611
+ const r = runAggregator(ONE_CANCELLED, {
612
+ policy: "provenance-aware",
613
+ runJson: runFixture([
614
+ timedJob("Unit (1/2)", "success", ranSteps, 90),
615
+ timedJob("E2E / Smoke (1/1)", "cancelled", ranSteps, 45 * 60),
616
+ ]),
617
+ runList: NEWER_RUNS,
618
+ });
619
+ assert.equal(r.status, 1);
620
+ assert.match(r.summary, /Provenance: `timed-out`/);
621
+ assert.doesNotMatch(r.summary, /neutral \(superseded run\)/);
622
+ }
623
+ );
624
+
625
+ test("provenance-aware keeps a fail-fast collateral cancel red", semantics, () => {
626
+ const r = runAggregator(ONE_CANCELLED, {
627
+ policy: "provenance-aware",
628
+ runJson: FIXTURES.failFast,
629
+ runList: NEWER_RUNS,
630
+ });
631
+ assert.equal(r.status, 1);
632
+ });
633
+
634
+ test("provenance-aware never clears a run where a job failed", semantics, () => {
635
+ const r = runAggregator(
636
+ { unit: "failure", e2e: "cancelled" },
637
+ {
638
+ policy: "provenance-aware",
639
+ runJson: FIXTURES.superseded,
640
+ runList: NEWER_RUNS,
641
+ }
642
+ );
643
+ assert.equal(r.status, 1);
644
+ assert.match(r.stderr, /unit\(failure\)/);
645
+ });
646
+
647
+ test(
648
+ "provenance-aware refuses to neutralize when nothing passed",
649
+ semantics,
650
+ () => {
651
+ // Neutralizing here would pass the required gate on a run with zero
652
+ // successful tiers — the vacuous pass the all-skipped guard also refuses.
653
+ const r = runAggregator(
654
+ { e2e: "cancelled" },
655
+ {
656
+ policy: "provenance-aware",
657
+ runJson: FIXTURES.supersededNoPass,
658
+ runList: NEWER_RUNS,
659
+ }
660
+ );
661
+ assert.equal(r.status, 1);
662
+ }
663
+ );
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // 3. DEGRADATION — no lookup failure can turn a red gate green, or fail the
667
+ // aggregator step itself.
668
+ // ---------------------------------------------------------------------------
669
+
670
+ test("a failing gh lookup degrades to unknown and stays red", semantics, () => {
671
+ for (const policy of ["strict", "provenance-aware"]) {
672
+ const r = runAggregator(ONE_CANCELLED, { policy, ghFails: true });
673
+ assert.equal(r.status, 1, `${policy} must stay red when gh fails`);
674
+ assert.match(r.stderr, /Cancelled provenance: unknown/);
675
+ }
676
+ });
677
+
678
+ test("an absent gh degrades to unknown and stays red", semantics, () => {
679
+ const dir = mkdtempSync(join(tmpdir(), "cancelled-provenance-nogh-"));
680
+ try {
681
+ const empty = join(dir, "bin");
682
+ mkdirSync(empty);
683
+ const summary = join(dir, "summary.md");
684
+ writeFileSync(summary, "");
685
+ const file = join(dir, "aggregate.sh");
686
+ writeFileSync(file, script);
687
+ // PATH keeps the real jq (the script needs it) but no gh.
688
+ const jqDir = dirname(execFileSync("which", ["jq"], { encoding: "utf8" }).trim());
689
+ const r = spawnSync("/bin/bash", [file], {
690
+ encoding: "utf8",
691
+ env: {
692
+ PATH: `${empty}:${jqDir}`,
693
+ NEEDS_JSON: JSON.stringify({
694
+ unit: { result: "success" },
695
+ e2e: { result: "cancelled" },
696
+ }),
697
+ CANCELLED_POLICY: "provenance-aware",
698
+ RUN_ID,
699
+ REPO: "Beestera/swarm-os",
700
+ GITHUB_STEP_SUMMARY: summary,
701
+ },
702
+ });
703
+ assert.equal(r.status, 1, r.stderr);
704
+ assert.match(r.stderr, /Cancelled provenance: unknown/);
705
+ } finally {
706
+ rmSync(dir, { recursive: true, force: true });
707
+ }
708
+ });
709
+
710
+ test("malformed API output degrades to unknown and stays red", semantics, () => {
711
+ const dir = mkdtempSync(join(tmpdir(), "cancelled-provenance-bad-"));
712
+ try {
713
+ const bin = join(dir, "bin");
714
+ mkdirSync(bin);
715
+ writeFileSync(
716
+ join(bin, "gh"),
717
+ "#!/usr/bin/env bash\nprintf 'not json at all'\nexit 0\n"
718
+ );
719
+ chmodSync(join(bin, "gh"), 0o755);
720
+ const summary = join(dir, "summary.md");
721
+ writeFileSync(summary, "");
722
+ const file = join(dir, "aggregate.sh");
723
+ writeFileSync(file, script);
724
+ const r = spawnSync("bash", [file], {
725
+ encoding: "utf8",
726
+ env: {
727
+ ...process.env,
728
+ PATH: `${bin}:${process.env.PATH}`,
729
+ NEEDS_JSON: JSON.stringify({
730
+ unit: { result: "success" },
731
+ e2e: { result: "cancelled" },
732
+ }),
733
+ CANCELLED_POLICY: "provenance-aware",
734
+ RUN_ID,
735
+ REPO: "Beestera/swarm-os",
736
+ GITHUB_STEP_SUMMARY: summary,
737
+ },
738
+ });
739
+ assert.equal(r.status, 1, r.stderr);
740
+ assert.match(r.stderr, /Cancelled provenance: unknown/);
741
+ } finally {
742
+ rmSync(dir, { recursive: true, force: true });
743
+ }
744
+ });
745
+
746
+ // ---------------------------------------------------------------------------
747
+ // 4. INPUT SURFACE — the policy is a declared, defaulted workflow_call input.
748
+ // ---------------------------------------------------------------------------
749
+
750
+ test("cancelled-policy is declared with a strict default", () => {
751
+ // Scan the input's own block by indentation rather than matching it with a
752
+ // multi-line regex: the obvious `(?: {8}.*\n|\s*\n)*?` formulation has
753
+ // overlapping alternatives and backtracks exponentially on a file with many
754
+ // newlines (CodeQL js/redos, high). A line walk has no such failure mode and
755
+ // reads more like the other extractors in this suite.
756
+ const lines = readFileSync(join(repoRoot, WORKFLOW), "utf8").split("\n");
757
+ const start = lines.findIndex((l) => l === " cancelled-policy:");
758
+ assert.notEqual(start, -1, "`cancelled-policy:` input not declared");
759
+
760
+ const block = [];
761
+ for (let i = start + 1; i < lines.length; i++) {
762
+ if (/^\s*$/.test(lines[i])) continue;
763
+ if (lines[i].match(/^(\s*)/)[1].length <= 6) break;
764
+ block.push(lines[i].trim());
765
+ }
766
+
767
+ assert.ok(
768
+ block.includes("default: strict"),
769
+ "`cancelled-policy` must default to `strict` so existing consumers are unaffected; " +
770
+ `declared instead: ${JSON.stringify(block)}`
771
+ );
772
+ });
773
+
774
+ test("tier-timeouts is declared with an empty-map default", () => {
775
+ // Same indentation walk as the sibling above, and the same reason for the
776
+ // default: an empty override map is byte-for-byte the behaviour every
777
+ // existing caller already gets, so adopting the input is opt-in.
778
+ const lines = readFileSync(join(repoRoot, WORKFLOW), "utf8").split("\n");
779
+ const start = lines.findIndex((l) => l === " tier-timeouts:");
780
+ assert.notEqual(start, -1, "`tier-timeouts:` input not declared");
781
+
782
+ const block = [];
783
+ for (let i = start + 1; i < lines.length; i++) {
784
+ if (/^\s*$/.test(lines[i])) continue;
785
+ if (lines[i].match(/^(\s*)/)[1].length <= 6) break;
786
+ block.push(lines[i].trim());
787
+ }
788
+
789
+ assert.ok(
790
+ block.includes("type: string"),
791
+ `\`tier-timeouts\` carries a JSON object, so it must be a string input; declared: ${JSON.stringify(block)}`
792
+ );
793
+ assert.ok(
794
+ block.includes("default: '{}'"),
795
+ "`tier-timeouts` must default to an empty map so existing consumers are unaffected; " +
796
+ `declared instead: ${JSON.stringify(block)}`
797
+ );
798
+ });
799
+
800
+ // ---------------------------------------------------------------------------
801
+ // 5. TIMEOUT SURFACE — every tier's ceiling is caller-tunable (Story #342).
802
+ //
803
+ // GitHub charges the pre-job `Set up runner` wait against the job's own
804
+ // `timeout-minutes`, so on a self-hosted pool a tier's real budget is
805
+ // `work + queue wait` and only the caller knows its own queue distribution. A
806
+ // literal ceiling left behind here is a tier the caller cannot reach — which is
807
+ // exactly the gap #340 filed, so it is asserted rather than trusted to review.
808
+ //
809
+ // The override is a `fromJSON(...)['<tier>'] || <base>` lookup rather than the
810
+ // single headroom addend #340 preferred, because GitHub Actions expressions
811
+ // have no arithmetic operators at all — `${{ 15 + inputs.x }}` fails to LEX,
812
+ // taking the whole workflow down with a "workflow file issue" and zero jobs.
813
+ // The `|| <base>` tail is what keeps a partial override map working, so it is
814
+ // asserted too: without it an unlisted tier would resolve to null.
815
+ // ---------------------------------------------------------------------------
816
+
817
+ /** Every `timeout-minutes:` declaration, tagged with its nesting depth. */
818
+ function timeoutDeclarations(rel) {
819
+ const lines = readFileSync(join(repoRoot, rel), "utf8").split("\n");
820
+ const found = [];
821
+ for (const [i, line] of lines.entries()) {
822
+ const m = line.match(/^(\s*)timeout-minutes:\s*(.+?)\s*$/);
823
+ if (m) found.push({ line: i + 1, indent: m[1].length, value: m[2] });
824
+ }
825
+ return found;
826
+ }
827
+
828
+ test("every job-level ceiling in pr-quality.yml is caller-overridable", () => {
829
+ // Job-level keys sit at 4 spaces (`jobs:` → `<job>:` → key); anything deeper
830
+ // is a step-level budget, covered by the next test.
831
+ const jobLevel = timeoutDeclarations(WORKFLOW).filter((d) => d.indent === 4);
832
+ assert.ok(
833
+ jobLevel.length >= 8,
834
+ `expected every tier plus the aggregator to declare a ceiling; found ${jobLevel.length}`
835
+ );
836
+ for (const d of jobLevel) {
837
+ assert.match(
838
+ d.value,
839
+ /^\$\{\{ fromJSON\(inputs\.tier-timeouts\)\['[a-z0-9-]+'\] \|\| \d+ \}\}$/,
840
+ `${WORKFLOW}:${d.line} declares a job-level ceiling the caller cannot reach ` +
841
+ `(${d.value}) — its budget is then work + queue wait with no way to raise ` +
842
+ "the clock, and without the `|| <base>` tail a partial override map would " +
843
+ "resolve an unlisted tier to null"
844
+ );
845
+ }
846
+ });
847
+
848
+ test("each tier's override key matches its own job id", () => {
849
+ // A copy-paste that points two tiers at one key silently makes one of them
850
+ // unreachable — the caller sets `e2e` and the e2e job keeps its default.
851
+ const lines = readFileSync(join(repoRoot, WORKFLOW), "utf8").split("\n");
852
+ let job = null;
853
+ const seen = new Set();
854
+ for (const line of lines) {
855
+ const j = line.match(/^ ([a-z][a-z0-9-]*):$/);
856
+ if (j) job = j[1];
857
+ const t = line.match(
858
+ /^ timeout-minutes: \$\{\{ fromJSON\(inputs\.tier-timeouts\)\['([a-z0-9-]+)'\]/
859
+ );
860
+ if (!t) continue;
861
+ assert.equal(
862
+ t[1],
863
+ job,
864
+ `job \`${job}\` reads override key \`${t[1]}\` — the key must be the job id, ` +
865
+ "or the caller's override for that tier lands on the wrong job (or nowhere)"
866
+ );
867
+ assert.ok(!seen.has(t[1]), `override key \`${t[1]}\` is claimed by two jobs`);
868
+ seen.add(t[1]);
869
+ }
870
+ assert.ok(seen.size >= 8, `expected every tier to be overridable; found ${seen.size}`);
871
+ });
872
+
873
+ test("the fail-fast cancel step's own budget carries no headroom", () => {
874
+ // `timeout-minutes: 1` on that step bounds a single API call inside an
875
+ // ALREADY-RUNNING job, so no queue wait is charged against it. Adding
876
+ // headroom there would slow a cancelled run down for no reason.
877
+ const stepLevel = timeoutDeclarations(WORKFLOW).filter((d) => d.indent > 4);
878
+ assert.ok(stepLevel.length > 0, "expected the fail-fast cancel step budget");
879
+ for (const d of stepLevel) {
880
+ assert.match(
881
+ d.value,
882
+ /^\d+$/,
883
+ `${WORKFLOW}:${d.line} is a step-level budget and must stay a literal; got ${d.value}`
884
+ );
885
+ }
886
+ });
887
+
888
+ test("the aggregator's base list carries every tier's default budget", () => {
889
+ // TIER_TIMEOUT_BASES is what makes `timed-out` detectable for a tier the
890
+ // caller did NOT override. If a tier's default budget changes and the list
891
+ // does not, that tier's timeouts silently fall back to `stopped-mid-step` —
892
+ // the misdiagnosis this Story removed. The caller's override values are
893
+ // unioned in at runtime, so they need no counterpart here.
894
+ const raw = readFileSync(join(repoRoot, WORKFLOW), "utf8");
895
+ const onJobs = new Set(
896
+ [
897
+ ...raw.matchAll(
898
+ /^ {4}timeout-minutes: \$\{\{ fromJSON\(inputs\.tier-timeouts\)\['[a-z0-9-]+'\] \|\| (\d+) \}\}$/gm
899
+ ),
900
+ ].map((m) => m[1])
901
+ );
902
+ assert.ok(onJobs.size > 0, "no job-level ceilings found");
903
+
904
+ const decl = raw.match(/^\s*TIER_TIMEOUT_BASES: '(\[[^\]]*\])'$/m);
905
+ assert.ok(decl, "`TIER_TIMEOUT_BASES` not declared as a JSON array literal");
906
+ const declared = new Set(JSON.parse(decl[1]).map(String));
907
+
908
+ for (const base of onJobs) {
909
+ assert.ok(
910
+ declared.has(base),
911
+ `a tier defaults to a ${base}m budget but TIER_TIMEOUT_BASES does not list ` +
912
+ `it (${decl[1]}) — a timeout on that tier would be misreported as ` +
913
+ "stopped-mid-step"
914
+ );
915
+ }
916
+ });
917
+
918
+ test("the aggregator env carries no job name (self-maintaining)", () => {
919
+ // The reason the caller's override map is threaded through RAW rather than
920
+ // resolved per tier: a per-tier map in this env block would name every job,
921
+ // which is exactly the bookkeeping check-ci-required-aggregator.test.mjs
922
+ // forbids. That suite checks `needs:` ids; this one pins the narrower rule
923
+ // that made the design what it is, next to the code it constrains.
924
+ const raw = readFileSync(join(repoRoot, WORKFLOW), "utf8");
925
+ const start = raw.indexOf("TIER_TIMEOUT_BASES:");
926
+ const end = raw.indexOf("GH_TOKEN:", start);
927
+ assert.ok(start !== -1 && end > start, "ceiling env block not found");
928
+ const block = raw.slice(start, end);
929
+ assert.doesNotMatch(
930
+ block,
931
+ /fromJSON\(inputs\.tier-timeouts\)\[/,
932
+ "the ceiling env block resolves per-tier overrides by name — thread " +
933
+ "`inputs.tier-timeouts` through raw and union it in the script instead, so " +
934
+ "adding a job to `needs:` stays the only edit"
935
+ );
936
+ });
937
+
938
+ // ---------------------------------------------------------------------------
939
+ // 6. PERMISSION RATCHET — the negative that makes this shippable (Story #342).
940
+ //
941
+ // #341 proposed reading GitHub's own timeout annotation via
942
+ // `GET /repos/{owner}/{repo}/check-runs/{id}/annotations`, which needs
943
+ // `checks: read`. GitHub validates a called reusable workflow's declared
944
+ // permissions against the CALLER's grant at compile time, ignoring every job's
945
+ // `if:` gate — so a new scope fails the entire call with `startup_failure` for
946
+ // any consumer that has not granted it. Story #292 added `pull-requests: read`
947
+ // to one job and broke ci.yml, the cross-repo smoke consumer, and a release.
948
+ // This is why the classifier infers from duration instead, and the allowlist
949
+ // below is what keeps a future change from quietly reintroducing the break.
950
+ // ---------------------------------------------------------------------------
951
+
952
+ test("pr-quality.yml declares no permission grant outside the allowlist", () => {
953
+ // Scope AND level, not scope alone: escalating an existing `contents: read`
954
+ // to `contents: write` is the same class of compile-time consumer break as
955
+ // adding a brand-new scope, and a name-only allowlist would wave it through.
956
+ const ALLOWED = new Set([
957
+ "contents:read",
958
+ "actions:write",
959
+ "pull-requests:read",
960
+ ]);
961
+ const lines = readFileSync(join(repoRoot, WORKFLOW), "utf8").split("\n");
962
+ const grants = new Set();
963
+
964
+ for (const [i, line] of lines.entries()) {
965
+ if (!/^\s*permissions:\s*$/.test(line)) continue;
966
+ const blockIndent = line.match(/^(\s*)/)[1].length;
967
+ for (let j = i + 1; j < lines.length; j++) {
968
+ if (/^\s*$/.test(lines[j]) || /^\s*#/.test(lines[j])) continue;
969
+ if (lines[j].match(/^(\s*)/)[1].length <= blockIndent) break;
970
+ const m = lines[j].match(/^\s*([a-z-]+):\s*(read|write|none)\s*$/);
971
+ if (m) grants.add(`${m[1]}:${m[2]}`);
972
+ }
973
+ }
974
+
975
+ assert.ok(grants.size > 0, "no `permissions:` block found to check");
976
+ for (const grant of grants) {
977
+ assert.ok(
978
+ ALLOWED.has(grant),
979
+ `pr-quality.yml declares \`${grant.replace(":", ": ")}\` — a reusable workflow's ` +
980
+ "permissions are validated against the caller's grant at COMPILE time regardless " +
981
+ "of any `if:` gate, so a new or escalated scope breaks every consumer that has " +
982
+ "not granted it (startup_failure, zero jobs). Widen this allowlist only alongside " +
983
+ "a lockstep update to ci.yml, the smoke consumer, and docs/reusable-workflows.md."
984
+ );
985
+ }
986
+ });