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.
@@ -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' `steps:` blocks are textually identical,
21
- * so a fix to one cannot drift from the other.
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 { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
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
- test("pr-quality.yml and ci.yml aggregator steps are textually identical", () => {
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` steps blocks must not drift — apply every change to both"
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
- return spawnSync("bash", [file], {
189
- encoding: "utf8",
190
- env: { ...process.env, NEEDS_JSON: JSON.stringify(needsJson) },
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
+ });