mandrel-platform 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,7 +44,7 @@
44
44
  "provenance": true
45
45
  },
46
46
  "dependencies": {
47
- "mandrel": "^2.35.0"
47
+ "mandrel": "^2.38.0"
48
48
  },
49
49
  "scripts": {
50
50
  "typecheck": "node --input-type=module --eval 'process.exit(0)'",
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-runner-runs-on.test.mjs — regression guard for the `runner` input's
4
+ * two documented shapes (Story #421).
5
+ *
6
+ * The bug this pins: every `runs-on:` site consumed the input raw as
7
+ * `${{ inputs.runner }}`. GitHub does not parse a JSON-array *string* in that
8
+ * position — it takes the entire text as ONE label name. A caller passing the
9
+ * documented `'["self-hosted","my-runner"]'` therefore targeted a label no
10
+ * runner carries, and every tier sat `queued` until the 24-hour timeout.
11
+ *
12
+ * It was silent. Nothing went red, no job started, so there were no logs, and
13
+ * `gh pr checks` reported `pending 0` — indistinguishable from a busy fleet.
14
+ * The tell was that caller-owned jobs went green while every reusable-workflow
15
+ * tier reported `pending 0`.
16
+ *
17
+ * Why this is not a grep. A `grep` cannot tell a resolvable `runs-on`
18
+ * expression from an unresolvable one — that is precisely how this shipped,
19
+ * past static checks that all passed. Asserting the expression's spelling
20
+ * would pin the wording and not the behaviour. So this EXTRACTS each real
21
+ * expression from the workflow and EVALUATES it under Actions semantics, the
22
+ * same read-then-execute approach as check-toolchain-cache-default.test.mjs.
23
+ *
24
+ * Run: node --test scripts/check-runner-runs-on.test.mjs
25
+ */
26
+
27
+ import assert from "node:assert/strict";
28
+ import { test } from "node:test";
29
+ import { readFileSync, readdirSync } from "node:fs";
30
+ import { evaluate } from "./lib/actions-expression.mjs";
31
+
32
+ const WORKFLOW_DIR = ".github/workflows";
33
+
34
+ /** Every workflow that declares a `runner` workflow_call input. */
35
+ function runnerWorkflows() {
36
+ return readdirSync(WORKFLOW_DIR)
37
+ .filter((f) => f.endsWith(".yml"))
38
+ .map((f) => ({ file: `${WORKFLOW_DIR}/${f}`, text: readFileSync(`${WORKFLOW_DIR}/${f}`, "utf8") }))
39
+ .filter(({ text }) => /^\s{6}runner:$/m.test(text));
40
+ }
41
+
42
+ /**
43
+ * The `${{ … }}` bodies of every `runs-on:` value that reads `inputs.runner`.
44
+ * Scans lines rather than building a regex around the file — the block
45
+ * boundary is a single line, and a dynamically-constructed regex is a SAST
46
+ * finding that buys nothing here.
47
+ */
48
+ function runsOnExpressions(text) {
49
+ const out = [];
50
+ for (const [idx, line] of text.split("\n").entries()) {
51
+ const trimmed = line.trim();
52
+ if (!trimmed.startsWith("runs-on:")) continue;
53
+ if (!trimmed.includes("inputs.runner")) continue;
54
+ const m = trimmed.match(/^runs-on:\s*\$\{\{(.+)\}\}\s*$/);
55
+ assert.ok(m, `line ${idx + 1}: runs-on reads inputs.runner but is not a single expression: ${trimmed}`);
56
+ out.push({ line: idx + 1, expr: m[1].trim() });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const WORKFLOWS = runnerWorkflows();
62
+
63
+ test("every workflow taking a `runner` input is covered by this guard", () => {
64
+ // A new reusable workflow with a `runner` input must not slip past unseen.
65
+ const names = WORKFLOWS.map(({ file }) => file.split("/").pop()).sort();
66
+ assert.deepEqual(names, [
67
+ "advisory-scan.yml",
68
+ "deploy-cloudflare.yml",
69
+ "pr-quality.yml",
70
+ "release-automation.yml",
71
+ "secret-scan-push.yml",
72
+ "uptime-apply.yml",
73
+ ]);
74
+ });
75
+
76
+ test("no `runs-on:` consumes `inputs.runner` raw", () => {
77
+ // The exact shape that shipped the bug. Kept as a cheap, legible tripwire
78
+ // alongside the behavioural assertions below.
79
+ for (const { file, text } of WORKFLOWS) {
80
+ for (const { line, expr } of runsOnExpressions(text)) {
81
+ assert.notEqual(
82
+ expr,
83
+ "inputs.runner",
84
+ `${file}:${line}: \`runs-on\` consumes the input raw — a JSON-array ` +
85
+ `string resolves to one unmatchable label name and the job queues forever`,
86
+ );
87
+ }
88
+ }
89
+ });
90
+
91
+ for (const { file, text } of WORKFLOWS) {
92
+ const sites = runsOnExpressions(text);
93
+
94
+ test(`${file}: has at least one runner-driven runs-on site`, () => {
95
+ assert.ok(sites.length > 0, "expected this workflow to drive runs-on from inputs.runner");
96
+ });
97
+
98
+ test(`${file}: a bare label resolves to that same string`, () => {
99
+ for (const { line, expr } of sites) {
100
+ assert.equal(evaluate(expr, { runner: "ubuntu-latest" }), "ubuntu-latest", `${file}:${line}`);
101
+ assert.equal(evaluate(expr, { runner: "beestera-runner" }), "beestera-runner", `${file}:${line}`);
102
+ }
103
+ });
104
+
105
+ test(`${file}: the documented JSON-array string resolves to a label ARRAY`, () => {
106
+ for (const { line, expr } of sites) {
107
+ assert.deepEqual(
108
+ evaluate(expr, { runner: '["self-hosted","beestera-runner"]' }),
109
+ ["self-hosted", "beestera-runner"],
110
+ `${file}:${line}: the documented array form must yield real labels, ` +
111
+ `not one label named after the whole JSON text`,
112
+ );
113
+ }
114
+ });
115
+
116
+ test(`${file}: a malformed array-shaped value fails loudly, not into the label branch`, () => {
117
+ // A silent fallback to the raw string would rebuild the original failure
118
+ // mode — an unmatchable label, queued until the 24-hour timeout — behind a
119
+ // fix that claims to have removed it.
120
+ for (const { line, expr } of sites) {
121
+ assert.throws(
122
+ () => evaluate(expr, { runner: '["unterminated' }),
123
+ /could not parse/,
124
+ `${file}:${line}: a '['-leading value that is not valid JSON must be a hard error`,
125
+ );
126
+ }
127
+ });
128
+
129
+ test(`${file}: every runs-on site resolves identically`, () => {
130
+ // One workflow must not drift into two dialects of the same decision.
131
+ const rendered = sites.map(({ expr }) =>
132
+ JSON.stringify(evaluate(expr, { runner: '["self-hosted","x"]' })),
133
+ );
134
+ assert.equal(new Set(rendered).size, 1, `${file}: runs-on sites disagree: ${rendered.join(" | ")}`);
135
+ });
136
+ }
137
+
138
+ test("the documented array form resolves AND derives toolchain-cache 'false'", () => {
139
+ // The coupling the gap report found: before this fix the only `runner` value
140
+ // that derived the correct cache posture was the one that never reached a
141
+ // runner, and the only value that reached a runner derived the wrong posture.
142
+ // A self-hosted caller could not get both right from the documented
143
+ // interface. Assert the pairing is now reachable in a single value.
144
+ const quality = readFileSync(`${WORKFLOW_DIR}/pr-quality.yml`, "utf8");
145
+ const runner = '["self-hosted","beestera-runner"]';
146
+
147
+ const [runsOn] = runsOnExpressions(quality);
148
+ assert.deepEqual(evaluate(runsOn.expr, { runner }), ["self-hosted", "beestera-runner"]);
149
+
150
+ const cache = quality.match(/^\s*cache:\s*\$\{\{(.+)\}\}\s*$/m);
151
+ assert.ok(cache, "no `cache: ${{ … }}` value found at the setup-toolchain call site");
152
+ assert.equal(evaluate(cache[1].trim(), { runner, "toolchain-cache": "auto" }), "false");
153
+ });
@@ -31,6 +31,7 @@
31
31
  import assert from "node:assert/strict";
32
32
  import { test } from "node:test";
33
33
  import { readFileSync } from "node:fs";
34
+ import { evaluate } from "./lib/actions-expression.mjs";
34
35
 
35
36
  const QUALITY = ".github/workflows/pr-quality.yml";
36
37
  const ADVISORY = ".github/workflows/advisory-scan.yml";
@@ -68,146 +69,13 @@ function inputDefault(text, name, file) {
68
69
  }
69
70
 
70
71
  // ---------------------------------------------------------------------------
71
- // A minimal GitHub Actions expression evaluator
72
- //
73
- // Only the surface this expression uses: string literals, `inputs.<name>` /
74
- // `inputs['<name>']` context reads, `==`/`!=`, `&&`/`||`, parentheses, and
75
- // `contains()`. The semantics that matter and that a hand-read gets wrong:
76
- //
77
- // - `a && b` yields `b` when `a` is truthy, else `a`.
78
- // - `a || b` yields `a` when `a` is truthy, else `b`.
79
- // - EVERY non-empty string is truthy — including the string `'false'`.
80
- // - String comparison is case-insensitive.
72
+ // The Actions expression evaluator now lives in scripts/lib/actions-expression.mjs
73
+ // (extracted by Story #421, when check-runner-runs-on.test.mjs needed the same
74
+ // read-then-execute approach). Its own semantics are covered by
75
+ // scripts/lib/actions-expression.test.mjs, so this file asserts only the
76
+ // toolchain-cache contract.
81
77
  // ---------------------------------------------------------------------------
82
78
 
83
- function truthy(v) {
84
- if (typeof v === "boolean") return v;
85
- if (typeof v === "number") return v !== 0;
86
- if (typeof v === "string") return v !== "";
87
- return v !== null && v !== undefined;
88
- }
89
-
90
- function looseEqual(a, b) {
91
- if (typeof a === "string" && typeof b === "string") {
92
- return a.toLowerCase() === b.toLowerCase();
93
- }
94
- return a === b;
95
- }
96
-
97
- function evaluate(expr, inputs) {
98
- let i = 0;
99
-
100
- const ws = () => {
101
- while (i < expr.length && /\s/.test(expr[i])) i++;
102
- };
103
- const eat = (token) => {
104
- ws();
105
- if (expr.startsWith(token, i)) {
106
- i += token.length;
107
- return true;
108
- }
109
- return false;
110
- };
111
-
112
- function parseOr() {
113
- let left = parseAnd();
114
- for (;;) {
115
- ws();
116
- if (!eat("||")) return left;
117
- const right = parseAnd();
118
- left = truthy(left) ? left : right;
119
- }
120
- }
121
-
122
- function parseAnd() {
123
- let left = parseCompare();
124
- for (;;) {
125
- ws();
126
- if (!eat("&&")) return left;
127
- const right = parseCompare();
128
- left = truthy(left) ? right : left;
129
- }
130
- }
131
-
132
- function parseCompare() {
133
- const left = parsePrimary();
134
- ws();
135
- if (eat("!=")) return !looseEqual(left, parsePrimary());
136
- if (eat("==")) return looseEqual(left, parsePrimary());
137
- return left;
138
- }
139
-
140
- function parsePrimary() {
141
- ws();
142
- if (eat("(")) {
143
- const v = parseOr();
144
- ws();
145
- assert.ok(eat(")"), `unbalanced parenthesis at ${i} in: ${expr}`);
146
- return v;
147
- }
148
- if (expr[i] === "'") {
149
- i++;
150
- let out = "";
151
- while (i < expr.length) {
152
- if (expr[i] === "'" && expr[i + 1] === "'") {
153
- out += "'";
154
- i += 2;
155
- continue;
156
- }
157
- if (expr[i] === "'") {
158
- i++;
159
- return out;
160
- }
161
- out += expr[i++];
162
- }
163
- assert.fail(`unterminated string literal in: ${expr}`);
164
- }
165
- if (expr.startsWith("contains(", i)) {
166
- i += "contains(".length;
167
- const hay = parseOr();
168
- ws();
169
- assert.ok(eat(","), `contains() expects two arguments in: ${expr}`);
170
- const needle = parseOr();
171
- ws();
172
- assert.ok(eat(")"), `unclosed contains() in: ${expr}`);
173
- return String(hay).toLowerCase().includes(String(needle).toLowerCase());
174
- }
175
- const ident = expr.slice(i).match(/^[A-Za-z_][A-Za-z0-9_.\-]*(\['[^']*'\])?/);
176
- assert.ok(ident, `unparseable token at ${i} in: ${expr}`);
177
- i += ident[0].length;
178
- const raw = ident[0];
179
- if (raw === "true") return true;
180
- if (raw === "false") return false;
181
- const bracket = raw.match(/^inputs\['([^']*)'\]$/);
182
- const dotted = raw.match(/^inputs\.(.+)$/);
183
- const key = bracket ? bracket[1] : dotted ? dotted[1] : null;
184
- assert.ok(key !== null, `unsupported context read \`${raw}\` in: ${expr}`);
185
- assert.ok(key in inputs, `expression reads \`inputs.${key}\`, not provided by the test case`);
186
- return inputs[key];
187
- }
188
-
189
- const value = parseOr();
190
- ws();
191
- assert.equal(i, expr.length, `trailing tokens at ${i} in: ${expr}`);
192
- return value;
193
- }
194
-
195
- // ---------------------------------------------------------------------------
196
- // The evaluator itself must be trustworthy before it can judge the workflow.
197
- // ---------------------------------------------------------------------------
198
-
199
- test("the expression evaluator models Actions truthiness, not JavaScript's", () => {
200
- // The trap the real expression depends on: the STRING 'false' is truthy, so
201
- // an explicitly-pinned 'false' survives the first arm of the ternary.
202
- assert.equal(evaluate("'false' && 'yes' || 'no'", {}), "yes");
203
- // `&&`/`||` yield operands, not booleans.
204
- assert.equal(evaluate("true && 'kept'", {}), "kept");
205
- assert.equal(evaluate("'' || 'fallback'", {}), "fallback");
206
- // Comparison is case-insensitive.
207
- assert.equal(evaluate("'AUTO' == 'auto'", {}), true);
208
- assert.equal(evaluate("contains('[\"self-hosted\",\"x\"]', 'self-hosted')", {}), true);
209
- });
210
-
211
79
  // ---------------------------------------------------------------------------
212
80
  // The contract
213
81
  // ---------------------------------------------------------------------------
@@ -0,0 +1,324 @@
1
+ // Structural guards for the workflow-lint tier (Story #425).
2
+ //
3
+ // These assertions are deliberately STRUCTURAL rather than textual: each one
4
+ // resolves the actual `workflow-lint` job block and inspects the keys inside
5
+ // it. A repo-wide grep cannot do this job — `pr-quality.yml` legitimately
6
+ // carries job-level `permissions:` on three OTHER jobs, so "does the file
7
+ // contain `permissions:`" answers a question nobody asked.
8
+ //
9
+ // The invariant that matters most here is the ABSENCE of a job-level
10
+ // `permissions:` on the new tier. GitHub validates a called reusable
11
+ // workflow's declared job permissions against the caller's grant at COMPILE
12
+ // time, regardless of the job's `if:` gate — so adding a scope to this job
13
+ // fails the ENTIRE call with `startup_failure` (zero jobs) for every consumer
14
+ // that has not granted it, including consumers who turned the tier off. Story
15
+ // #292 is the precedent: `pull-requests: read` on migration-guard broke
16
+ // ci.yml and the cross-repo smoke consumer, and stranded a release
17
+ // unpublished. That break is invisible until a consumer's next pin bump,
18
+ // which is exactly the kind of regression a unit test should hold.
19
+
20
+ import { test } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { readFileSync } from "node:fs";
23
+ import { resolve, dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
+ const read = (rel) => readFileSync(join(repoRoot, rel), "utf8");
28
+
29
+ const PR_QUALITY = read(".github/workflows/pr-quality.yml");
30
+ const CI = read(".github/workflows/ci.yml");
31
+ const ACTION = read(".github/actions/workflow-lint/action.yml");
32
+ const DOCS = read("docs/reusable-workflows.md");
33
+
34
+ /**
35
+ * Return the lines belonging to one top-level job — from ` <name>:` until the
36
+ * next key at the same indentation. This is the "parse" the permissions
37
+ * assertion needs: it scopes the search to ONE job rather than the file.
38
+ *
39
+ * @param {string} text Workflow file contents.
40
+ * @param {string} job Job id, e.g. "workflow-lint".
41
+ * @returns {string[]}
42
+ */
43
+ export function jobBlock(text, job) {
44
+ const lines = text.split(/\r?\n/);
45
+ // A plain line comparison, not a built regex: Semgrep's
46
+ // detect-non-literal-regexp rejects a RegExp built from a non-literal,
47
+ // and a job header is an exact line anyway.
48
+ const start = lines.findIndex((l) => l.trimEnd() === ` ${job}:`);
49
+ assert.notEqual(start, -1, `job '${job}' not found`);
50
+ const block = [];
51
+ for (let i = start + 1; i < lines.length; i += 1) {
52
+ const line = lines[i];
53
+ // A new key at the job's own indent (2 spaces) ends this block. Blank
54
+ // lines and comments belong to whatever follows, so they never terminate.
55
+ if (/^ {2}\S/.test(line)) break;
56
+ block.push(line);
57
+ }
58
+ return block;
59
+ }
60
+
61
+ /** Keys declared directly on a job (indent 4), ignoring nested mappings. */
62
+ export function jobKeys(block) {
63
+ return block
64
+ .filter((l) => /^ {4}[A-Za-z_-]+:/.test(l))
65
+ .map((l) => l.trim().split(":")[0]);
66
+ }
67
+
68
+ /**
69
+ * The block declaring one composite-action input. Anchored to a line start
70
+ * because the action's header carries USAGE COMMENTS that contain the same
71
+ * ` <input>:` text — an unanchored indexOf reads the comment instead.
72
+ *
73
+ * @param {string} text @param {string} name
74
+ */
75
+ export function actionInput(text, name) {
76
+ const lines = text.split(/\r?\n/);
77
+ const start = lines.findIndex((l) => l.trimEnd() === ` ${name}:`);
78
+ assert.ok(start !== -1, `input '${name}' not declared`);
79
+ const block = [];
80
+ for (let i = start + 1; i < lines.length; i += 1) {
81
+ // The next input header (indent 2, bare key) ends this block.
82
+ if (/^ {2}[A-Za-z_-]+:\s*$/.test(lines[i])) break;
83
+ block.push(lines[i]);
84
+ }
85
+ return block.join("\n");
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // The compile-time consumer break (AC-3)
90
+ // ---------------------------------------------------------------------------
91
+
92
+ test("the workflow-lint job declares NO job-level permissions", () => {
93
+ const keys = jobKeys(jobBlock(PR_QUALITY, "workflow-lint"));
94
+ assert.ok(
95
+ !keys.includes("permissions"),
96
+ "adding a job-level `permissions:` scope here fails the ENTIRE reusable-workflow " +
97
+ "call with startup_failure for every consumer lacking that grant, regardless " +
98
+ "of the job's `if:` gate (Story #292). Use the workflow-level grant instead.",
99
+ );
100
+ });
101
+
102
+ test("the scoping is real: other pr-quality jobs DO declare permissions", () => {
103
+ // If this ever fails, `jobBlock` has stopped scoping and the assertion above
104
+ // has quietly become vacuous.
105
+ const withPermissions = ["migration-guard", "security", "osv-scan"].filter((job) =>
106
+ jobKeys(jobBlock(PR_QUALITY, job)).includes("permissions"),
107
+ );
108
+ assert.ok(
109
+ withPermissions.length > 0,
110
+ "expected at least one job to declare permissions, else the guard above proves nothing",
111
+ );
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Inputs and defaults (AC-3, AC-8)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ test("enable-workflow-lint is a boolean defaulting to true", () => {
119
+ const block = PR_QUALITY.slice(PR_QUALITY.indexOf(" enable-workflow-lint:"));
120
+ assert.match(block.slice(0, 300), /type: boolean/);
121
+ assert.match(block.slice(0, 300), /default: true/);
122
+ });
123
+
124
+ test("workflow-lint-enforce is a boolean defaulting to false — advisory on arrival", () => {
125
+ const block = PR_QUALITY.slice(PR_QUALITY.indexOf(" workflow-lint-enforce:"));
126
+ assert.match(block.slice(0, 400), /type: boolean/);
127
+ assert.match(
128
+ block.slice(0, 400),
129
+ /default: false/,
130
+ "the tier must ship advisory: a consumer inheriting it on a pin bump must not " +
131
+ "have pre-existing workflow debt red their merge",
132
+ );
133
+ });
134
+
135
+ test("the tier-timeouts description names the new tier key", () => {
136
+ const desc = PR_QUALITY.slice(
137
+ PR_QUALITY.indexOf(" tier-timeouts:"),
138
+ PR_QUALITY.indexOf(" tier-timeouts:") + 900,
139
+ );
140
+ assert.match(desc, /workflow-lint/);
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Gate wiring (AC-4)
145
+ // ---------------------------------------------------------------------------
146
+
147
+ test("workflow-lint is a needs: of ci-required", () => {
148
+ const block = jobBlock(PR_QUALITY, "ci-required").join("\n");
149
+ const needs = block.slice(block.indexOf("needs:"), block.indexOf("steps:"));
150
+ assert.match(
151
+ needs,
152
+ /^\s*- workflow-lint$/m,
153
+ "the aggregator is self-maintaining — adding the job to needs: is the only edit " +
154
+ "required to make the tier branch-protection load-bearing, with no new required " +
155
+ "context to register when it later flips to enforcing",
156
+ );
157
+ });
158
+
159
+ test("the tier's base timeout is present in TIER_TIMEOUT_BASES", () => {
160
+ const job = jobBlock(PR_QUALITY, "workflow-lint").join("\n");
161
+ const m = job.match(/tier-timeouts\)\['workflow-lint'\]\s*\|\|\s*(\d+)/);
162
+ assert.ok(m, "the tier must read its budget from the tier-timeouts map");
163
+ const base = m[1];
164
+ const bases = PR_QUALITY.match(/TIER_TIMEOUT_BASES:\s*'(\[[^\]]*\])'/);
165
+ assert.ok(bases, "TIER_TIMEOUT_BASES not found");
166
+ assert.ok(
167
+ JSON.parse(bases[1]).includes(Number(base)),
168
+ `base ceiling ${base} must appear in TIER_TIMEOUT_BASES ${bases[1]} — the ` +
169
+ "cancelled-provenance classifier infers a timed-out cancel by matching a job's " +
170
+ "wall duration against that set",
171
+ );
172
+ });
173
+
174
+ test("the tier honours the runner input like every other tier", () => {
175
+ const job = jobBlock(PR_QUALITY, "workflow-lint").join("\n");
176
+ assert.match(job, /runs-on: \$\{\{ fromJSON\(startsWith\(inputs\.runner, '\['\)/);
177
+ });
178
+
179
+ test("the tier is gated on its enable input", () => {
180
+ const job = jobBlock(PR_QUALITY, "workflow-lint").join("\n");
181
+ assert.match(job, /if: \$\{\{ inputs\.enable-workflow-lint \}\}/);
182
+ });
183
+
184
+ test("pr-quality pins the composite by absolute SHA, as consumers require", () => {
185
+ const job = jobBlock(PR_QUALITY, "workflow-lint").join("\n");
186
+ assert.match(
187
+ job,
188
+ /uses: dsj1984\/mandrel-platform\/\.github\/actions\/workflow-lint@[0-9a-f]{40}$/m,
189
+ "a reusable workflow must reference first-party actions by absolute owner/repo " +
190
+ "path at a full 40-hex SHA — a relative ./ path resolves against the CALLER's " +
191
+ "checkout, where this action does not exist",
192
+ );
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Tool posture (AC-6) and consolidation (AC-7)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ test("zizmor runs offline at the configured minimum severity", () => {
200
+ assert.match(ACTION, /--offline/, "online audits red on real ref-version-mismatch " +
201
+ "findings and would make the gate depend on a live API call");
202
+ assert.match(ACTION, /--min-severity "\$\{ZIZMOR_MIN_SEVERITY\}"/);
203
+ assert.match(ACTION, /default: 'medium'/);
204
+ });
205
+
206
+ test("zizmor's findings never set the exit code — the gate script decides", () => {
207
+ assert.match(ACTION, /--no-exit-codes/);
208
+ });
209
+
210
+ test("pyflakes is always disabled and shellcheck is opt-in", () => {
211
+ assert.match(ACTION, /-pyflakes=/, "pyflakes must be explicitly disabled, not left to PATH");
212
+ assert.match(ACTION, /inputs\.shellcheck/);
213
+ assert.match(
214
+ actionInput(ACTION, "shellcheck"),
215
+ /default: ''|default: 'false'/,
216
+ "consumers must not inherit a PATH-dependent gate",
217
+ );
218
+ });
219
+
220
+ test("consumers get shellcheck off; this repo's own ci.yml opts in", () => {
221
+ const job = jobBlock(PR_QUALITY, "workflow-lint").join("\n");
222
+ assert.match(job, /shellcheck: 'false'/);
223
+ const ciJob = jobBlock(CI, "actionlint").join("\n");
224
+ assert.match(
225
+ ciJob,
226
+ /shellcheck: 'true'/,
227
+ "ci.yml relied on ubuntu-latest's PATH shellcheck before this tier existed; " +
228
+ "consolidating onto the composite must not silently drop that coverage",
229
+ );
230
+ });
231
+
232
+ test("ci.yml keeps actionlint blocking while zizmor lands advisory", () => {
233
+ const ciJob = jobBlock(CI, "actionlint").join("\n");
234
+ assert.match(ciJob, /enforce-actionlint: 'true'/);
235
+ assert.match(ciJob, /enforce-zizmor: 'false'/);
236
+ });
237
+
238
+ test("ci.yml carries no actionlint version or checksum of its own (AC-7)", () => {
239
+ assert.ok(
240
+ !/ACTIONLINT_VERSION|ACTIONLINT_SHA256/.test(CI),
241
+ "the version + checksum map must exist in exactly one place — the composite — " +
242
+ "or a bump silently updates one copy and leaves the other running an old binary",
243
+ );
244
+ assert.match(jobBlock(CI, "actionlint").join("\n"), /uses: \.\/\.github\/actions\/workflow-lint/);
245
+ });
246
+
247
+ test("the composite pins four checksums per tool across darwin/linux x amd64/arm64", () => {
248
+ for (const slug of [
249
+ "1.7.12_darwin_amd64",
250
+ "1.7.12_darwin_arm64",
251
+ "1.7.12_linux_amd64",
252
+ "1.7.12_linux_arm64",
253
+ "1.30.0_aarch64-apple-darwin",
254
+ "1.30.0_x86_64-apple-darwin",
255
+ "1.30.0_aarch64-unknown-linux-gnu",
256
+ "1.30.0_x86_64-unknown-linux-gnu",
257
+ ]) {
258
+ // Plain string scan (no built regex — see jobBlock): find the slug's own
259
+ // case arm, then assert the 64-hex literal that follows it on that line.
260
+ const arm = ACTION.split(/\r?\n/).find((l) => l.includes(`"${slug}")`));
261
+ assert.ok(arm, `missing case arm for ${slug}`);
262
+ assert.match(arm, /="[0-9a-f]{64}"/, `missing pinned SHA-256 for ${slug}`);
263
+ }
264
+ });
265
+
266
+ test("an unmapped platform slug is a hard error, never a silent skip", () => {
267
+ assert.match(ACTION, /No pinned checksum for actionlint/);
268
+ assert.match(ACTION, /No pinned checksum for zizmor/);
269
+ assert.match(ACTION, /actionlint checksum mismatch/);
270
+ assert.match(ACTION, /zizmor checksum mismatch/);
271
+ });
272
+
273
+ test("actionlint gets no path arguments — it errors on a directory", () => {
274
+ // The tools disagree on argument shape; unifying them broke the first cut.
275
+ assert.match(ACTION, /auto-discovers/);
276
+ assert.match(ACTION, /\$\{zz_targets\}/, "zizmor takes the resolved directory list");
277
+ assert.ok(
278
+ !/actionlint" -no-color -format '\{\{json \.\}\}'[^\n]*\$\{zz_targets\}/.test(ACTION),
279
+ "actionlint must not be handed directory arguments",
280
+ );
281
+ });
282
+
283
+ test("ci.yml's dogfood self-call does not run the natively-covered tier", () => {
284
+ // The self-call exists to dogfood the SECURITY tier; every tier ci.yml runs
285
+ // itself is disabled there. workflow-lint is now one of them — and leaving it
286
+ // on would also make the dogfood depend on the tier's SHA-pinned `uses:`,
287
+ // which cannot resolve in the PR that first introduces the action.
288
+ const block = CI.slice(CI.indexOf("uses: ./.github/workflows/pr-quality.yml"));
289
+ assert.match(block.slice(0, 1500), /enable-workflow-lint: false/);
290
+ });
291
+
292
+ test("no test or action file builds a RegExp from a non-literal", () => {
293
+ // Semgrep's detect-non-literal-regexp is diff-baselined and blocks new JS
294
+ // that does. Pinning it here keeps a future edit from rediscovering it in CI.
295
+ for (const rel of [
296
+ "scripts/check-workflow-lint-tier.test.mjs",
297
+ "scripts/workflow-lint-gate.test.mjs",
298
+ ".github/actions/workflow-lint/workflow-lint-gate.mjs",
299
+ ]) {
300
+ // Assembled so this guard's own needle is not a literal occurrence.
301
+ const needle = ["new", "RegExp("].join(" ");
302
+ assert.ok(!read(rel).includes(needle), `${rel} builds a RegExp dynamically`);
303
+ }
304
+ });
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Documentation (AC-8)
308
+ // ---------------------------------------------------------------------------
309
+
310
+ test("the docs carry an inputs row and a dedicated tier section", () => {
311
+ assert.match(DOCS, /\| `enable-workflow-lint`/);
312
+ assert.match(DOCS, /### Workflow lint tier \(`enable-workflow-lint`\)/);
313
+ });
314
+
315
+ test("the docs explain the advisory posture, the dial, shellcheck and provenance", () => {
316
+ const start = DOCS.indexOf("### Workflow lint tier (`enable-workflow-lint`)");
317
+ assert.notEqual(start, -1);
318
+ const section = DOCS.slice(start, start + 9000);
319
+ assert.match(section, /advisory/i);
320
+ assert.match(section, /workflow-lint-enforce/);
321
+ assert.match(section, /shellcheck/i);
322
+ assert.match(section, /checksum/i);
323
+ assert.match(section, /no checksums file/i, "the zizmor provenance caveat must be recorded");
324
+ });