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.
- package/README.md +6 -2
- package/package.json +4 -2
- package/scripts/audit-check.mjs +210 -47
- package/scripts/audit-check.test.mjs +479 -49
- package/scripts/check-action-download-retries.mjs +155 -11
- package/scripts/check-action-download-retries.test.mjs +168 -3
- package/scripts/check-advisory-scan-setup.test.mjs +186 -5
- package/scripts/check-coverage-threshold.mjs +112 -17
- package/scripts/check-coverage-threshold.test.mjs +335 -0
- package/scripts/check-husky-hook-modes.test.mjs +134 -0
- package/scripts/check-runner-runs-on.test.mjs +223 -2
- package/scripts/check-semgrep-lockfile.test.mjs +370 -1
- package/scripts/check-workflow-lint-tier.test.mjs +176 -0
- package/scripts/check-workflow-portability.mjs +6 -3
- package/scripts/check-workflow-portability.test.mjs +1 -1
- package/scripts/env-doctor.mjs +647 -90
- package/scripts/env-doctor.test.mjs +772 -3
- package/scripts/fixtures/npm-audit-v2.json +66 -0
- package/scripts/install-git-hooks.mjs +123 -0
- package/scripts/install-git-hooks.test.mjs +160 -0
- package/scripts/issue-intake.test.mjs +351 -21
- package/scripts/runner-toggle.test.mjs +407 -0
- package/scripts/select-semgrep-python.sh +215 -0
- package/scripts/select-semgrep-python.test.mjs +344 -0
- package/scripts/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
|
@@ -38,7 +38,61 @@ const LOCKFILE = "scripts/semgrep-requirements.txt";
|
|
|
38
38
|
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
39
39
|
const UPDATER = "scripts/update-semgrep-rules.mjs";
|
|
40
40
|
|
|
41
|
+
const DOCS = "docs/reusable-workflows.md";
|
|
42
|
+
|
|
41
43
|
const lockfile = readFileSync(LOCKFILE, "utf8");
|
|
44
|
+
const workflow = readFileSync(WORKFLOW, "utf8");
|
|
45
|
+
|
|
46
|
+
const SAST_STEP_HEADER = "- name: SAST (Semgrep, blocking, no SARIF upload)";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Return the SAST step's `run:` body — from its `- name:` line to the next
|
|
50
|
+
* step at the same indentation — with comment-only lines stripped.
|
|
51
|
+
*
|
|
52
|
+
* Both halves earn their place. Scoping to the step is what stops a whole-file
|
|
53
|
+
* `indexOf` from resolving an anchor against an unrelated tier a thousand
|
|
54
|
+
* lines away; dropping comments is what stops it from resolving against PROSE
|
|
55
|
+
* ABOUT the code instead of the code. The selector-before-venv guard below did
|
|
56
|
+
* both: its `select-semgrep-python.sh` hit landed in the side-checkout step's
|
|
57
|
+
* comment block and its `-m venv` hit in a paragraph explaining venvs, so the
|
|
58
|
+
* ordering it asserted was the ordering of two comments.
|
|
59
|
+
*/
|
|
60
|
+
function sastStepCode(text = workflow) {
|
|
61
|
+
const start = text.indexOf(SAST_STEP_HEADER);
|
|
62
|
+
if (start === -1) return "";
|
|
63
|
+
const rest = text.slice(start + SAST_STEP_HEADER.length);
|
|
64
|
+
const end = rest.indexOf("\n - ");
|
|
65
|
+
const step = end === -1 ? rest : rest.slice(0, end);
|
|
66
|
+
return step
|
|
67
|
+
.split("\n")
|
|
68
|
+
.filter((line) => !line.trimStart().startsWith("#"))
|
|
69
|
+
.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True when the SAST step chooses its interpreter before it builds the venv.
|
|
74
|
+
* A predicate rather than a bare assertion so the guard can be exercised
|
|
75
|
+
* against a MUTATED workflow — a test that only ever sees the passing input
|
|
76
|
+
* cannot show it would notice the failure.
|
|
77
|
+
*/
|
|
78
|
+
function selectorPrecedesVenv(text) {
|
|
79
|
+
const code = sastStepCode(text);
|
|
80
|
+
const select = code.indexOf('source "${SELECT_PYTHON}"');
|
|
81
|
+
const venv = code.indexOf('"${SEMGREP_PYTHON}" -m venv');
|
|
82
|
+
if (select === -1 || venv === -1) return false;
|
|
83
|
+
return select < venv;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// semgrep's own `requires_python`, recorded per release. Verified on PyPI
|
|
87
|
+
// 2026-09-10: 1.97.0 was `>=3.8`, 1.136.0 `>=3.9`, and 1.137.0 raised it to
|
|
88
|
+
// `>=3.10` — which is why a runner on macOS system Python (3.9.6) could not
|
|
89
|
+
// install the 1.176.1 pin at all (issue #480).
|
|
90
|
+
//
|
|
91
|
+
// This table is what makes the floor in pr-quality.yml checkable without a
|
|
92
|
+
// network call: a bump to a release with no entry here fails loudly, so
|
|
93
|
+
// "look up the new requires_python" becomes a step of the bump rather than
|
|
94
|
+
// something discovered by a consumer's red CI.
|
|
95
|
+
const SEMGREP_PYTHON_FLOORS = new Map([["1.176.1", "3.10"]]);
|
|
42
96
|
|
|
43
97
|
/**
|
|
44
98
|
* Parse `name==version` requirement lines, ignoring comments and hash
|
|
@@ -118,7 +172,6 @@ test("the lockfile, the workflow, and the rules updater pin the same semgrep", (
|
|
|
118
172
|
const lockVersion = REQS.get("semgrep");
|
|
119
173
|
assert.ok(lockVersion, `${LOCKFILE}: semgrep must be pinned`);
|
|
120
174
|
|
|
121
|
-
const workflow = readFileSync(WORKFLOW, "utf8");
|
|
122
175
|
const wf = workflow.match(/SEMGREP_PIN='semgrep==([^']+)'/);
|
|
123
176
|
assert.ok(wf, `${WORKFLOW}: SEMGREP_PIN not found`);
|
|
124
177
|
assert.equal(wf[1], lockVersion, "workflow SEMGREP_PIN disagrees with the lockfile");
|
|
@@ -203,3 +256,319 @@ test("the header warns about the manylinux_2_34 wheel tag", () => {
|
|
|
203
256
|
"the header must carry a regeneration command",
|
|
204
257
|
);
|
|
205
258
|
});
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// 5. The interpreter floor on the non-lockfile install path (Story #482)
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
test("pr-quality.yml declares an interpreter floor matching the pinned semgrep", () => {
|
|
265
|
+
const version = REQS.get("semgrep");
|
|
266
|
+
const declared = workflow.match(/SEMGREP_PYTHON_FLOOR='([^']+)'/);
|
|
267
|
+
assert.ok(
|
|
268
|
+
declared,
|
|
269
|
+
`${WORKFLOW}: SEMGREP_PYTHON_FLOOR must be declared beside SEMGREP_PIN — without it the SAST step cannot tell a too-old interpreter from a working one`,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const recorded = SEMGREP_PYTHON_FLOORS.get(version);
|
|
273
|
+
assert.ok(
|
|
274
|
+
recorded,
|
|
275
|
+
`no requires_python floor recorded for semgrep ${version} — read it off PyPI and add it to SEMGREP_PYTHON_FLOORS before bumping SEMGREP_PIN`,
|
|
276
|
+
);
|
|
277
|
+
assert.equal(
|
|
278
|
+
declared[1],
|
|
279
|
+
recorded,
|
|
280
|
+
`SEMGREP_PYTHON_FLOOR is ${declared[1]} but semgrep ${version} requires Python >= ${recorded}`,
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("the selector rides the same side-checkout as the lockfile", () => {
|
|
285
|
+
// The sparse-checkout is NON-CONE and lists exact paths, so a script the
|
|
286
|
+
// SAST step sources is absent at run time unless it is named here — and a
|
|
287
|
+
// missing `source` target kills the security tier for every consumer at
|
|
288
|
+
// once. Both files must sit in the one list.
|
|
289
|
+
const start = workflow.indexOf("- name: Checkout Semgrep lockfile");
|
|
290
|
+
assert.notEqual(start, -1, `${WORKFLOW}: the Semgrep side-checkout step was renamed or removed`);
|
|
291
|
+
const step = workflow.slice(start, workflow.indexOf("path: _mandrel-platform-semgrep", start));
|
|
292
|
+
|
|
293
|
+
assert.ok(
|
|
294
|
+
step.includes("scripts/semgrep-requirements.txt"),
|
|
295
|
+
"the side-checkout must still carry the lockfile",
|
|
296
|
+
);
|
|
297
|
+
assert.ok(
|
|
298
|
+
step.includes("scripts/select-semgrep-python.sh"),
|
|
299
|
+
"the side-checkout must carry the interpreter selector the SAST step sources",
|
|
300
|
+
);
|
|
301
|
+
assert.ok(
|
|
302
|
+
step.includes("sparse-checkout-cone-mode: false"),
|
|
303
|
+
"non-cone mode is what makes the exact-path list meaningful",
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("the SAST step selects an interpreter before it creates the venv", () => {
|
|
308
|
+
// A venv inherits the interpreter that built it, so a floor enforced after
|
|
309
|
+
// `-m venv` cannot fix anything. Order is the whole guarantee.
|
|
310
|
+
const code = sastStepCode();
|
|
311
|
+
assert.notEqual(code, "", `${WORKFLOW}: the SAST step was renamed or removed`);
|
|
312
|
+
assert.ok(
|
|
313
|
+
code.includes('source "${SELECT_PYTHON}"'),
|
|
314
|
+
`${WORKFLOW}: the SAST step must source the interpreter selector`,
|
|
315
|
+
);
|
|
316
|
+
assert.ok(
|
|
317
|
+
code.includes('"${SEMGREP_PYTHON}" -m venv'),
|
|
318
|
+
"the venv must be built from the selected interpreter, not from bare python3",
|
|
319
|
+
);
|
|
320
|
+
assert.ok(selectorPrecedesVenv(workflow), "the selector must be sourced BEFORE the venv");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("the selector-before-venv guard fails when the two are swapped", () => {
|
|
324
|
+
// The mutation the guard exists to catch, performed on a copy: move the venv
|
|
325
|
+
// line above the `source` line and the check must go red. Without this, the
|
|
326
|
+
// guard could be resolving anchors that no reordering of the real step would
|
|
327
|
+
// ever move — which is precisely how it passed while comparing two comments.
|
|
328
|
+
const VENV_LINE = ' "${SEMGREP_PYTHON}" -m venv "${venv_dir}"\n';
|
|
329
|
+
const SOURCE_LINE = ' source "${SELECT_PYTHON}"\n';
|
|
330
|
+
assert.ok(workflow.includes(VENV_LINE), `${WORKFLOW}: the venv line changed shape`);
|
|
331
|
+
assert.ok(workflow.includes(SOURCE_LINE), `${WORKFLOW}: the source line changed shape`);
|
|
332
|
+
|
|
333
|
+
const mutated = workflow
|
|
334
|
+
.replace(VENV_LINE, "")
|
|
335
|
+
.replace(SOURCE_LINE, `${VENV_LINE}${SOURCE_LINE}`);
|
|
336
|
+
assert.notEqual(mutated, workflow, "the mutation must actually change the workflow");
|
|
337
|
+
assert.equal(
|
|
338
|
+
selectorPrecedesVenv(mutated),
|
|
339
|
+
false,
|
|
340
|
+
"a venv built before the interpreter is chosen must fail the guard",
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("the non-lockfile path installs exactly SEMGREP_PIN, never a resolved older release", () => {
|
|
345
|
+
// Downgrading to fit an old interpreter re-admits CVE-2026-0994: the newest
|
|
346
|
+
// py3.9-compatible semgrep (1.136.0) pins opentelemetry ~=1.25.0, which caps
|
|
347
|
+
// protobuf below 5.0, and every protobuf 4.x is affected. This path is not
|
|
348
|
+
// hash-pinned and its closure is not OSV-scanned, so it would be silent.
|
|
349
|
+
assert.ok(
|
|
350
|
+
workflow.includes('--retries 3 "${SEMGREP_PIN}"'),
|
|
351
|
+
"the fallback must install the exact pin",
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// Matched as literal substrings rather than a regex alternating the
|
|
355
|
+
// comparison operators: CodeQL reads such a pattern as an attempted HTML-tag
|
|
356
|
+
// filter and raises js/bad-tag-filter at HIGH, which blocks the merge.
|
|
357
|
+
const LOOSE = ["semgrep<", "semgrep>", "semgrep~=", "semgrep!=", 'semgrep=="${'];
|
|
358
|
+
for (const loose of LOOSE) {
|
|
359
|
+
assert.ok(
|
|
360
|
+
!workflow.includes(loose),
|
|
361
|
+
`${WORKFLOW}: '${loose}' would let pip resolve a semgrep other than the pin`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("the Linux hash-pinned branch keeps its exact cp312 equality", () => {
|
|
367
|
+
// Widening this to a `>=` (proposed in issue #480) would route a cp313
|
|
368
|
+
// interpreter onto the lockfile's cp312-only wheels under
|
|
369
|
+
// `--only-binary :all:`, with no sdist fallback — the fleet-red the fallback
|
|
370
|
+
// exists to avoid. The equality is the guard, not the oversight.
|
|
371
|
+
assert.ok(
|
|
372
|
+
workflow.includes('[ "${pyver}" = "312" ]'),
|
|
373
|
+
`${WORKFLOW}: the cp312 test must stay an equality`,
|
|
374
|
+
);
|
|
375
|
+
assert.ok(
|
|
376
|
+
workflow.includes('The `= "312"` below is an EQUALITY on purpose'),
|
|
377
|
+
"the equality must carry a comment saying why a >= test would be wrong",
|
|
378
|
+
);
|
|
379
|
+
assert.ok(
|
|
380
|
+
workflow.includes("sdist fallback"),
|
|
381
|
+
"that comment must name the missing sdist fallback as the mechanism",
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("the floor added no workflow_call input and no new job permission", () => {
|
|
386
|
+
// A consumer-set semgrep pin would re-open the same un-scanned downgrade
|
|
387
|
+
// hole operator-side; `enable-sast: false` is the escape hatch. And a new
|
|
388
|
+
// job-level permission is a COMPILE-TIME break for every caller of this
|
|
389
|
+
// reusable workflow, not a runtime one.
|
|
390
|
+
assert.ok(!workflow.includes("semgrep-pin:"), "no semgrep-pin input — see the Story's non-goals");
|
|
391
|
+
assert.ok(!workflow.includes("python-version:"), "no python-version input — see the Story's non-goals");
|
|
392
|
+
|
|
393
|
+
const start = workflow.indexOf("name: Security (secret scan + SAST)");
|
|
394
|
+
assert.notEqual(start, -1, `${WORKFLOW}: the security job was renamed`);
|
|
395
|
+
const header = workflow.slice(start, workflow.indexOf("steps:", start));
|
|
396
|
+
const granted = header
|
|
397
|
+
.split("\n")
|
|
398
|
+
.map((l) => l.trim())
|
|
399
|
+
.filter((l) => l === "contents: read" || l === "actions: write");
|
|
400
|
+
assert.equal(
|
|
401
|
+
granted.length,
|
|
402
|
+
2,
|
|
403
|
+
"the security job's permissions must remain exactly contents: read + actions: write",
|
|
404
|
+
);
|
|
405
|
+
assert.ok(!header.includes("id-token:"), "no new permission was needed for an interpreter floor");
|
|
406
|
+
assert.ok(!header.includes("packages:"), "no new permission was needed for an interpreter floor");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// 6. The hash-pinned path's real preconditions, and an audible fallback (#495)
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
test("the hash-pinned branch tests architecture as well as OS and ABI", () => {
|
|
414
|
+
// The lockfile was resolved with `--platform manylinux*_x86_64` only, and
|
|
415
|
+
// PyPI ships a separate `manylinux_2_34_aarch64` semgrep wheel that no entry
|
|
416
|
+
// covers. On an arm64 Linux runner the OS+ABI test passed and
|
|
417
|
+
// `--require-hashes --only-binary` hard-failed on a hash mismatch — a red
|
|
418
|
+
// that reads like a tampered artifact rather than an unsupported arch.
|
|
419
|
+
const code = sastStepCode();
|
|
420
|
+
assert.ok(
|
|
421
|
+
code.includes('[ "${pyver}" = "312" ]'),
|
|
422
|
+
`${WORKFLOW}: the cp312 test must stay an equality`,
|
|
423
|
+
);
|
|
424
|
+
assert.ok(
|
|
425
|
+
code.includes('[ "${runner_arch}" = "x86_64" ]'),
|
|
426
|
+
`${WORKFLOW}: the hash-pinned branch must also require x86_64`,
|
|
427
|
+
);
|
|
428
|
+
assert.ok(
|
|
429
|
+
code.includes('runner_arch="$(uname -m)"'),
|
|
430
|
+
"the architecture must be read from the runner, not assumed",
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("the lockfile's declared ABI matches the branch's cp equality", () => {
|
|
435
|
+
// Two spellings of one fact: `3.12` steers the interpreter probe and `312`
|
|
436
|
+
// gates the install. A bump that moved one and not the other would probe for
|
|
437
|
+
// an interpreter the install then rejects.
|
|
438
|
+
const abi = workflow.match(/export SEMGREP_LOCKFILE_ABI='([^']+)'/);
|
|
439
|
+
assert.ok(abi, `${WORKFLOW}: SEMGREP_LOCKFILE_ABI must be exported for the selector`);
|
|
440
|
+
assert.equal(
|
|
441
|
+
abi[1].replace(".", ""),
|
|
442
|
+
"312",
|
|
443
|
+
`SEMGREP_LOCKFILE_ABI is ${abi[1]} but the hash-pinned branch installs only on cp312`,
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("every reason for leaving the hash-pinned path is warned about by name", () => {
|
|
448
|
+
// The single line this replaced said "Non-Linux runner (or no lockfile)" on
|
|
449
|
+
// all four, so the two that actually cost the fleet its supply-chain pin — a
|
|
450
|
+
// rolled interpreter and an unsupported arch — were reported as neither of
|
|
451
|
+
// them, at log level, on a green job.
|
|
452
|
+
const code = sastStepCode();
|
|
453
|
+
const reasons = code.split("\n").filter((l) => l.includes("fallback_reason="));
|
|
454
|
+
assert.equal(reasons.length, 4, "expected one named reason per precondition");
|
|
455
|
+
|
|
456
|
+
for (const [precondition, phrase] of [
|
|
457
|
+
["OS", "not Linux"],
|
|
458
|
+
["architecture", "not x86_64"],
|
|
459
|
+
["interpreter ABI", "not the lockfile's cp312"],
|
|
460
|
+
["missing lockfile", "lockfile is missing"],
|
|
461
|
+
]) {
|
|
462
|
+
assert.ok(
|
|
463
|
+
reasons.some((line) => line.includes(phrase)),
|
|
464
|
+
`no fallback reason names the ${precondition} precondition (looked for "${phrase}")`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const warning = code
|
|
469
|
+
.split("\n")
|
|
470
|
+
.find((l) => l.includes("::warning::") && l.includes("${fallback_reason}"));
|
|
471
|
+
assert.ok(
|
|
472
|
+
warning,
|
|
473
|
+
"the fallback must emit a ::warning:: carrying the resolved reason, not a log line",
|
|
474
|
+
);
|
|
475
|
+
assert.ok(
|
|
476
|
+
warning.includes("WITHOUT hash-pinning"),
|
|
477
|
+
"the warning must say what was actually lost",
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("the SAST exclude list covers every platform side-checkout", () => {
|
|
482
|
+
// Two directories are materialized from mandrel-platform's own tree —
|
|
483
|
+
// `_mandrel-platform-sast` (the ruleset) and `_mandrel-platform-semgrep`
|
|
484
|
+
// (the lockfile + selector). Only the first was ever excluded, so platform
|
|
485
|
+
// source could enter a caller's finding set. The glob covers the next one
|
|
486
|
+
// too.
|
|
487
|
+
const code = sastStepCode();
|
|
488
|
+
assert.ok(
|
|
489
|
+
code.includes("--exclude '_mandrel-platform-*'"),
|
|
490
|
+
`${WORKFLOW}: the exclude list must cover _mandrel-platform-* , not one checkout by name`,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// Both checkout paths must fall under the glob, or the exclude is cosmetic.
|
|
494
|
+
for (const path of ["_mandrel-platform-sast", "_mandrel-platform-semgrep"]) {
|
|
495
|
+
assert.ok(
|
|
496
|
+
workflow.includes(`path: ${path}`),
|
|
497
|
+
`${WORKFLOW}: expected a side-checkout at ${path}`,
|
|
498
|
+
);
|
|
499
|
+
assert.ok(path.startsWith("_mandrel-platform-"), `${path} is not covered by the glob`);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// 7. The rules updater shares the selector, and counts its own hashes (#495)
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
test("the rules updater builds its venv from the shared interpreter selector", () => {
|
|
508
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
509
|
+
assert.ok(
|
|
510
|
+
updater.includes("select-semgrep-python.sh"),
|
|
511
|
+
`${UPDATER}: the venv interpreter must come from the same selector the SAST step uses`,
|
|
512
|
+
);
|
|
513
|
+
assert.ok(
|
|
514
|
+
!/spawnSync\("python3"/.test(updater),
|
|
515
|
+
`${UPDATER}: a hard-coded python3 installs nothing on a macOS system interpreter`,
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("the rules updater no longer installs setuptools", () => {
|
|
520
|
+
// It was there only because semgrep 1.97.0's transitive
|
|
521
|
+
// opentelemetry-instrumentation 0.46b0 imported pkg_resources at load.
|
|
522
|
+
// 0.58b0 does not, and 80.9.0 — the last release shipping pkg_resources —
|
|
523
|
+
// carries its own advisories.
|
|
524
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
525
|
+
assert.ok(
|
|
526
|
+
!/["']setuptools["']/.test(updater),
|
|
527
|
+
`${UPDATER}: setuptools must not be reinstalled into the vendoring venv`,
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("the SEMGREP_HASHES comment states the artifact count it actually carries", () => {
|
|
532
|
+
// The comment read "the four platform wheels + the sdist" while the array
|
|
533
|
+
// held eight. A reader checking whether the hash set is complete is reading
|
|
534
|
+
// the comment, so a stale count is a supply-chain claim that is not true.
|
|
535
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
536
|
+
const version = REQS.get("semgrep");
|
|
537
|
+
|
|
538
|
+
const mapStart = updater.indexOf("const SEMGREP_HASHES = {");
|
|
539
|
+
assert.notEqual(mapStart, -1, `${UPDATER}: SEMGREP_HASHES not found`);
|
|
540
|
+
const entryStart = updater.indexOf(`"${version}": [`, mapStart);
|
|
541
|
+
assert.notEqual(entryStart, -1, `${UPDATER}: SEMGREP_HASHES has no entry for ${version}`);
|
|
542
|
+
const entry = updater.slice(entryStart, updater.indexOf("],", entryStart));
|
|
543
|
+
const recorded = entry.split("sha256:").length - 1;
|
|
544
|
+
assert.ok(recorded > 0, `${UPDATER}: the ${version} entry carries no hashes`);
|
|
545
|
+
|
|
546
|
+
const comment = updater.slice(0, mapStart);
|
|
547
|
+
const claimed = comment.match(/(\d+) artifacts/);
|
|
548
|
+
assert.ok(
|
|
549
|
+
claimed,
|
|
550
|
+
`${UPDATER}: the SEMGREP_HASHES comment must state how many artifacts it pins`,
|
|
551
|
+
);
|
|
552
|
+
assert.equal(
|
|
553
|
+
Number.parseInt(claimed[1], 10),
|
|
554
|
+
recorded,
|
|
555
|
+
`the comment claims ${claimed[1]} artifacts but the ${version} entry pins ${recorded}`,
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
// 8. The documented behaviour is the shipped behaviour (#495)
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
test("the SAST docs describe the ABI-first probe order and the fallback warning", () => {
|
|
564
|
+
// A consumer debugging a lost hash pin reads this section, not the workflow.
|
|
565
|
+
const docs = readFileSync(DOCS, "utf8");
|
|
566
|
+
const start = docs.indexOf("> **Python interpreter floor.**");
|
|
567
|
+
assert.notEqual(start, -1, `${DOCS}: the SAST interpreter section was renamed or removed`);
|
|
568
|
+
const section = docs.slice(start, docs.indexOf("#### SAST ruleset provenance", start));
|
|
569
|
+
|
|
570
|
+
assert.match(section, /SEMGREP_LOCKFILE_ABI|lockfile.{0,40}ABI/i, "the ABI input must be named");
|
|
571
|
+
assert.match(section, /probed first|first on Linux/i, "the probe order must be stated");
|
|
572
|
+
assert.match(section, /::warning::/, "the fallback warning must be documented");
|
|
573
|
+
assert.match(section, /x86_64/, "the architecture precondition must be documented");
|
|
574
|
+
});
|
|
@@ -65,6 +65,44 @@ export function jobKeys(block) {
|
|
|
65
65
|
.map((l) => l.trim().split(":")[0]);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The composite's single `run:` block — everything after its `run: |` line.
|
|
70
|
+
* The infra-failure assertions below are about SHELL CONTROL FLOW, so they
|
|
71
|
+
* must not be able to match the action's header comments, which describe that
|
|
72
|
+
* flow in prose using the same words.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} text
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function runBlock(text) {
|
|
78
|
+
const marker = "\n run: |\n";
|
|
79
|
+
const start = text.indexOf(marker);
|
|
80
|
+
assert.notEqual(start, -1, "the composite's run: block was not found");
|
|
81
|
+
const block = text.slice(start + marker.length);
|
|
82
|
+
assert.ok(block.includes("set -euo pipefail"), "run: block did not resolve to the script");
|
|
83
|
+
return block;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The body of one shell function defined at the run-block's own indent.
|
|
88
|
+
* Scoped like `jobBlock`: the point is to assert what `infra_fail` does, not
|
|
89
|
+
* that the two words appear somewhere in a 300-line file.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} run @param {string} name
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function shellFunction(run, name) {
|
|
95
|
+
const lines = run.split(/\r?\n/);
|
|
96
|
+
const start = lines.findIndex((l) => l.trim() === `${name}() {`);
|
|
97
|
+
assert.notEqual(start, -1, `shell function '${name}' not defined`);
|
|
98
|
+
const body = [];
|
|
99
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
100
|
+
if (lines[i].trim() === "}") return body.join("\n");
|
|
101
|
+
body.push(lines[i]);
|
|
102
|
+
}
|
|
103
|
+
assert.fail(`shell function '${name}' is unterminated`);
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
/**
|
|
69
107
|
* The block declaring one composite-action input. Anchored to a line start
|
|
70
108
|
* because the action's header carries USAGE COMMENTS that contain the same
|
|
@@ -322,3 +360,141 @@ test("the docs explain the advisory posture, the dial, shellcheck and provenance
|
|
|
322
360
|
assert.match(section, /checksum/i);
|
|
323
361
|
assert.match(section, /no checksums file/i, "the zizmor provenance caveat must be recorded");
|
|
324
362
|
});
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// Infrastructure failure routes to the gate, not to `exit 1` (Story #496, AC-2)
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
//
|
|
368
|
+
// Before #496 a release-CDN blip on either binary `exit 1`-ed inside this
|
|
369
|
+
// inline bash BEFORE the gate script was ever invoked, so the enforcement dial
|
|
370
|
+
// the gate owns could not see it: an advisory tier reddened every consumer's
|
|
371
|
+
// required check on someone else's outage. These assertions pin the routing —
|
|
372
|
+
// the behaviour on the other side of it is unit-tested end-to-end in
|
|
373
|
+
// `workflow-lint-gate.test.mjs`, which runs the real gate.
|
|
374
|
+
|
|
375
|
+
test("the gate is the ONE exit from this step — every path funnels through it", () => {
|
|
376
|
+
const run = runBlock(ACTION);
|
|
377
|
+
assert.equal(
|
|
378
|
+
run.split("workflow-lint-gate.mjs").length - 1,
|
|
379
|
+
1,
|
|
380
|
+
"the gate must be invoked from exactly one place (`run_gate`), or the infra " +
|
|
381
|
+
"path and the normal path can drift into disagreeing about the dial",
|
|
382
|
+
);
|
|
383
|
+
const gate = shellFunction(run, "run_gate");
|
|
384
|
+
assert.match(
|
|
385
|
+
gate,
|
|
386
|
+
/WORKFLOW_LINT_INFRA_FAILURE="\$\{1:-\}"/,
|
|
387
|
+
"the reason travels to the gate as an env var, empty on the normal path",
|
|
388
|
+
);
|
|
389
|
+
assert.match(gate, /WORKFLOW_LINT_ENFORCE="\$\{WORKFLOW_LINT_ENFORCE\}"/);
|
|
390
|
+
assert.match(gate, /node "\$\{ACTION_PATH\}\/workflow-lint-gate\.mjs"/);
|
|
391
|
+
assert.match(run, /^\s*run_gate ""$/m, "the normal path still invokes the gate");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("infra_fail writes empty reports and hands the reason to the gate", () => {
|
|
395
|
+
const body = shellFunction(runBlock(ACTION), "infra_fail");
|
|
396
|
+
assert.match(body, /echo "\[\]" > "\$\{al_report\}"/);
|
|
397
|
+
assert.match(body, /echo "\[\]" > "\$\{zz_report\}"/);
|
|
398
|
+
assert.match(body, /run_gate "\$1"/, "the failure reason is forwarded, not swallowed");
|
|
399
|
+
assert.match(
|
|
400
|
+
body,
|
|
401
|
+
/exit "\$infra_code"/,
|
|
402
|
+
"the step's exit code is the GATE's verdict, not a hard-coded 1",
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("the download branches route to infra_fail rather than exiting 1", () => {
|
|
407
|
+
const run = runBlock(ACTION);
|
|
408
|
+
// curl's failure must be CAPTURED — an unguarded curl under `set -e` aborts
|
|
409
|
+
// the step before any of this routing can run.
|
|
410
|
+
for (const varName of ["al_curl", "zz_curl"]) {
|
|
411
|
+
assert.ok(run.includes(`${varName}=$?`), `${varName} does not capture curl's exit code`);
|
|
412
|
+
assert.ok(
|
|
413
|
+
run.includes(`if [ "$${varName}" -ne 0 ]; then`),
|
|
414
|
+
`${varName} is captured but never branched on`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
assert.equal(
|
|
418
|
+
run.split('infra_fail "download-failed:').length - 1,
|
|
419
|
+
2,
|
|
420
|
+
"both downloads (actionlint and zizmor) must route to infra_fail",
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("the checksum-mismatch branches route to infra_fail rather than exiting 1", () => {
|
|
425
|
+
const run = runBlock(ACTION);
|
|
426
|
+
assert.equal(
|
|
427
|
+
run.split('infra_fail "checksum-mismatch:').length - 1,
|
|
428
|
+
2,
|
|
429
|
+
"both checksum comparisons must route to infra_fail",
|
|
430
|
+
);
|
|
431
|
+
// Trust is unchanged: a mismatch still never reaches extract/execute.
|
|
432
|
+
const mismatch = run.slice(run.indexOf('infra_fail "checksum-mismatch: actionlint'));
|
|
433
|
+
assert.ok(
|
|
434
|
+
mismatch.indexOf("tar -xzf") > 0,
|
|
435
|
+
"the mismatch branch must precede extraction — an unverified archive is never opened",
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("an unmapped platform routes to infra_fail from all four sites", () => {
|
|
440
|
+
const run = runBlock(ACTION);
|
|
441
|
+
assert.equal(
|
|
442
|
+
run.split('infra_fail "unmapped-platform:').length - 1,
|
|
443
|
+
4,
|
|
444
|
+
"unsupported OS, unsupported arch, and the two unmapped checksum slugs",
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("no infrastructure path exits 1 directly any more", () => {
|
|
449
|
+
const run = runBlock(ACTION);
|
|
450
|
+
const hardExits = run
|
|
451
|
+
.split(/\r?\n/)
|
|
452
|
+
.filter((l) => l.trim() === "exit 1" || l.trim().endsWith("; exit 1 ;;"));
|
|
453
|
+
assert.deepEqual(
|
|
454
|
+
hardExits,
|
|
455
|
+
[],
|
|
456
|
+
"a bare `exit 1` here bypasses the gate's dial entirely, which is the defect " +
|
|
457
|
+
"Story #496 removed. Route the failure through infra_fail instead.",
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("a genuine TOOL failure still exits non-zero regardless of the dial", () => {
|
|
462
|
+
// The split matters: a linter that RAN and crashed is about the caller's
|
|
463
|
+
// tree, not about a third-party CDN, so it is red either way.
|
|
464
|
+
const run = runBlock(ACTION);
|
|
465
|
+
assert.match(run, /exit "\$al_code"/);
|
|
466
|
+
assert.match(run, /exit "\$zz_code"/);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// The posture is documented on both surfaces (Story #496, AC-3)
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
test("the enforce input describes the advisory infra-failure posture", () => {
|
|
474
|
+
const desc = actionInput(ACTION, "enforce").replace(/\s+/g, " ");
|
|
475
|
+
assert.match(desc, /INFRASTRUCTURE FAILURES follow this same dial/);
|
|
476
|
+
assert.match(desc, /exits 0 while the tier is advisory/);
|
|
477
|
+
assert.match(
|
|
478
|
+
desc,
|
|
479
|
+
/TOOL failure/,
|
|
480
|
+
"the description must still name the class that is red regardless",
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("the docs no longer claim infrastructure failures always fail the tier", () => {
|
|
485
|
+
assert.ok(
|
|
486
|
+
!/always fails? the tier/i.test(DOCS),
|
|
487
|
+
"the retired sentence (download/checksum/tool failures always fail the tier) " +
|
|
488
|
+
"is now wrong for two of those three classes",
|
|
489
|
+
);
|
|
490
|
+
const start = DOCS.indexOf("### Workflow lint tier (`enable-workflow-lint`)");
|
|
491
|
+
assert.notEqual(start, -1);
|
|
492
|
+
const section = DOCS.slice(start, start + 9000);
|
|
493
|
+
assert.match(section, /infrastructure failure/i);
|
|
494
|
+
assert.match(
|
|
495
|
+
section,
|
|
496
|
+
/exits 0 while the tier\s+is advisory/,
|
|
497
|
+
"the section must state the advisory-until-enforced behaviour explicitly",
|
|
498
|
+
);
|
|
499
|
+
assert.match(section, /never extracted or executed/, "the trust caveat must survive");
|
|
500
|
+
});
|
|
@@ -48,8 +48,11 @@
|
|
|
48
48
|
* are no logs, and `gh pr checks` reports `pending 0`, indistinguishable
|
|
49
49
|
* from a busy fleet. Normalize to
|
|
50
50
|
* `fromJSON(startsWith(inputs.runner, '[') && inputs.runner ||
|
|
51
|
-
* format('"{0}"', inputs.runner))`, which accepts both
|
|
52
|
-
* shapes
|
|
51
|
+
* format('"{0}"', inputs.runner || 'ubuntu-latest'))`, which accepts both
|
|
52
|
+
* documented shapes and lands an EMPTY value on the default label rather
|
|
53
|
+
* than on `""` — a `default:` fires only when the key is absent, so an
|
|
54
|
+
* empty-but-present value reaches the site and would queue forever too
|
|
55
|
+
* (#493). (Caused #419 / v1.5.0; behaviour is covered by
|
|
53
56
|
* check-runner-runs-on.test.mjs, which evaluates the real expression
|
|
54
57
|
* rather than matching its spelling.)
|
|
55
58
|
*
|
|
@@ -301,7 +304,7 @@ export function checkWorkflowContent(content) {
|
|
|
301
304
|
`JSON-encoded label-array string resolves to ONE unmatchable label ` +
|
|
302
305
|
`name and the job queues until the 24-hour timeout, silently. ` +
|
|
303
306
|
`Normalize it: \`fromJSON(startsWith(inputs.runner, '[') && ` +
|
|
304
|
-
`inputs.runner || format('"{0}"', inputs.runner))\`.`,
|
|
307
|
+
`inputs.runner || format('"{0}"', inputs.runner || 'ubuntu-latest'))\`.`,
|
|
305
308
|
});
|
|
306
309
|
});
|
|
307
310
|
|
|
@@ -223,7 +223,7 @@ function runnerWorkflow(value) {
|
|
|
223
223
|
|
|
224
224
|
const NORMALIZED =
|
|
225
225
|
"${{ fromJSON(startsWith(inputs.runner, '[') && inputs.runner " +
|
|
226
|
-
"|| format('\"{0}\"', inputs.runner)) }}";
|
|
226
|
+
"|| format('\"{0}\"', inputs.runner || 'ubuntu-latest')) }}";
|
|
227
227
|
|
|
228
228
|
test("Rule 4: a raw `runs-on: ${{ inputs.runner }}` is a violation", () => {
|
|
229
229
|
const violations = checkWorkflowContent(runnerWorkflow("${{ inputs.runner }}"));
|