mandrel-platform 1.5.1 → 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.1",
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": {
@@ -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
+ });
@@ -0,0 +1,425 @@
1
+ // Unit coverage for the workflow-lint advisory/enforcing gate (Story #425).
2
+ //
3
+ // The enforcement dial is the load-bearing part of this tier: the gate ships
4
+ // ADVISORY so a consumer inheriting it on a pin bump does not have their
5
+ // pre-existing workflow debt redden the merge, and flips to blocking only when
6
+ // the caller opts in. A grep over pr-quality.yml cannot tell a working dial
7
+ // from a broken one — it pins the spelling, not the behaviour — so the
8
+ // decision lives in a script and is pinned here, mirroring
9
+ // `osv-report-gate.test.mjs` next door.
10
+ //
11
+ // The other invariant these tests hold: a missing or malformed report is a
12
+ // TOOL FAILURE and exits non-zero even in advisory mode. A gate that reports
13
+ // "no findings" because the linter never ran is worse than no gate at all.
14
+
15
+ import { test } from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { execFileSync, spawnSync } from "node:child_process";
18
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
19
+ import { join, resolve, dirname } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { tmpdir } from "node:os";
22
+
23
+ import {
24
+ normalizeActionlint,
25
+ normalizeZizmor,
26
+ classify,
27
+ countBySeverity,
28
+ findingsDigest,
29
+ renderSummary,
30
+ loadReport,
31
+ severityRank,
32
+ resolveEnforcement,
33
+ WorkflowLintGateError,
34
+ } from "../.github/actions/workflow-lint/workflow-lint-gate.mjs";
35
+
36
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
37
+ const GATE = join(repoRoot, ".github/actions/workflow-lint/workflow-lint-gate.mjs");
38
+
39
+ // An actionlint `-format '{{json .}}'` row, as the real binary emits it.
40
+ const actionlintRow = (over = {}) => ({
41
+ message: "got unexpected character '+' while lexing expression",
42
+ filepath: ".github/workflows/bad.yml",
43
+ line: 6,
44
+ column: 29,
45
+ kind: "expression",
46
+ snippet: " timeout-minutes: ${{ 15 + 3 }}",
47
+ ...over,
48
+ });
49
+
50
+ // A zizmor `--format json` (v1) finding, as the real binary emits it. Note the
51
+ // ZERO-indexed start_point — the +1 is the thing worth pinning.
52
+ const zizmorRow = (over = {}) => ({
53
+ ident: "excessive-permissions",
54
+ desc: "overly broad permissions",
55
+ url: "https://docs.zizmor.sh/audits/#excessive-permissions",
56
+ determinations: { confidence: "High", severity: "High", persona: "Regular" },
57
+ locations: [
58
+ {
59
+ symbolic: {
60
+ key: { Local: { verbatim_path: ".github/workflows/release-please.yml" } },
61
+ kind: "Primary",
62
+ },
63
+ concrete: { location: { start_point: { row: 31, column: 2 } } },
64
+ },
65
+ ],
66
+ ...over,
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Normalization
71
+ // ---------------------------------------------------------------------------
72
+
73
+ test("normalizeActionlint maps a row and records it at high severity", () => {
74
+ const [f] = normalizeActionlint([actionlintRow()]);
75
+ assert.equal(f.tool, "actionlint");
76
+ assert.equal(f.id, "expression");
77
+ // actionlint has no severity model — every diagnostic is an error.
78
+ assert.equal(f.severity, "high");
79
+ assert.equal(f.file, ".github/workflows/bad.yml");
80
+ assert.equal(f.line, 6);
81
+ assert.equal(f.column, 29);
82
+ });
83
+
84
+ test("normalizeActionlint keeps only the first line of a multi-line message", () => {
85
+ const [f] = normalizeActionlint([actionlintRow({ message: "first\nsecond" })]);
86
+ assert.equal(f.message, "first");
87
+ });
88
+
89
+ test("normalizeZizmor converts zizmor's 0-indexed point to a 1-indexed location", () => {
90
+ const [f] = normalizeZizmor([zizmorRow()]);
91
+ assert.equal(f.tool, "zizmor");
92
+ assert.equal(f.id, "excessive-permissions");
93
+ assert.equal(f.severity, "high");
94
+ assert.equal(f.file, ".github/workflows/release-please.yml");
95
+ // row 31 / column 2 are 0-indexed; zizmor's own plain renderer prints 32:3.
96
+ assert.equal(f.line, 32);
97
+ assert.equal(f.column, 3);
98
+ });
99
+
100
+ test("normalizeZizmor prefers the Primary location over other locations", () => {
101
+ const row = zizmorRow({
102
+ locations: [
103
+ {
104
+ symbolic: { key: { Local: { verbatim_path: "other.yml" } }, kind: "Related" },
105
+ concrete: { location: { start_point: { row: 0, column: 0 } } },
106
+ },
107
+ {
108
+ symbolic: { key: { Local: { verbatim_path: "primary.yml" } }, kind: "Primary" },
109
+ concrete: { location: { start_point: { row: 9, column: 4 } } },
110
+ },
111
+ ],
112
+ });
113
+ const [f] = normalizeZizmor([row]);
114
+ assert.equal(f.file, "primary.yml");
115
+ assert.equal(f.line, 10);
116
+ });
117
+
118
+ test("normalizeZizmor tolerates a finding with no locations", () => {
119
+ const [f] = normalizeZizmor([zizmorRow({ locations: [] })]);
120
+ assert.equal(f.file, "");
121
+ assert.equal(f.line, 0);
122
+ });
123
+
124
+ test("a non-array report is a hard error for either tool", () => {
125
+ assert.throws(() => normalizeActionlint({ nope: true }), WorkflowLintGateError);
126
+ assert.throws(() => normalizeZizmor("nope"), WorkflowLintGateError);
127
+ });
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // The enforcement dial — the reason this file exists
131
+ // ---------------------------------------------------------------------------
132
+
133
+ test("advisory (the default): findings are reported but do NOT fail the tier", () => {
134
+ const findings = [
135
+ ...normalizeActionlint([actionlintRow()]),
136
+ ...normalizeZizmor([zizmorRow()]),
137
+ ];
138
+ const verdict = classify(findings);
139
+ assert.equal(verdict.enforce, false);
140
+ assert.equal(verdict.findings.length, 2, "every finding is still reported");
141
+ assert.equal(verdict.blocking.length, 0);
142
+ assert.equal(verdict.exitCode, 0, "advisory mode must never red the tier");
143
+ });
144
+
145
+ test("enforcing: the same findings DO fail the tier", () => {
146
+ const findings = normalizeZizmor([zizmorRow()]);
147
+ const verdict = classify(findings, { enforce: true });
148
+ assert.equal(verdict.blocking.length, 1);
149
+ assert.equal(verdict.exitCode, 1);
150
+ });
151
+
152
+ test("no findings passes under either setting", () => {
153
+ assert.equal(classify([]).exitCode, 0);
154
+ assert.equal(classify([], { enforce: true }).exitCode, 0);
155
+ });
156
+
157
+ test("findings sort by severity, strongest first", () => {
158
+ const findings = [
159
+ { tool: "zizmor", id: "a", severity: "medium", file: "a.yml", line: 1 },
160
+ { tool: "zizmor", id: "b", severity: "high", file: "b.yml", line: 1 },
161
+ ];
162
+ assert.equal(classify(findings).findings[0].severity, "high");
163
+ });
164
+
165
+ test("severityRank orders the ladder and floors an unknown band", () => {
166
+ assert.ok(severityRank("high") > severityRank("medium"));
167
+ assert.ok(severityRank("medium") > severityRank("low"));
168
+ assert.equal(severityRank("nonsense"), 0);
169
+ });
170
+
171
+ test("countBySeverity buckets each band and files strays under unknown", () => {
172
+ const counts = countBySeverity([
173
+ { severity: "high" },
174
+ { severity: "medium" },
175
+ { severity: "medium" },
176
+ { severity: "bogus" },
177
+ ]);
178
+ assert.equal(counts.high, 1);
179
+ assert.equal(counts.medium, 2);
180
+ assert.equal(counts.unknown, 1);
181
+ });
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Per-tool enforcement — the two linters arrive with different debt
185
+ // ---------------------------------------------------------------------------
186
+
187
+ test("resolveEnforcement: a per-tool value overrides the tier-wide default", () => {
188
+ assert.deepEqual(resolveEnforcement({ enforce: "false", actionlint: "true" }), {
189
+ actionlint: true,
190
+ zizmor: false,
191
+ });
192
+ assert.deepEqual(resolveEnforcement({ enforce: "true", zizmor: "false" }), {
193
+ actionlint: true,
194
+ zizmor: false,
195
+ });
196
+ });
197
+
198
+ test("resolveEnforcement: an empty per-tool value inherits the tier default", () => {
199
+ assert.deepEqual(resolveEnforcement({ enforce: "true" }), {
200
+ actionlint: true,
201
+ zizmor: true,
202
+ });
203
+ assert.deepEqual(resolveEnforcement({}), { actionlint: false, zizmor: false });
204
+ });
205
+
206
+ test("resolveEnforcement: only the literal 'true' enables a tool", () => {
207
+ for (const value of ["TRUE", "1", "yes", "on"]) {
208
+ assert.deepEqual(
209
+ resolveEnforcement({ actionlint: value, zizmor: value }),
210
+ { actionlint: false, zizmor: false },
211
+ `${value} must not enable enforcement`,
212
+ );
213
+ }
214
+ });
215
+
216
+ test("only the enforcing tool's findings block — this repo's ci.yml case", () => {
217
+ // actionlint blocking (clean), zizmor advisory (pre-existing backlog).
218
+ const findings = [
219
+ ...normalizeActionlint([actionlintRow()]),
220
+ ...normalizeZizmor([zizmorRow()]),
221
+ ];
222
+ const verdict = classify(findings, {
223
+ enforce: { actionlint: true, zizmor: false },
224
+ });
225
+ assert.equal(verdict.findings.length, 2, "both are still reported");
226
+ assert.equal(verdict.blocking.length, 1);
227
+ assert.equal(verdict.blocking[0].tool, "actionlint");
228
+ assert.equal(verdict.exitCode, 1);
229
+ });
230
+
231
+ test("a clean enforcing tool passes even while the advisory tool has findings", () => {
232
+ const verdict = classify(normalizeZizmor([zizmorRow()]), {
233
+ enforce: { actionlint: true, zizmor: false },
234
+ });
235
+ assert.equal(verdict.findings.length, 1);
236
+ assert.equal(verdict.blocking.length, 0);
237
+ assert.equal(verdict.exitCode, 0);
238
+ });
239
+
240
+ test("the summary labels each row's own gate when the tools differ", () => {
241
+ const findings = [
242
+ ...normalizeActionlint([actionlintRow()]),
243
+ ...normalizeZizmor([zizmorRow()]),
244
+ ];
245
+ const md = renderSummary(classify(findings, { enforce: { actionlint: true, zizmor: false } }));
246
+ assert.match(md, /❌ blocking \| high \| actionlint/);
247
+ assert.match(md, /⚠️ advisory \| high \| zizmor/);
248
+ assert.match(md, /1 of 2 finding\(s\)/);
249
+ });
250
+
251
+ test("the digest counts a mixed verdict rather than calling it all-or-nothing", () => {
252
+ const findings = [
253
+ ...normalizeActionlint([actionlintRow()]),
254
+ ...normalizeZizmor([zizmorRow()]),
255
+ ];
256
+ const digest = findingsDigest(
257
+ classify(findings, { enforce: { actionlint: true, zizmor: false } }),
258
+ );
259
+ assert.match(digest, /1 ENFORCING \(failing this tier\), 1 advisory/);
260
+ });
261
+
262
+ test("CLI: per-tool env vars drive the gate", () => {
263
+ const both = { actionlint: [actionlintRow()], zizmor: [zizmorRow()] };
264
+ // actionlint enforcing → red, even though the tier default is advisory.
265
+ assert.equal(
266
+ runGate({ WORKFLOW_LINT_ENFORCE_ACTIONLINT: "true" }, both).status,
267
+ 1,
268
+ );
269
+ // zizmor advisory override beats an enforcing tier default → its findings
270
+ // alone cannot red the tier.
271
+ assert.equal(
272
+ runGate(
273
+ { WORKFLOW_LINT_ENFORCE: "true", WORKFLOW_LINT_ENFORCE_ACTIONLINT: "false", WORKFLOW_LINT_ENFORCE_ZIZMOR: "false" },
274
+ both,
275
+ ).status,
276
+ 0,
277
+ );
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Reporting — an advisory finding nobody can see is the same as no tier
282
+ // ---------------------------------------------------------------------------
283
+
284
+ test("the digest names the posture so a green log is not mistaken for clean", () => {
285
+ const findings = normalizeZizmor([zizmorRow()]);
286
+ assert.match(findingsDigest(classify(findings)), /advisory, so they do NOT fail/);
287
+ assert.match(findingsDigest(classify(findings, { enforce: true })), /ENFORCING/);
288
+ assert.match(findingsDigest(classify([])), /no findings/);
289
+ });
290
+
291
+ test("the summary renders every finding in advisory mode", () => {
292
+ const md = renderSummary(classify(normalizeZizmor([zizmorRow()])));
293
+ assert.match(md, /advisory/);
294
+ assert.match(md, /workflow-lint-enforce: true/);
295
+ assert.match(md, /release-please\.yml:32:3/);
296
+ assert.match(md, /excessive-permissions/);
297
+ });
298
+
299
+ test("the summary says findings fail the build when enforcing", () => {
300
+ const md = renderSummary(classify(normalizeZizmor([zizmorRow()]), { enforce: true }));
301
+ assert.match(md, /enforcing/);
302
+ assert.doesNotMatch(md, /do \*\*not\*\* fail/);
303
+ });
304
+
305
+ test("backslashes are escaped BEFORE pipes, so a cell cannot be merged", () => {
306
+ // CodeQL js/incomplete-sanitization: escaping pipes first would let an input
307
+ // backslash become the escape character for the pipe after it, silently
308
+ // merging two table cells.
309
+ const md = renderSummary(
310
+ classify(normalizeActionlint([actionlintRow({ message: String.raw`a \ b | c` })])),
311
+ );
312
+ const row = md.split("\n").find((l) => l.includes("| actionlint |"));
313
+ assert.ok(row.includes(String.raw`a \\ b \| c`), row);
314
+ });
315
+
316
+ test("a pipe in a finding message cannot break the summary table", () => {
317
+ const md = renderSummary(
318
+ classify(normalizeActionlint([actionlintRow({ message: "a | b" })])),
319
+ );
320
+ assert.match(md, /a \\\| b/);
321
+ });
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Report loading — a report that did not arrive is never a pass
325
+ // ---------------------------------------------------------------------------
326
+
327
+ test("an unset report path contributes nothing", () => {
328
+ assert.deepEqual(loadReport("actionlint", ""), []);
329
+ });
330
+
331
+ test("a missing report file is fatal, not an empty pass", () => {
332
+ assert.throws(
333
+ () => loadReport("zizmor", "/no/such/report.json"),
334
+ (err) => err instanceof WorkflowLintGateError && /did not run/.test(err.message),
335
+ );
336
+ });
337
+
338
+ test("an unparseable report is fatal", () => {
339
+ const dir = mkdtempSync(join(tmpdir(), "wl-gate-"));
340
+ try {
341
+ const p = join(dir, "bad.json");
342
+ writeFileSync(p, "{not json");
343
+ assert.throws(() => loadReport("actionlint", p), WorkflowLintGateError);
344
+ } finally {
345
+ rmSync(dir, { recursive: true, force: true });
346
+ }
347
+ });
348
+
349
+ test("an empty report file reads as no findings", () => {
350
+ const dir = mkdtempSync(join(tmpdir(), "wl-gate-"));
351
+ try {
352
+ const p = join(dir, "empty.json");
353
+ writeFileSync(p, " ");
354
+ assert.deepEqual(loadReport("actionlint", p), []);
355
+ } finally {
356
+ rmSync(dir, { recursive: true, force: true });
357
+ }
358
+ });
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // End-to-end through the real CLI entrypoint (AC-5)
362
+ // ---------------------------------------------------------------------------
363
+
364
+ function runGate(env, { actionlint = [], zizmor = [] } = {}) {
365
+ const dir = mkdtempSync(join(tmpdir(), "wl-gate-e2e-"));
366
+ try {
367
+ const alPath = join(dir, "al.json");
368
+ const zzPath = join(dir, "zz.json");
369
+ writeFileSync(alPath, JSON.stringify(actionlint));
370
+ writeFileSync(zzPath, JSON.stringify(zizmor));
371
+ return spawnSync(process.execPath, [GATE], {
372
+ encoding: "utf8",
373
+ env: {
374
+ ...process.env,
375
+ ACTIONLINT_REPORT: alPath,
376
+ ZIZMOR_REPORT: zzPath,
377
+ GITHUB_STEP_SUMMARY: "",
378
+ ...env,
379
+ },
380
+ });
381
+ } finally {
382
+ rmSync(dir, { recursive: true, force: true });
383
+ }
384
+ }
385
+
386
+ test("CLI: a finding exits 0 in advisory mode and still reports itself", () => {
387
+ const res = runGate({}, { zizmor: [zizmorRow()] });
388
+ assert.equal(res.status, 0, res.stderr);
389
+ assert.match(res.stdout, /excessive-permissions/);
390
+ assert.match(res.stdout, /advisory/);
391
+ // Advisory findings annotate as warnings, which never red a check run.
392
+ assert.match(res.stdout, /::warning /);
393
+ });
394
+
395
+ test("CLI: the same finding exits non-zero when enforcing", () => {
396
+ const res = runGate({ WORKFLOW_LINT_ENFORCE: "true" }, { zizmor: [zizmorRow()] });
397
+ assert.equal(res.status, 1);
398
+ assert.match(res.stdout, /::error /);
399
+ });
400
+
401
+ test("CLI: a clean report exits 0 either way", () => {
402
+ assert.equal(runGate({}).status, 0);
403
+ assert.equal(runGate({ WORKFLOW_LINT_ENFORCE: "true" }).status, 0);
404
+ });
405
+
406
+ test("CLI: a missing report fails even in advisory mode", () => {
407
+ const res = spawnSync(process.execPath, [GATE], {
408
+ encoding: "utf8",
409
+ env: {
410
+ ...process.env,
411
+ ACTIONLINT_REPORT: "/no/such/al.json",
412
+ ZIZMOR_REPORT: "",
413
+ WORKFLOW_LINT_ENFORCE: "false",
414
+ },
415
+ });
416
+ assert.equal(res.status, 1, "a linter that never ran is not a clean gate");
417
+ assert.match(res.stderr, /::error::workflow-lint gate/);
418
+ });
419
+
420
+ test("CLI: only the literal string 'true' enables enforcement", () => {
421
+ for (const value of ["false", "TRUE", "1", "yes", ""]) {
422
+ const res = runGate({ WORKFLOW_LINT_ENFORCE: value }, { zizmor: [zizmorRow()] });
423
+ assert.equal(res.status, 0, `enforce=${JSON.stringify(value)} must stay advisory`);
424
+ }
425
+ });