mandrel-platform 1.0.0 → 1.0.1
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/check-cancelled-provenance.test.mjs +494 -0
- package/scripts/check-ci-required-aggregator.test.mjs +169 -13
- package/scripts/check-destructive-migration.test.mjs +37 -0
- package/scripts/check-fail-fast-attribution.test.mjs +390 -0
- package/scripts/platform-sync.mjs +89 -15
- package/scripts/platform-sync.test.mjs +125 -0
|
@@ -17,20 +17,34 @@
|
|
|
17
17
|
* 1. STRUCTURE — each aggregator's `steps:` derive results from
|
|
18
18
|
* `toJSON(needs)` and contain NO hardcoded reference to any job named in
|
|
19
19
|
* its own `needs:` array (the "no hardcoded tier-name list" AC).
|
|
20
|
-
* 2. PARITY — the two aggregators' `
|
|
21
|
-
* so a fix to one cannot drift from the
|
|
20
|
+
* 2. PARITY — the two aggregators' `run:` scripts are byte-identical and both
|
|
21
|
+
* declare the same `env:` keys, so a fix to one cannot drift from the
|
|
22
|
+
* other. (Story #333 loosened this from "the whole `steps:` blocks are
|
|
23
|
+
* identical": ci.yml has no `workflow_call` inputs, so it must source
|
|
24
|
+
* `CANCELLED_POLICY` as a literal where pr-quality.yml sources it from
|
|
25
|
+
* `inputs.cancelled-policy`. The shared script reads it from the
|
|
26
|
+
* environment, which is what keeps the logic itself byte-identical.)
|
|
22
27
|
* 3. SEMANTICS — the shared run script passes on `success`/`skipped` and
|
|
23
28
|
* fails on anything else, INCLUDING `cancelled` (load-bearing for #223's
|
|
24
29
|
* fail-fast design), while naming the failing jobs and their results.
|
|
25
30
|
* Executed against real bash+jq; skipped when jq is unavailable locally
|
|
26
|
-
* (CI's ubuntu runner always has it).
|
|
31
|
+
* (CI's ubuntu runner always has it). `gh` is stubbed to fail so these
|
|
32
|
+
* tests stay hermetic — provenance classification (Story #333) has its
|
|
33
|
+
* own suite in scripts/check-cancelled-provenance.test.mjs.
|
|
27
34
|
*
|
|
28
35
|
* Run: node --test scripts/check-ci-required-aggregator.test.mjs
|
|
29
36
|
*/
|
|
30
37
|
|
|
31
38
|
import assert from "node:assert/strict";
|
|
32
39
|
import { test } from "node:test";
|
|
33
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
readFileSync,
|
|
42
|
+
mkdtempSync,
|
|
43
|
+
mkdirSync,
|
|
44
|
+
writeFileSync,
|
|
45
|
+
chmodSync,
|
|
46
|
+
rmSync,
|
|
47
|
+
} from "node:fs";
|
|
34
48
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
35
49
|
import { join, resolve, dirname } from "node:path";
|
|
36
50
|
import { fileURLToPath } from "node:url";
|
|
@@ -155,14 +169,61 @@ for (const { rel, needs, steps } of blocks) {
|
|
|
155
169
|
// 2. PARITY — the two implementations are textually identical
|
|
156
170
|
// ---------------------------------------------------------------------------
|
|
157
171
|
|
|
158
|
-
|
|
172
|
+
// Story #333 re-shaped this from "the whole `steps:` blocks are byte-identical"
|
|
173
|
+
// to "the `run:` scripts are byte-identical AND both declare the same `env:`
|
|
174
|
+
// keys". ci.yml has no `workflow_call` inputs, so it cannot source
|
|
175
|
+
// `CANCELLED_POLICY` from `inputs.cancelled-policy` the way pr-quality.yml
|
|
176
|
+
// does — but the LOGIC is what must not drift, and the script reads the policy
|
|
177
|
+
// from the environment precisely so the two can share it verbatim.
|
|
178
|
+
|
|
179
|
+
/** The `KEY:` names declared under a steps block's `env:` mapping. */
|
|
180
|
+
function extractEnvKeys(steps) {
|
|
181
|
+
const stepLines = steps.split("\n");
|
|
182
|
+
const start = stepLines.findIndex((l) => /^\s+env:\s*$/.test(l));
|
|
183
|
+
assert.notEqual(start, -1, "`env:` block not found");
|
|
184
|
+
const envIndent = stepLines[start].match(/^(\s*)/)[1].length;
|
|
185
|
+
const keys = [];
|
|
186
|
+
for (let i = start + 1; i < stepLines.length; i++) {
|
|
187
|
+
if (/^\s*$/.test(stepLines[i])) continue;
|
|
188
|
+
if (/^\s*#/.test(stepLines[i])) continue;
|
|
189
|
+
const indent = stepLines[i].match(/^(\s*)/)[1].length;
|
|
190
|
+
if (indent <= envIndent) break;
|
|
191
|
+
const key = stepLines[i].match(/^\s+([A-Za-z_][A-Za-z0-9_]*):/);
|
|
192
|
+
if (key) keys.push(key[1]);
|
|
193
|
+
}
|
|
194
|
+
assert.ok(keys.length > 0, "`env:` block declared no keys");
|
|
195
|
+
return keys.sort();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test("pr-quality.yml and ci.yml aggregator run scripts are byte-identical", () => {
|
|
159
199
|
assert.equal(
|
|
160
|
-
blocks[0].steps,
|
|
161
|
-
blocks[1].steps,
|
|
162
|
-
"the two `ci-required`
|
|
200
|
+
extractRunScript(blocks[0].steps),
|
|
201
|
+
extractRunScript(blocks[1].steps),
|
|
202
|
+
"the two `ci-required` run scripts must not drift — apply every logic change to both"
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("pr-quality.yml and ci.yml aggregators declare the same env keys", () => {
|
|
207
|
+
assert.deepEqual(
|
|
208
|
+
extractEnvKeys(blocks[0].steps),
|
|
209
|
+
extractEnvKeys(blocks[1].steps),
|
|
210
|
+
"the shared run script reads its inputs from the environment — a key present " +
|
|
211
|
+
"in only one workflow would leave the other running the same script unconfigured"
|
|
163
212
|
);
|
|
164
213
|
});
|
|
165
214
|
|
|
215
|
+
test("the shared aggregator script never references workflow_call inputs", () => {
|
|
216
|
+
// ci.yml has none, so an `inputs.*` reference could not be mirrored and
|
|
217
|
+
// would break the byte-identical run-script guarantee above.
|
|
218
|
+
for (const { rel, steps } of blocks) {
|
|
219
|
+
assert.doesNotMatch(
|
|
220
|
+
extractRunScript(steps),
|
|
221
|
+
/inputs\./,
|
|
222
|
+
`${rel}: the run script must read configuration from \`env:\`, not \`inputs.*\``
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
166
227
|
// ---------------------------------------------------------------------------
|
|
167
228
|
// 3. SEMANTICS — pass on success/skipped, fail (naming jobs) on anything else
|
|
168
229
|
// ---------------------------------------------------------------------------
|
|
@@ -176,7 +237,7 @@ function jqAvailable() {
|
|
|
176
237
|
}
|
|
177
238
|
}
|
|
178
239
|
|
|
179
|
-
function runAggregator(needsResults) {
|
|
240
|
+
function runAggregator(needsResults, { captureSummary = false } = {}) {
|
|
180
241
|
const script = extractRunScript(blocks[0].steps);
|
|
181
242
|
const dir = mkdtempSync(join(tmpdir(), "ci-required-"));
|
|
182
243
|
try {
|
|
@@ -185,10 +246,36 @@ function runAggregator(needsResults) {
|
|
|
185
246
|
const needsJson = Object.fromEntries(
|
|
186
247
|
Object.entries(needsResults).map(([k, result]) => [k, { result, outputs: {} }])
|
|
187
248
|
);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
249
|
+
// Neutralize the provenance lookup (Story #333): shadow `gh` with a stub
|
|
250
|
+
// that always fails, so these tests stay hermetic and exercise the
|
|
251
|
+
// `unknown`-provenance path. Provenance classification itself is covered
|
|
252
|
+
// by scripts/check-cancelled-provenance.test.mjs.
|
|
253
|
+
const bin = join(dir, "bin");
|
|
254
|
+
mkdirSync(bin);
|
|
255
|
+
const ghStub = join(bin, "gh");
|
|
256
|
+
writeFileSync(ghStub, "#!/usr/bin/env bash\nexit 1\n");
|
|
257
|
+
chmodSync(ghStub, 0o755);
|
|
258
|
+
|
|
259
|
+
const env = {
|
|
260
|
+
...process.env,
|
|
261
|
+
PATH: `${bin}:${process.env.PATH}`,
|
|
262
|
+
NEEDS_JSON: JSON.stringify(needsJson),
|
|
263
|
+
};
|
|
264
|
+
// The step-summary write must degrade when GITHUB_STEP_SUMMARY is unset
|
|
265
|
+
// (this harness, and any `act`-style local runner), so only define it when
|
|
266
|
+
// a test is actually asserting on the summary body.
|
|
267
|
+
const summaryPath = join(dir, "summary.md");
|
|
268
|
+
if (captureSummary) {
|
|
269
|
+
writeFileSync(summaryPath, "");
|
|
270
|
+
env.GITHUB_STEP_SUMMARY = summaryPath;
|
|
271
|
+
} else {
|
|
272
|
+
delete env.GITHUB_STEP_SUMMARY;
|
|
273
|
+
}
|
|
274
|
+
const r = spawnSync("bash", [file], { encoding: "utf8", env });
|
|
275
|
+
return {
|
|
276
|
+
...r,
|
|
277
|
+
summary: captureSummary ? readFileSync(summaryPath, "utf8") : "",
|
|
278
|
+
};
|
|
192
279
|
} finally {
|
|
193
280
|
rmSync(dir, { recursive: true, force: true });
|
|
194
281
|
}
|
|
@@ -225,3 +312,72 @@ test("run script: every non-passing job is named", semantics, () => {
|
|
|
225
312
|
assert.match(r.stderr, /unit\(cancelled\)/);
|
|
226
313
|
assert.doesNotMatch(r.stderr, /e2e/);
|
|
227
314
|
});
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// 4. TRIAGE PARTITION (Story #331) — a fail-fast run reds N jobs but only one
|
|
318
|
+
// of them actually failed. The aggregate must say WHICH, so triage does not
|
|
319
|
+
// land on a collateral cancel.
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
test("run script: partitions own-failures from collateral cancels", semantics, () => {
|
|
323
|
+
const r = runAggregator({
|
|
324
|
+
lint: "success",
|
|
325
|
+
security: "failure",
|
|
326
|
+
unit: "cancelled",
|
|
327
|
+
e2e: "cancelled",
|
|
328
|
+
});
|
|
329
|
+
assert.equal(r.status, 1);
|
|
330
|
+
|
|
331
|
+
const own = r.stderr
|
|
332
|
+
.split("\n")
|
|
333
|
+
.find((l) => l.includes("Failed on their own"));
|
|
334
|
+
const collateral = r.stderr
|
|
335
|
+
.split("\n")
|
|
336
|
+
.find((l) => l.includes("do not triage"));
|
|
337
|
+
|
|
338
|
+
assert.ok(own, `no own-failure line in stderr:\n${r.stderr}`);
|
|
339
|
+
assert.ok(collateral, `no collateral line in stderr:\n${r.stderr}`);
|
|
340
|
+
|
|
341
|
+
// The tier that actually failed is named ONLY on the triage line, and the
|
|
342
|
+
// cancelled siblings ONLY on the collateral line — a partition, not two
|
|
343
|
+
// copies of the same flat list.
|
|
344
|
+
assert.match(own, /security\(failure\)/);
|
|
345
|
+
assert.doesNotMatch(own, /unit|e2e/);
|
|
346
|
+
assert.match(collateral, /unit\(cancelled\)/);
|
|
347
|
+
assert.match(collateral, /e2e\(cancelled\)/);
|
|
348
|
+
assert.doesNotMatch(collateral, /security/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("run script: an all-cancelled aggregate says no job failed on its own", semantics, () => {
|
|
352
|
+
const r = runAggregator({ lint: "success", unit: "cancelled" }, { captureSummary: true });
|
|
353
|
+
assert.equal(r.status, 1);
|
|
354
|
+
assert.doesNotMatch(r.stderr, /Failed on their own/);
|
|
355
|
+
assert.match(r.summary, /No job reported its own failure/);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("run script: writes the triage partition to the job summary", semantics, () => {
|
|
359
|
+
const r = runAggregator(
|
|
360
|
+
{ security: "failure", unit: "cancelled" },
|
|
361
|
+
{ captureSummary: true }
|
|
362
|
+
);
|
|
363
|
+
assert.equal(r.status, 1);
|
|
364
|
+
assert.match(r.summary, /ci-required failed/);
|
|
365
|
+
assert.match(r.summary, /Failed on their own[^\n]*security\(failure\)/);
|
|
366
|
+
assert.match(r.summary, /Cancelled[^\n]*do not triage:[^\n]*unit\(cancelled\)/);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("run script: a green aggregate writes no summary", semantics, () => {
|
|
370
|
+
const r = runAggregator({ lint: "success", unit: "success" }, { captureSummary: true });
|
|
371
|
+
assert.equal(r.status, 0, r.stderr);
|
|
372
|
+
assert.equal(r.summary, "");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("run script: survives an unset GITHUB_STEP_SUMMARY", semantics, () => {
|
|
376
|
+
// The failure path writes a summary; with the variable unset (local runners,
|
|
377
|
+
// `act`, this harness) the redirect must degrade rather than error out and
|
|
378
|
+
// swallow the exit-1 verdict.
|
|
379
|
+
const r = runAggregator({ unit: "failure" });
|
|
380
|
+
assert.equal(r.status, 1);
|
|
381
|
+
assert.match(r.stderr, /unit\(failure\)/);
|
|
382
|
+
assert.doesNotMatch(r.stderr, /ambiguous redirect|No such file or directory/);
|
|
383
|
+
});
|
|
@@ -56,6 +56,43 @@ test("globToRegExp: `**` crosses path separators, `*` does not", () => {
|
|
|
56
56
|
assert.ok(globToRegExp("*.sql").test("x.sql"));
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
// The glob set reaches this function from a CLI argument, so CodeQL reports
|
|
60
|
+
// `js/regex-injection` (high) on the `new RegExp` it builds. That alert is a
|
|
61
|
+
// false positive ONLY for as long as the translator escapes every regex
|
|
62
|
+
// metacharacter — the input must be able to control `*` / `**` semantics and
|
|
63
|
+
// nothing else. These two tests are the evidence that claim rests on; if a
|
|
64
|
+
// future edit drops a character from the escape set, they fail rather than
|
|
65
|
+
// letting a stale dismissal stand.
|
|
66
|
+
test("globToRegExp: escapes every regex metacharacter to a literal", () => {
|
|
67
|
+
// `*` is excluded deliberately — it is the one character the glob syntax
|
|
68
|
+
// gives meaning to. `/` is not a metacharacter in the RegExp *constructor*
|
|
69
|
+
// (only in literal notation), but is asserted anyway.
|
|
70
|
+
const METACHARS = [".", "+", "?", "^", "$", "{", "}", "(", ")", "|", "[", "]", "\\", "/"];
|
|
71
|
+
for (const meta of METACHARS) {
|
|
72
|
+
const glob = `a${meta}b`;
|
|
73
|
+
const re = globToRegExp(glob);
|
|
74
|
+
assert.ok(re.test(glob), `\`${meta}\` must match itself literally (got ${re})`);
|
|
75
|
+
assert.ok(
|
|
76
|
+
!re.test("aXb"),
|
|
77
|
+
`\`${meta}\` leaked regex meaning — it matched an arbitrary character (got ${re})`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("globToRegExp: a regex-injection payload translates to a literal pattern", () => {
|
|
83
|
+
// The classic catastrophic-backtracking payload. If any of it survived
|
|
84
|
+
// unescaped, this pattern would carry regex semantics into the matcher.
|
|
85
|
+
const re = globToRegExp("(a+)+$");
|
|
86
|
+
// Compared as a STRING, not against a regex literal: writing the expected
|
|
87
|
+
// pattern as `/^\(a\+\)\+\$$/` puts a (wholly escaped, quantifier-free)
|
|
88
|
+
// regex literal in the file that CodeQL's js/redos analysis reads as a live
|
|
89
|
+
// backtracking hazard. The assertion is identical in strength — this test
|
|
90
|
+
// has always been about the produced source text.
|
|
91
|
+
assert.equal(String(re), "/^\\(a\\+\\)\\+\\$$/");
|
|
92
|
+
assert.ok(re.test("(a+)+$"), "the payload matches only its literal self");
|
|
93
|
+
assert.ok(!re.test("aaaaaaaa"), "the payload carries no quantifier semantics");
|
|
94
|
+
});
|
|
95
|
+
|
|
59
96
|
test("isMigrationFile: matches migration globs and any .sql tail", () => {
|
|
60
97
|
assert.ok(isMigrationFile("apps/api/migrations/0007_drop.ts"));
|
|
61
98
|
assert.ok(isMigrationFile("drizzle/0001_init.sql"));
|
|
@@ -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
|
+
});
|