mandrel-platform 1.13.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.
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import assert from "node:assert/strict";
23
- import { execFileSync } from "node:child_process";
23
+ import { spawnSync } from "node:child_process";
24
24
  import { mkdtempSync, statSync, writeFileSync } from "node:fs";
25
25
  import { tmpdir } from "node:os";
26
26
  import { dirname, join } from "node:path";
@@ -38,31 +38,42 @@ const FLOOR = "3.10";
38
38
  * an interpreter name to the `major.minor` it should report; a stub ignores
39
39
  * its arguments and echoes `"<major> <minor>"`, which is the only thing the
40
40
  * script asks of it.
41
+ *
42
+ * `os`, when given, additionally plants a `uname` stub that reports it. The
43
+ * fixture PATH is the process's WHOLE PATH, so a stub `uname` is the only
44
+ * `uname` the script can resolve — which is what makes the Linux-only
45
+ * ABI-first branch assertable from a macOS laptop and a Linux runner alike.
46
+ * Omit it to model a runner where the OS cannot be read at all; the script
47
+ * must still choose an interpreter rather than dying in a command
48
+ * substitution.
41
49
  */
42
- function stubPath(versions) {
50
+ function stubPath(versions, { os = null } = {}) {
43
51
  const dir = mkdtempSync(join(tmpdir(), "semgrep-python-stubs-"));
44
52
  for (const [name, version] of Object.entries(versions)) {
45
53
  const [major, minor] = version.split(".");
46
54
  writeFileSync(join(dir, name), `#!/bin/sh\necho "${major} ${minor}"\n`, { mode: 0o755 });
47
55
  }
56
+ if (os !== null) {
57
+ // `uname -s` and `uname -m` are both answered; the selector only asks for
58
+ // `-s`, and a stub that ignores its flags cannot drift from that.
59
+ writeFileSync(join(dir, "uname"), `#!/bin/sh\necho "${os}"\n`, { mode: 0o755 });
60
+ }
48
61
  return dir;
49
62
  }
50
63
 
51
64
  /**
52
- * Execute the selector with the given PATH and env. Returns the exit status
53
- * and stdout — the script writes its `::error::` annotation to stdout, which
54
- * is where GitHub reads workflow commands from.
65
+ * Execute the selector with the given PATH and env. Returns the exit status,
66
+ * stdout and stderr — the script writes its `::error::` annotation to stdout,
67
+ * which is where GitHub reads workflow commands from, and mirrors the reason
68
+ * (without the prefix) to stderr for callers that capture only that stream.
55
69
  */
56
- function select({ path, floor = FLOOR, pin = PIN }) {
70
+ function select({ path, floor = FLOOR, pin = PIN, lockfileAbi = null }) {
57
71
  const env = { PATH: path };
58
72
  if (floor !== null) env.SEMGREP_PYTHON_FLOOR = floor;
59
73
  if (pin !== null) env.SEMGREP_PIN = pin;
60
- try {
61
- const stdout = execFileSync("/bin/bash", [SCRIPT], { encoding: "utf8", env });
62
- return { status: 0, stdout };
63
- } catch (err) {
64
- return { status: err.status, stdout: err.stdout ?? "" };
65
- }
74
+ if (lockfileAbi !== null) env.SEMGREP_LOCKFILE_ABI = lockfileAbi;
75
+ const r = spawnSync("/bin/bash", [SCRIPT], { encoding: "utf8", env });
76
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
66
77
  }
67
78
 
68
79
  /** Parse the `KEY=value` lines the script emits when it succeeds. */
@@ -215,3 +226,119 @@ test("a malformed floor fails closed", () => {
215
226
  const r = select({ path: stubPath({ python3: "3.12" }), floor: "latest" });
216
227
  assert.equal(r.status, 1, "a malformed floor must not be treated as satisfied");
217
228
  });
229
+
230
+ test("a malformed floor is named as malformed, not as bash's integer error", () => {
231
+ // `3.1O` is the letter O, and it is the exact shape a shape-only check
232
+ // misses: `[0-9]*.[0-9]*` pins the first character of each field, so the
233
+ // typo reached `[ ... -ge "1O" ]` and produced bash's own
234
+ // "integer expression expected" on stderr — a message that names the shell,
235
+ // never the input, and leaves the step to blame the runner's interpreters
236
+ // for a typo in its own configuration.
237
+ const r = select({ path: stubPath({ python3: "3.12" }), floor: "3.1O" });
238
+ assert.equal(r.status, 1, "a malformed floor must not be treated as satisfied");
239
+ assert.match(r.stderr, /malformed/, `the reason must reach stderr: ${r.stderr}`);
240
+ assert.ok(
241
+ r.stderr.includes("3.1O"),
242
+ `stderr must quote the offending value back: ${r.stderr}`,
243
+ );
244
+ const combined = `${r.stdout}${r.stderr}`;
245
+ assert.ok(
246
+ !combined.includes("integer expression expected"),
247
+ `the input must be rejected before it reaches an arithmetic test: ${combined}`,
248
+ );
249
+ });
250
+
251
+ test("a floor with no minor field is malformed rather than half-read", () => {
252
+ // `310` splits to major "310" and a minor equal to the whole string, which
253
+ // an unvalidated read would silently treat as 310.310.
254
+ const r = select({ path: stubPath({ python3: "3.12" }), floor: "310" });
255
+ assert.equal(r.status, 1);
256
+ assert.match(r.stderr, /malformed/);
257
+ });
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // 6. Lockfile-ABI-first probe order on Linux (Story #495)
261
+ // ---------------------------------------------------------------------------
262
+
263
+ test("on Linux the lockfile's ABI interpreter is probed before bare python3", () => {
264
+ // The failure this closes: the hash-pinned install is valid only on a cp312
265
+ // interpreter, so the day a CI image rolls bare `python3` to 3.13 the old
266
+ // order routed the WHOLE fleet onto the un-hash-pinned fallback while an
267
+ // eligible python3.12 sat unused on the same PATH.
268
+ const r = select({
269
+ path: stubPath({ python3: "3.13", "python3.12": "3.12" }, { os: "Linux" }),
270
+ lockfileAbi: "3.12",
271
+ });
272
+ assert.equal(r.status, 0);
273
+ const out = parse(r.stdout);
274
+ assert.equal(out.SEMGREP_PYTHON, "python3.12", "the lockfile ABI must win the probe order");
275
+ assert.equal(out.SEMGREP_PYTHON_VERSION, "3.12");
276
+ });
277
+
278
+ test("on Darwin the same fixture still selects bare python3", () => {
279
+ // The mirror case, and the reason the OS is read rather than assumed: no
280
+ // darwin hashes are generated, so there is no ABI worth steering toward and
281
+ // the historical order must stand untouched.
282
+ const r = select({
283
+ path: stubPath({ python3: "3.13", "python3.12": "3.12" }, { os: "Darwin" }),
284
+ lockfileAbi: "3.12",
285
+ });
286
+ assert.equal(r.status, 0);
287
+ assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
288
+ });
289
+
290
+ test("with no lockfile ABI declared, Linux keeps the historical order", () => {
291
+ const r = select({
292
+ path: stubPath({ python3: "3.13", "python3.12": "3.12" }, { os: "Linux" }),
293
+ });
294
+ assert.equal(r.status, 0);
295
+ assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
296
+ });
297
+
298
+ test("a malformed lockfile ABI is ignored, not fatal", () => {
299
+ // The ABI only reorders probing; the floor is the check that fails closed.
300
+ // Turning a bad optimisation hint into a hard stop would take the security
301
+ // tier down for every consumer over a cosmetic input.
302
+ const r = select({
303
+ path: stubPath({ python3: "3.13", "python3.12": "3.12" }, { os: "Linux" }),
304
+ lockfileAbi: "cp312",
305
+ });
306
+ assert.equal(r.status, 0);
307
+ assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
308
+ });
309
+
310
+ test("the ABI candidate still has to clear the floor", () => {
311
+ // Probing it first is an ordering preference, not an exemption: a
312
+ // below-floor python3.12 must fall through exactly as any other candidate
313
+ // does, because the install would fail on it either way.
314
+ const r = select({
315
+ path: stubPath({ python3: "3.13", "python3.12": "3.9" }, { os: "Linux" }),
316
+ lockfileAbi: "3.12",
317
+ });
318
+ assert.equal(r.status, 0);
319
+ assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
320
+ });
321
+
322
+ test("an absent uname does not break selection", () => {
323
+ // The selector runs under the caller's `set -e`, where an unguarded command
324
+ // substitution on a missing binary would abort the step before any
325
+ // interpreter was chosen.
326
+ const r = select({ path: stubPath({ python3: "3.12" }), lockfileAbi: "3.12" });
327
+ assert.equal(r.status, 0);
328
+ assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
329
+ });
330
+
331
+ test("the fail-closed message reports the order actually probed", () => {
332
+ // A hard-coded list in the message would go stale the moment the ABI
333
+ // reorders it, and the log line is the only place a consumer can see which
334
+ // names were tried.
335
+ const r = select({
336
+ path: stubPath({ python3: "3.9" }, { os: "Linux" }),
337
+ lockfileAbi: "3.12",
338
+ });
339
+ assert.equal(r.status, 1);
340
+ assert.ok(
341
+ r.stdout.includes("Probed, in order: python3.12 python3 "),
342
+ `the probe order must be reported as it ran: ${r.stdout}`,
343
+ );
344
+ });
@@ -79,8 +79,19 @@ const REPO_ROOT = resolve(__dirname, "..");
79
79
  // generate the file is a (harmless but inconsistent) version skew.
80
80
  const DEFAULT_SEMGREP_PIN = "semgrep==1.176.1";
81
81
 
82
- // SHA-256 hashes for every `DEFAULT_SEMGREP_PIN` distribution published on
83
- // PyPI (the four platform wheels + the sdist). pip's `--require-hashes` mode
82
+ // Minimum interpreter for the pin above — semgrep's own `requires_python`,
83
+ // and the same value `SEMGREP_PYTHON_FLOOR` carries in pr-quality.yml's SAST
84
+ // step. Passed to the shared selector below so this script fails with the
85
+ // same named error a runner gets, rather than pip's "could not find a version
86
+ // that satisfies the requirement", which reads like a registry outage.
87
+ const DEFAULT_SEMGREP_PYTHON_FLOOR = "3.10";
88
+
89
+ // SHA-256 hashes for all 8 artifacts published on PyPI for
90
+ // `DEFAULT_SEMGREP_PIN` — the platform wheels plus the sdist. The count is
91
+ // asserted against this map by `check-semgrep-lockfile.test.mjs`: it read
92
+ // "the four platform wheels + the sdist" while the array below held eight,
93
+ // which is exactly the kind of stale gloss that makes a reader trust a
94
+ // partial hash set. pip's `--require-hashes` mode
84
95
  // verifies the downloaded `semgrep` artifact against this set before it is
85
96
  // installed, so a compromised or swapped PyPI artifact for this exact version
86
97
  // is rejected at install time — the same "pin the supply-chain input" posture
@@ -170,11 +181,72 @@ function parseArgs(argv) {
170
181
  return opts;
171
182
  }
172
183
 
184
+ /**
185
+ * Return the interpreter name to build the vendoring venv from, by RUNNING
186
+ * `scripts/select-semgrep-python.sh` — the same selector `pr-quality.yml`'s
187
+ * SAST step sources. Executed rather than sourced: run directly, the script
188
+ * prints `SEMGREP_PYTHON=<name>` / `SEMGREP_PYTHON_VERSION=<x.y>` and exits
189
+ * with the selection's own status, which is the whole interface a non-shell
190
+ * caller needs.
191
+ *
192
+ * A hard-coded `python3` was the alternative, and it is wrong for the reason
193
+ * the selector exists: macOS ships `/usr/bin/python3` = 3.9.6, below the pin's
194
+ * `requires_python`, so this script would install nothing and report pip's
195
+ * resolver error rather than naming the interpreter. Sharing the selector also
196
+ * means the two callers cannot drift on what "acceptable" means.
197
+ *
198
+ * @param {object} [options]
199
+ * @param {NodeJS.ProcessEnv} [options.env] environment the selector runs
200
+ * under. Defaults to this process's. Injectable so the unit suite can hand
201
+ * it a fixture PATH of stub interpreters instead of asserting against
202
+ * whatever Python the host running the tests happens to ship.
203
+ * @returns {string} an interpreter resolvable on PATH
204
+ */
205
+ export function selectPythonInterpreter({ env = process.env } = {}) {
206
+ const selector = join(REPO_ROOT, "scripts", "select-semgrep-python.sh");
207
+ // `/bin/bash` absolutely, never a bare `bash`: the PATH this resolves
208
+ // against is the CALLER'S `env`, so a bare name would make the interpreter
209
+ // itself a function of the very variable under test — and a caller passing a
210
+ // narrowed PATH would get a different bash, or none.
211
+ const result = spawnSync("/bin/bash", [selector], {
212
+ encoding: "utf8",
213
+ env: {
214
+ ...env,
215
+ SEMGREP_PIN: DEFAULT_SEMGREP_PIN,
216
+ SEMGREP_PYTHON_FLOOR: DEFAULT_SEMGREP_PYTHON_FLOOR,
217
+ },
218
+ });
219
+
220
+ // The selector's own `::error::` explains the failure far better than a
221
+ // wrapper could — it names the floor, the pin, the version found and the
222
+ // remedy — so it is surfaced verbatim rather than summarized away.
223
+ if (result.status !== 0) {
224
+ process.stderr.write(result.stdout ?? "");
225
+ process.stderr.write(result.stderr ?? "");
226
+ throw new Error(
227
+ `no interpreter on PATH satisfies Python >= ${DEFAULT_SEMGREP_PYTHON_FLOOR} for ${DEFAULT_SEMGREP_PIN}`
228
+ );
229
+ }
230
+
231
+ const line = (result.stdout ?? "")
232
+ .split("\n")
233
+ .find((l) => l.startsWith("SEMGREP_PYTHON="));
234
+ const selected = line ? line.slice("SEMGREP_PYTHON=".length).trim() : "";
235
+ if (selected === "") {
236
+ throw new Error(
237
+ `${selector} exited 0 without reporting SEMGREP_PYTHON — its run-not-source output contract changed`
238
+ );
239
+ }
240
+ return selected;
241
+ }
242
+
173
243
  /**
174
244
  * Resolve `p/default` against a pinned, ephemeral Semgrep install and return
175
245
  * the full rule list as parsed JSON objects. Mirrors the hermetic-venv
176
246
  * install strategy `pr-quality.yml`'s SAST step uses (Story #92): an
177
- * ephemeral `python3 -m venv` under `mktemp -d`, never the shared user site.
247
+ * ephemeral venv under `mktemp -d`, never the shared user site — built from
248
+ * the interpreter `scripts/select-semgrep-python.sh` picks, so this script and
249
+ * the SAST step cannot disagree about which Python is acceptable.
178
250
  */
179
251
  function resolveRegistryRules(semgrepPin) {
180
252
  const venvDir = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-")), "venv");
@@ -183,7 +255,7 @@ function resolveRegistryRules(semgrepPin) {
183
255
  const reqsFile = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-reqs-")), "semgrep.txt");
184
256
 
185
257
  try {
186
- spawnSync("python3", ["-m", "venv", venvDir], { stdio: "inherit" });
258
+ spawnSync(selectPythonInterpreter(), ["-m", "venv", venvDir], { stdio: "inherit" });
187
259
  const pip = join(venvDir, "bin", "pip");
188
260
  const semgrep = join(venvDir, "bin", "semgrep");
189
261
 
@@ -234,9 +306,15 @@ function resolveRegistryRules(semgrepPin) {
234
306
  );
235
307
  }
236
308
 
309
+ // Dependencies only — semgrep itself is already installed, hash-verified,
310
+ // above. Nothing is added to this list: the package that used to sit here
311
+ // was needed solely because semgrep 1.97.0's transitive
312
+ // `opentelemetry-instrumentation==0.46b0` imported `pkg_resources` at
313
+ // load, which 0.58b0 no longer does. Re-adding it would drag its own
314
+ // advisories back into a venv this script then runs.
237
315
  const install = spawnSync(
238
316
  pip,
239
- ["install", "--quiet", "--disable-pip-version-check", "setuptools", semgrepPin],
317
+ ["install", "--quiet", "--disable-pip-version-check", semgrepPin],
240
318
  { stdio: "inherit" }
241
319
  );
242
320
  if (install.status !== 0) {
@@ -17,7 +17,11 @@
17
17
  import assert from "node:assert/strict";
18
18
  import { test } from "node:test";
19
19
 
20
- import { buildVendoredRuleset } from "./update-semgrep-rules.mjs";
20
+ import { mkdtempSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ import { buildVendoredRuleset, selectPythonInterpreter } from "./update-semgrep-rules.mjs";
21
25
 
22
26
  function rule(id, languages) {
23
27
  return { id, languages, message: "m", severity: "ERROR", metadata: {} };
@@ -148,3 +152,53 @@ test("the committed .semgrep/rules.json vendored file is well-formed and non-emp
148
152
  "pnpm trustPolicy rule must remain in force (Story #132 AC)"
149
153
  );
150
154
  });
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // The vendoring venv is built from the shared interpreter selector (#495)
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Build a stub interpreter directory to be used as the WHOLE PATH.
162
+ *
163
+ * The fixture must be the whole PATH, not a prefix of it: appending a real
164
+ * directory leaks that host's interpreters into the probe, and the selector
165
+ * tries every versioned candidate rather than `python3` alone. On a
166
+ * merged-usr Linux runner `/bin/python3.12` is real and clears the floor, so
167
+ * a `${dir}:/bin` PATH quietly passed on macOS and failed in CI — the exact
168
+ * host-dependence this Story exists to remove from the SAST step.
169
+ *
170
+ * A `uname` stub ships with the interpreters because it is the selector's one
171
+ * external command, and a stub-only PATH could not otherwise resolve it. This
172
+ * mirrors `select-semgrep-python.test.mjs`'s own fixture builder.
173
+ */
174
+ function pythonStubDir(versions, { os = "Linux" } = {}) {
175
+ const dir = mkdtempSync(join(tmpdir(), "updater-python-stubs-"));
176
+ for (const [name, version] of Object.entries(versions)) {
177
+ const [major, minor] = version.split(".");
178
+ writeFileSync(join(dir, name), `#!/bin/sh\necho "${major} ${minor}"\n`, { mode: 0o755 });
179
+ }
180
+ writeFileSync(join(dir, "uname"), `#!/bin/sh\necho "${os}"\n`, { mode: 0o755 });
181
+ return dir;
182
+ }
183
+
184
+ test("selectPythonInterpreter returns the selector's choice, not a hard-coded python3", () => {
185
+ // A fixture PATH rather than the host's real interpreters: the branch that
186
+ // matters is a below-floor `python3` with a versioned sibling, which no CI
187
+ // tier here provides and a developer laptop provides only by accident.
188
+ const dir = pythonStubDir({ python3: "3.9", "python3.12": "3.12" });
189
+
190
+ assert.equal(selectPythonInterpreter({ env: { PATH: dir } }), "python3.12");
191
+ });
192
+
193
+ test("selectPythonInterpreter fails closed when nothing clears the floor", () => {
194
+ // Hard-coding `python3` here installed nothing on macOS system Python and
195
+ // reported pip's resolver error, which names neither the interpreter nor
196
+ // the floor. Sharing the selector means this script fails the same way the
197
+ // SAST step does.
198
+ const dir = pythonStubDir({ python3: "3.9" });
199
+
200
+ assert.throws(
201
+ () => selectPythonInterpreter({ env: { PATH: dir } }),
202
+ /satisfies Python >= 3\.10/,
203
+ );
204
+ });
@@ -11,6 +11,15 @@
11
11
  // The other invariant these tests hold: a missing or malformed report is a
12
12
  // TOOL FAILURE and exits non-zero even in advisory mode. A gate that reports
13
13
  // "no findings" because the linter never ran is worse than no gate at all.
14
+ //
15
+ // Story #496 adds the third class and pins its split from the second: an
16
+ // INFRASTRUCTURE failure — a tool that never arrived at all, because the
17
+ // release CDN blipped, the checksum did not match, or the platform has no
18
+ // pinned entry — obeys the same dial findings do. It used to `exit 1` inside
19
+ // the composite's inline bash, so a CDN blip reddened every consumer's
20
+ // required check while this tier was documented as advisory. What it must
21
+ // never become is silent: an advisory infra failure still says, loudly, that
22
+ // nothing was linted.
14
23
 
15
24
  import { test } from "node:test";
16
25
  import assert from "node:assert/strict";
@@ -30,9 +39,21 @@ import {
30
39
  loadReport,
31
40
  severityRank,
32
41
  resolveEnforcement,
42
+ classifyInfraFailure,
43
+ renderInfraSummary,
44
+ INFRA_FAILURE_ENV,
33
45
  WorkflowLintGateError,
34
46
  } from "../.github/actions/workflow-lint/workflow-lint-gate.mjs";
35
47
 
48
+ // A composite-supplied reason, in the shape action.yml actually emits. Kept
49
+ // free of any URL-shaped literal on purpose: asserting a substring against one
50
+ // is CodeQL js/incomplete-url-substring-sanitization, so the marker asserted
51
+ // on below is the slug prefix, never a host.
52
+ const INFRA_REASON =
53
+ "download-failed: curl exited 22 fetching actionlint_1.7.12_linux_amd64.tar.gz " +
54
+ "from the actionlint release assets.";
55
+ const INFRA_MARKER = "download-failed";
56
+
36
57
  const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
37
58
  const GATE = join(repoRoot, ".github/actions/workflow-lint/workflow-lint-gate.mjs");
38
59
 
@@ -423,3 +444,110 @@ test("CLI: only the literal string 'true' enables enforcement", () => {
423
444
  assert.equal(res.status, 0, `enforce=${JSON.stringify(value)} must stay advisory`);
424
445
  }
425
446
  });
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Infrastructure failure — a tool that never arrived (Story #496, AC-1)
450
+ // ---------------------------------------------------------------------------
451
+
452
+ test("classifyInfraFailure: no reason means there is nothing to report", () => {
453
+ assert.equal(classifyInfraFailure(undefined, false), null);
454
+ assert.equal(classifyInfraFailure("", true), null);
455
+ assert.equal(classifyInfraFailure(" \n ", true), null, "whitespace is not a reason");
456
+ });
457
+
458
+ test("classifyInfraFailure: advisory tier warns and does NOT fail", () => {
459
+ const infra = classifyInfraFailure(INFRA_REASON, { actionlint: false, zizmor: false });
460
+ assert.equal(infra.enforced, false);
461
+ assert.equal(infra.level, "warning");
462
+ assert.equal(infra.exitCode, 0, "a CDN blip must not red an advisory tier");
463
+ assert.ok(infra.message.includes(INFRA_MARKER), infra.message);
464
+ });
465
+
466
+ test("classifyInfraFailure: an enforcing tier fails — tier-wide or per tool", () => {
467
+ assert.equal(classifyInfraFailure(INFRA_REASON, true).exitCode, 1);
468
+ assert.equal(classifyInfraFailure(INFRA_REASON, true).level, "error");
469
+ // A consumer who enforced ONE tool still asked this tier to block, so a
470
+ // gate that could not run is a failure for them.
471
+ const perTool = classifyInfraFailure(INFRA_REASON, { actionlint: true, zizmor: false });
472
+ assert.equal(perTool.enforced, true);
473
+ assert.equal(perTool.exitCode, 1);
474
+ });
475
+
476
+ test("renderInfraSummary states the posture and that nothing was linted", () => {
477
+ const advisory = renderInfraSummary(classifyInfraFailure(INFRA_REASON, false));
478
+ assert.match(advisory, /advisory/);
479
+ assert.match(advisory, /no workflows were linted/i);
480
+ assert.ok(advisory.includes(INFRA_MARKER), advisory);
481
+ const enforcing = renderInfraSummary(classifyInfraFailure(INFRA_REASON, true));
482
+ assert.match(enforcing, /enforcing/);
483
+ assert.doesNotMatch(enforcing, /does \*\*not\*\* fail/);
484
+ });
485
+
486
+ test("CLI: an infra failure exits 0 and warns while nothing is enforced", () => {
487
+ const res = runGate({
488
+ [INFRA_FAILURE_ENV]: INFRA_REASON,
489
+ WORKFLOW_LINT_ENFORCE: "false",
490
+ WORKFLOW_LINT_ENFORCE_ACTIONLINT: "false",
491
+ WORKFLOW_LINT_ENFORCE_ZIZMOR: "false",
492
+ });
493
+ assert.equal(res.status, 0, res.stderr);
494
+ assert.match(res.stdout, /::warning::/);
495
+ assert.doesNotMatch(res.stdout, /::error::/);
496
+ assert.ok(res.stdout.includes(INFRA_MARKER), res.stdout);
497
+ });
498
+
499
+ test("CLI: the same infra failure exits 1 and errors when the tier is enforced", () => {
500
+ const res = runGate({
501
+ [INFRA_FAILURE_ENV]: INFRA_REASON,
502
+ WORKFLOW_LINT_ENFORCE: "true",
503
+ });
504
+ assert.equal(res.status, 1);
505
+ assert.match(res.stdout, /::error::/);
506
+ assert.ok(res.stdout.includes(INFRA_MARKER), res.stdout);
507
+ });
508
+
509
+ test("CLI: a per-tool enforce alone makes an infra failure blocking", () => {
510
+ const res = runGate({
511
+ [INFRA_FAILURE_ENV]: INFRA_REASON,
512
+ WORKFLOW_LINT_ENFORCE: "false",
513
+ WORKFLOW_LINT_ENFORCE_ZIZMOR: "true",
514
+ });
515
+ assert.equal(res.status, 1);
516
+ assert.match(res.stdout, /::error::/);
517
+ });
518
+
519
+ test("CLI: an advisory infra failure is never reported as a clean run", () => {
520
+ // The empty reports the composite writes alongside the signal carry no
521
+ // information. Rendering them as "no findings" would turn a gate that never
522
+ // ran into a green tick that looks audited.
523
+ const res = runGate({
524
+ [INFRA_FAILURE_ENV]: INFRA_REASON,
525
+ WORKFLOW_LINT_ENFORCE: "false",
526
+ });
527
+ assert.equal(res.status, 0, res.stderr);
528
+ assert.doesNotMatch(res.stdout, /no findings/);
529
+ assert.doesNotMatch(res.stdout, /reported no findings/);
530
+ });
531
+
532
+ test("CLI: an unset infra reason leaves the findings path untouched", () => {
533
+ const res = runGate({ [INFRA_FAILURE_ENV]: "" }, { zizmor: [zizmorRow()] });
534
+ assert.equal(res.status, 0, res.stderr);
535
+ assert.match(res.stdout, /excessive-permissions/);
536
+ });
537
+
538
+ test("an infra failure does not relax the missing-report rule", () => {
539
+ // Different class, different answer: a report that never arrived means the
540
+ // linter ran and produced nothing readable, which stays fatal.
541
+ const res = spawnSync(process.execPath, [GATE], {
542
+ encoding: "utf8",
543
+ env: {
544
+ ...process.env,
545
+ ACTIONLINT_REPORT: "/no/such/al.json",
546
+ ZIZMOR_REPORT: "",
547
+ WORKFLOW_LINT_ENFORCE: "false",
548
+ [INFRA_FAILURE_ENV]: "",
549
+ },
550
+ });
551
+ assert.equal(res.status, 1);
552
+ assert.match(res.stderr, /::error::workflow-lint gate/);
553
+ });