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 +2 -2
- package/scripts/check-runner-runs-on.test.mjs +153 -0
- package/scripts/check-toolchain-cache-default.test.mjs +6 -138
- package/scripts/check-workflow-lint-tier.test.mjs +324 -0
- package/scripts/check-workflow-portability.mjs +39 -0
- package/scripts/check-workflow-portability.test.mjs +66 -0
- package/scripts/lib/actions-expression.mjs +271 -0
- package/scripts/lib/actions-expression.test.mjs +83 -0
- package/scripts/workflow-lint-gate.test.mjs +425 -0
|
@@ -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
|
+
});
|