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,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
|
+
});
|