mandrel-platform 1.12.0 → 1.13.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.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-husky-hook-modes.test.mjs — regression guard for the mode bit that
4
+ * decides whether this repository's git hooks run at all.
5
+ *
6
+ * The bug this pins: `.husky/pre-commit` was tracked at mode 100644. Git
7
+ * 2.36+ refuses to execute a hook that is not marked executable, and the way
8
+ * it refuses is the problem — it prints
9
+ *
10
+ * hint: The '.../.husky/pre-commit' hook was ignored because it's not
11
+ * set as executable.
12
+ *
13
+ * to stderr and then lets the commit SUCCEED with exit 0. So the quality gate
14
+ * the hook exists to run (`quality-preview.js`, which blocks MI/CRAP drift at
15
+ * commit time) never ran for anybody, in any clone, from the day it was
16
+ * added — and nothing anywhere went red to say so. An inert gate and a
17
+ * passing gate are indistinguishable from the outside.
18
+ *
19
+ * Why the mode is asserted against the INDEX and not the filesystem. `chmod`
20
+ * on the working copy is not the fix and would not be caught here: git stores
21
+ * only two file modes, 100644 and 100755, and it is the tracked one that
22
+ * every fresh clone and every `git checkout` materializes. A working tree can
23
+ * be executable while the committed mode is not (and vice versa), so a
24
+ * `statSync` check would go green on the maintainer's machine and ship the
25
+ * defect to everyone else. `git ls-files -s` reads the index, which is the
26
+ * mode that actually propagates.
27
+ *
28
+ * Why this is not a one-line assertion on one path. The upstream cause is
29
+ * still live: the hook is installed by the vendored bootstrap
30
+ * (`.agents/scripts/lib/bootstrap/quality-bootstrap.js`) with a bare
31
+ * `fs.writeFileSync` and no `chmod`, so a hook file CREATED by a fresh
32
+ * bootstrap run is born 0644. (An existing file survives — `writeFileSync`
33
+ * truncates in place and preserves the mode — which is why fixing the tracked
34
+ * mode holds.) `.agents/**` is vendored payload re-materialized from the
35
+ * mandrel package, so that cause cannot be fixed here; this guard is the
36
+ * compensating control, and it therefore covers every hook the directory may
37
+ * grow, not just the one that was broken.
38
+ *
39
+ * Run: node --test scripts/check-husky-hook-modes.test.mjs
40
+ */
41
+
42
+ import assert from "node:assert/strict";
43
+ import { test } from "node:test";
44
+ import { execFileSync } from "node:child_process";
45
+
46
+ const HOOKS_DIR = ".husky";
47
+ const EXECUTABLE = "100755";
48
+
49
+ /**
50
+ * Every client-side hook name git will invoke. A file in `.husky/` whose name
51
+ * is not on this list is never run by git no matter what its mode is, so a
52
+ * typo (`pre_commit`, `precommit`) is the same silent no-op as a missing
53
+ * executable bit and is asserted against below.
54
+ *
55
+ * Source: `githooks(5)`, client-side hooks only — server-side hooks
56
+ * (`pre-receive`, `update`, `post-receive`) cannot fire from a clone.
57
+ */
58
+ const GIT_CLIENT_HOOKS = new Set([
59
+ "applypatch-msg",
60
+ "pre-applypatch",
61
+ "post-applypatch",
62
+ "pre-commit",
63
+ "pre-merge-commit",
64
+ "prepare-commit-msg",
65
+ "commit-msg",
66
+ "post-commit",
67
+ "pre-rebase",
68
+ "post-checkout",
69
+ "post-merge",
70
+ "pre-push",
71
+ "pre-auto-gc",
72
+ "post-rewrite",
73
+ "sendemail-validate",
74
+ "post-index-change",
75
+ "reference-transaction",
76
+ "push-to-checkout",
77
+ ]);
78
+
79
+ /**
80
+ * Tracked entries directly under `.husky/`, as `{ mode, path, name }`.
81
+ *
82
+ * `git ls-files -s` emits `<mode> <object> <stage>\t<path>`. Husky's own
83
+ * `_/` shim directory and dotfiles (`.gitignore`) are not hooks and are
84
+ * excluded; everything else in the directory is one by convention.
85
+ */
86
+ function trackedHooks() {
87
+ const out = execFileSync("git", ["ls-files", "-s", "--", HOOKS_DIR], {
88
+ encoding: "utf8",
89
+ });
90
+
91
+ return out
92
+ .split("\n")
93
+ .filter((line) => line.length > 0)
94
+ .map((line) => {
95
+ const [meta, path] = line.split("\t");
96
+ return { mode: meta.split(" ")[0], path, name: path.slice(HOOKS_DIR.length + 1) };
97
+ })
98
+ .filter(({ name }) => !name.startsWith(".") && !name.startsWith("_/"));
99
+ }
100
+
101
+ test("the quality-gate pre-commit hook is still tracked", () => {
102
+ // Without this the mode assertion below passes vacuously the moment the
103
+ // hook is deleted — which is the same outcome (no gate) by another route.
104
+ const names = trackedHooks().map((h) => h.name);
105
+ assert.ok(
106
+ names.includes("pre-commit"),
107
+ `${HOOKS_DIR}/pre-commit is not tracked; the commit-time quality gate would not run. Tracked: ${names.join(", ") || "(none)"}`,
108
+ );
109
+ });
110
+
111
+ test("every tracked hook under .husky/ is executable in the index", () => {
112
+ const hooks = trackedHooks();
113
+ assert.ok(hooks.length > 0, `no tracked hooks found under ${HOOKS_DIR}/`);
114
+
115
+ const nonExecutable = hooks.filter((h) => h.mode !== EXECUTABLE);
116
+ assert.deepEqual(
117
+ nonExecutable.map((h) => `${h.path} (${h.mode})`),
118
+ [],
119
+ `git 2.36+ silently IGNORES a non-executable hook and lets the commit succeed with exit 0. ` +
120
+ `Fix the tracked mode with: git update-index --chmod=+x <path> (a bare chmod does not change it).`,
121
+ );
122
+ });
123
+
124
+ test("every tracked hook under .husky/ has a name git will actually invoke", () => {
125
+ const unknown = trackedHooks()
126
+ .map((h) => h.name)
127
+ .filter((name) => !GIT_CLIENT_HOOKS.has(name));
128
+
129
+ assert.deepEqual(
130
+ unknown,
131
+ [],
132
+ `git invokes hooks by exact filename; a name outside githooks(5) never runs, as silently as a non-executable one.`,
133
+ );
134
+ });
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * check-runner-runs-on.test.mjs — regression guard for the `runner` input's
4
- * two documented shapes (Story #421).
3
+ * check-runner-runs-on.test.mjs — regression guard for every shape the
4
+ * `runner` input can arrive in (Stories #421, #493).
5
5
  *
6
6
  * The bug this pins: every `runs-on:` site consumed the input raw as
7
7
  * `${{ inputs.runner }}`. GitHub does not parse a JSON-array *string* in that
@@ -21,6 +21,25 @@
21
21
  * expression from the workflow and EVALUATES it under Actions semantics, the
22
22
  * same read-then-execute approach as check-toolchain-cache-default.test.mjs.
23
23
  *
24
+ * The same failure had a second door, closed by #493: `runner: ''`. A
25
+ * workflow_call `default:` fires only when the key is ABSENT, so a caller
26
+ * who passes the key with an empty value — threading an unset input or a
27
+ * matrix value through — got the label `""` rather than `ubuntu-latest`, and
28
+ * with it the identical never-scheduled job. The fallback therefore lives at
29
+ * the `runs-on` site, and the byte-identical assertion below is what stops a
30
+ * fix that reaches six of the seven workflows from shipping as if it reached
31
+ * all seven.
32
+ *
33
+ * Story #493 also left a downstream door open, closed here: `runs-on` was not
34
+ * the only site reading `inputs.runner`. `pr-quality.yml`'s harden-runner
35
+ * egress-audit step gates on `startsWith(inputs.runner, 'ubuntu-')`, which was
36
+ * unreachable-but-consistent while `runner: ''` never scheduled a job. Once the
37
+ * empty value resolved to the hosted default, the job ran on ubuntu-latest
38
+ * while the gate read the raw `''` and skipped — a SECURITY step opting itself
39
+ * out with nothing red to show for it. So the gate is asserted the same way:
40
+ * extracted and EVALUATED, and required to AGREE with what `runs-on` resolves
41
+ * to for the same input.
42
+ *
24
43
  * Run: node --test scripts/check-runner-runs-on.test.mjs
25
44
  */
26
45
 
@@ -28,6 +47,7 @@ import assert from "node:assert/strict";
28
47
  import { test } from "node:test";
29
48
  import { readFileSync, readdirSync } from "node:fs";
30
49
  import { evaluate } from "./lib/actions-expression.mjs";
50
+ import { stepByName } from "./lib/yaml-step.mjs";
31
51
 
32
52
  const WORKFLOW_DIR = ".github/workflows";
33
53
 
@@ -58,6 +78,28 @@ function runsOnExpressions(text) {
58
78
  return out;
59
79
  }
60
80
 
81
+ /**
82
+ * The `default:` this workflow declares for its `runner` workflow_call input.
83
+ *
84
+ * Read from the YAML rather than hardcoded so the empty-input assertion below
85
+ * pins the real contract: whatever label a caller gets by omitting `runner`
86
+ * is the label an explicitly-empty `runner` must get too. Scanned line by
87
+ * line — the block boundary is indentation, and a dynamically-constructed
88
+ * regex is a SAST finding that buys nothing here.
89
+ */
90
+ function declaredRunnerDefault(text) {
91
+ const lines = text.split("\n");
92
+ const start = lines.indexOf(" runner:");
93
+ assert.notEqual(start, -1, "no `runner:` workflow_call input found");
94
+ for (let i = start + 1; i < lines.length; i++) {
95
+ const line = lines[i];
96
+ if (line.trim() !== "" && !line.startsWith(" ")) break;
97
+ const m = line.match(/^\s+default:\s*'([^']*)'\s*$/);
98
+ if (m) return m[1];
99
+ }
100
+ return assert.fail("the `runner` input declares no `default:` to fall back to");
101
+ }
102
+
61
103
  const WORKFLOWS = runnerWorkflows();
62
104
 
63
105
  test("every workflow taking a `runner` input is covered by this guard", () => {
@@ -127,6 +169,38 @@ for (const { file, text } of WORKFLOWS) {
127
169
  }
128
170
  });
129
171
 
172
+ test(`${file}: an empty runner resolves to this workflow's documented default`, () => {
173
+ // The silent-queue shape #421 left behind. `runner: ''` is not exotic — a
174
+ // caller threading `runner: ${{ inputs.runner }}` or a matrix value that
175
+ // resolves to nothing passes it without meaning to, and `format('"{0}"',
176
+ // '')` yielded the label `""`. An empty label matches no runner, so the
177
+ // job sat `queued` with no logs and no red, exactly as the JSON-array
178
+ // string did. The fallback belongs at the `runs-on` site because the
179
+ // input `default:` only fires when the key is ABSENT, never when it is
180
+ // present and empty.
181
+ const fallback = declaredRunnerDefault(text);
182
+ assert.notEqual(fallback, "", `${file}: the declared default is itself empty`);
183
+ for (const { line, expr } of sites) {
184
+ const resolved = evaluate(expr, { runner: "" });
185
+ assert.equal(
186
+ typeof resolved,
187
+ "string",
188
+ `${file}:${line}: an empty runner must resolve to a single label, not ${JSON.stringify(resolved)}`,
189
+ );
190
+ assert.notEqual(
191
+ resolved,
192
+ "",
193
+ `${file}:${line}: an empty runner resolves to an empty label — no runner ` +
194
+ `carries it, so the job queues until the 24-hour timeout with nothing to read`,
195
+ );
196
+ assert.equal(
197
+ resolved,
198
+ fallback,
199
+ `${file}:${line}: an empty runner must land on the input's documented default`,
200
+ );
201
+ }
202
+ });
203
+
130
204
  test(`${file}: every runs-on site resolves identically`, () => {
131
205
  // One workflow must not drift into two dialects of the same decision.
132
206
  const rendered = sites.map(({ expr }) =>
@@ -136,6 +210,25 @@ for (const { file, text } of WORKFLOWS) {
136
210
  });
137
211
  }
138
212
 
213
+
214
+ test("every runs-on expression across the seven workflows is byte-identical", () => {
215
+ // The per-workflow tests above each score one file, so a fix applied to six
216
+ // of the seven passes every one of them and ships the seventh still broken.
217
+ // This is the assertion a partial edit cannot survive: one decision, spelled
218
+ // one way, everywhere it is made.
219
+ const distinct = new Map();
220
+ for (const { file, text } of WORKFLOWS) {
221
+ for (const { line, expr } of runsOnExpressions(text)) {
222
+ if (!distinct.has(expr)) distinct.set(expr, []);
223
+ distinct.get(expr).push(`${file}:${line}`);
224
+ }
225
+ }
226
+ const report = [...distinct.entries()]
227
+ .map(([expr, at]) => `${expr} @ ${at.join(", ")}`)
228
+ .join("\n ");
229
+ assert.equal(distinct.size, 1, `runs-on expressions have drifted apart:\n ${report}`);
230
+ });
231
+
139
232
  test("the documented array form resolves AND derives toolchain-cache 'false'", () => {
140
233
  // The coupling the gap report found: before this fix the only `runner` value
141
234
  // that derived the correct cache posture was the one that never reached a
@@ -152,3 +245,131 @@ test("the documented array form resolves AND derives toolchain-cache 'false'", (
152
245
  assert.ok(cache, "no `cache: ${{ … }}` value found at the setup-toolchain call site");
153
246
  assert.equal(evaluate(cache[1].trim(), { runner, "toolchain-cache": "auto" }), "false");
154
247
  });
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // The harden-runner egress-audit gate (`pr-quality.yml`)
251
+ //
252
+ // `runs-on` was not the only expression reading `inputs.runner`. Anything that
253
+ // branches on the runner class has to resolve the input the SAME way, or the
254
+ // job and the step disagree about which machine they are on. This section
255
+ // pins that agreement behaviourally — extract the real `if:` and run it.
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * The `${{ … }}` body of the harden-runner step's `if:` gate.
260
+ *
261
+ * Keyed off the step, not off a line pattern that happens to contain
262
+ * `startsWith` — the point is to score whatever expression actually guards
263
+ * that step, including one a future edit spells differently.
264
+ */
265
+ function hardenRunnerGate(text) {
266
+ const block = stepByName(text, "Harden runner (egress audit)");
267
+ assert.match(
268
+ block,
269
+ /uses: step-security\/harden-runner@/,
270
+ "the extracted block is not the harden-runner step",
271
+ );
272
+ const m = block.match(/^\s*if:\s*\$\{\{(.+)\}\}\s*$/m);
273
+ assert.ok(m, "the harden-runner step has no single-expression `if:` gate to score");
274
+ return m[1].trim();
275
+ }
276
+
277
+ /**
278
+ * What `runs-on` resolves to for `runner`, as a hosted-ubuntu predicate.
279
+ *
280
+ * A single string label starting with `ubuntu-` is a GitHub-hosted ubuntu
281
+ * image — the one environment where harden-runner installs its own monitor.
282
+ * An ARRAY (the documented self-hosted form) is not, regardless of the labels
283
+ * inside it: harden-runner ships its agent in a self-hosted runner image, so
284
+ * the step is correctly a no-op there.
285
+ */
286
+ function resolvesToHostedUbuntu(runsOnExpr, runner) {
287
+ const resolved = evaluate(runsOnExpr, { runner });
288
+ return typeof resolved === "string" && resolved.startsWith("ubuntu-");
289
+ }
290
+
291
+ const QUALITY_FILE = `${WORKFLOW_DIR}/pr-quality.yml`;
292
+ const QUALITY_TEXT = readFileSync(QUALITY_FILE, "utf8");
293
+
294
+ test("pr-quality.yml: the harden-runner gate reaches every tier through one anchor", () => {
295
+ // The gate is written once (`&harden-runner`) and aliased into the other
296
+ // tiers. A second literal copy could carry a stale expression that every
297
+ // behavioural assertion below would miss, because they score the anchor.
298
+ assert.match(QUALITY_TEXT, /^ {6}- &harden-runner$/m, "the harden-runner anchor is missing");
299
+ assert.ok(
300
+ (QUALITY_TEXT.match(/^ {6}- \*harden-runner$/gm) ?? []).length > 0,
301
+ "expected the harden-runner anchor to be aliased into the other tiers",
302
+ );
303
+ assert.equal(
304
+ (QUALITY_TEXT.match(/uses: step-security\/harden-runner@/g) ?? []).length,
305
+ 1,
306
+ "expected exactly one harden-runner step — a second one would bypass the anchor",
307
+ );
308
+ });
309
+
310
+ test("pr-quality.yml: an empty runner keeps the egress audit ON", () => {
311
+ // The regression. `runner: ''` resolves to the hosted ubuntu-latest default
312
+ // at `runs-on` (#493), so the job DOES run on a GitHub-hosted machine — but
313
+ // a gate reading the raw input saw `startsWith('', 'ubuntu-')` → false and
314
+ // skipped. Nothing goes red when a step is skipped, so the egress baseline
315
+ // silently disappears for exactly the callers who never asked to opt out.
316
+ const gate = hardenRunnerGate(QUALITY_TEXT);
317
+ assert.equal(
318
+ evaluate(gate, { runner: "", "enable-harden-runner": true }),
319
+ true,
320
+ "an empty runner lands on hosted ubuntu-latest, so the egress audit must run there",
321
+ );
322
+ });
323
+
324
+ test("pr-quality.yml: the gate agrees with what runs-on resolves to", () => {
325
+ // The real contract, and the one that survives a respelling of either
326
+ // expression: the step runs precisely when the job is on a hosted ubuntu
327
+ // image. Scoring both sides against the same input is what makes a future
328
+ // change to one of them fail here instead of shipping a silent divergence.
329
+ const gate = hardenRunnerGate(QUALITY_TEXT);
330
+ const [runsOn] = runsOnExpressions(QUALITY_TEXT);
331
+ for (const runner of [
332
+ "",
333
+ "ubuntu-latest",
334
+ "ubuntu-24.04",
335
+ "ubuntu-22.04",
336
+ "ubuntu-latest-8-cores",
337
+ "macos-14",
338
+ "windows-latest",
339
+ '["self-hosted","beestera-runner"]',
340
+ '["ubuntu-latest"]',
341
+ ]) {
342
+ assert.equal(
343
+ evaluate(gate, { runner, "enable-harden-runner": true }),
344
+ resolvesToHostedUbuntu(runsOn.expr, runner),
345
+ `runner ${JSON.stringify(runner)}: the gate and the resolved runs-on disagree ` +
346
+ `about whether this job is on a GitHub-hosted ubuntu image`,
347
+ );
348
+ }
349
+ });
350
+
351
+ test("pr-quality.yml: `enable-harden-runner: false` still opts out everywhere", () => {
352
+ // The documented escape hatch. A fallback added to the runner half of the
353
+ // gate must not make the boolean half unreachable — `false && …` yields
354
+ // `false`, but only if the operands stayed in that order.
355
+ const gate = hardenRunnerGate(QUALITY_TEXT);
356
+ for (const runner of ["", "ubuntu-latest", "ubuntu-24.04", '["self-hosted","x"]']) {
357
+ assert.equal(
358
+ evaluate(gate, { runner, "enable-harden-runner": false }),
359
+ false,
360
+ `runner ${JSON.stringify(runner)}: opting out must win regardless of the runner`,
361
+ );
362
+ }
363
+ });
364
+
365
+ test("pr-quality.yml: the toolchain-cache derivation reads an empty runner as hosted", () => {
366
+ // The sibling `inputs.runner` reader, checked rather than assumed. It is
367
+ // correct as written for `runner: ''` — but only incidentally, because
368
+ // `contains('', 'self-hosted')` is false and the derivation is
369
+ // self-hosted-side. Pinning it here means a future inversion to a
370
+ // hosted-side test (`contains(runner, 'ubuntu')`) trips instead of quietly
371
+ // disabling the cache for every empty-runner caller.
372
+ const cache = QUALITY_TEXT.match(/^\s*cache:\s*\$\{\{(.+)\}\}\s*$/m);
373
+ assert.ok(cache, "no `cache: ${{ … }}` value found at the setup-toolchain call site");
374
+ assert.equal(evaluate(cache[1].trim(), { runner: "", "toolchain-cache": "auto" }), "true");
375
+ });