mandrel-platform 1.2.0 → 1.3.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/README.md +69 -12
- package/config/stryker.base.json +7 -2
- package/package.json +1 -1
- package/scripts/audit-check.mjs +331 -6
- package/scripts/audit-check.test.mjs +382 -1
- package/scripts/check-affected-mode.test.mjs +5 -51
- package/scripts/check-codeql-gating.test.mjs +649 -0
- package/scripts/check-destructive-migration.mjs +277 -11
- package/scripts/check-destructive-migration.test.mjs +334 -0
- package/scripts/check-environments-isolation-audit.test.mjs +212 -0
- package/scripts/check-fail-fast-attribution.test.mjs +90 -4
- package/scripts/check-first-party-pin-freshness.mjs +137 -21
- package/scripts/check-first-party-pin-freshness.test.mjs +135 -0
- package/scripts/check-gitleaks-allowlist.test.mjs +312 -0
- package/scripts/check-osv-scan-mode.test.mjs +5 -50
- package/scripts/check-release-type.mjs +591 -0
- package/scripts/check-release-type.test.mjs +678 -0
- package/scripts/check-setup-toolchain-store.test.mjs +139 -0
- package/scripts/check-toolchain-cache-default.test.mjs +308 -0
- package/scripts/lib/yaml-step.mjs +109 -0
- package/scripts/lib/yaml-step.test.mjs +156 -0
- package/scripts/osv-report-gate.test.mjs +289 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-setup-toolchain-store.test.mjs — regression guard for where the pnpm
|
|
4
|
+
* store lives (setup-toolchain).
|
|
5
|
+
*
|
|
6
|
+
* The bug this pins: pnpm derives its default store from PNPM_HOME, which
|
|
7
|
+
* `pnpm/action-setup` sets to `dest` — so the store lands inside the shim dir
|
|
8
|
+
* that the action's own "Clean stale pnpm install dir" step removes at the top
|
|
9
|
+
* of every job. With `cache: 'false'` that left a self-hosted runner with no
|
|
10
|
+
* cache at all (none from GitHub by design, none on disk in fact), re-fetching
|
|
11
|
+
* the whole dependency graph every time. It looks like a warm store right up
|
|
12
|
+
* until a registry blip fails the install (Beestera/swarm-os#1174).
|
|
13
|
+
*
|
|
14
|
+
* Reading the YAML is not enough: what decides the outcome is a shell branch,
|
|
15
|
+
* and the failure mode is silent — a resolution bug just hands pnpm no flag and
|
|
16
|
+
* quietly restores the old behaviour. So this extracts the real `run:` body and
|
|
17
|
+
* executes it against a stub `pnpm` that echoes its argv, the same
|
|
18
|
+
* read-then-execute approach as `check-osv-scan-mode.test.mjs`.
|
|
19
|
+
*
|
|
20
|
+
* Run: node --test scripts/check-setup-toolchain-store.test.mjs
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import assert from "node:assert/strict";
|
|
24
|
+
import { test } from "node:test";
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
26
|
+
import { readFileSync, mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
import { stepByName, runScript } from "./lib/yaml-step.mjs";
|
|
31
|
+
|
|
32
|
+
const ACTION = ".github/actions/setup-toolchain/action.yml";
|
|
33
|
+
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
34
|
+
|
|
35
|
+
const actionText = readFileSync(ACTION, "utf8");
|
|
36
|
+
const installScript = runScript(stepByName(actionText, "Install dependencies"));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run the extracted install body with a stub `pnpm` on PATH and return what the
|
|
40
|
+
* stub saw. `TRUST_LOCKFILE` defaults to the action's own default so a case only
|
|
41
|
+
* states the variables it is about.
|
|
42
|
+
*/
|
|
43
|
+
function runInstall(env) {
|
|
44
|
+
const dir = mkdtempSync(path.join(tmpdir(), "setup-toolchain-store-"));
|
|
45
|
+
try {
|
|
46
|
+
const stub = path.join(dir, "pnpm");
|
|
47
|
+
writeFileSync(stub, '#!/bin/sh\necho "PNPM_ARGV: $*"\n');
|
|
48
|
+
chmodSync(stub, 0o755);
|
|
49
|
+
const script = path.join(dir, "install.sh");
|
|
50
|
+
writeFileSync(script, installScript);
|
|
51
|
+
return execFileSync("bash", [script], {
|
|
52
|
+
cwd: dir,
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
env: {
|
|
55
|
+
PATH: `${dir}${path.delimiter}${process.env.PATH}`,
|
|
56
|
+
TRUST_LOCKFILE: "false",
|
|
57
|
+
STORE_DIR_INPUT: "",
|
|
58
|
+
...env,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("cache 'false' puts the store in the tool cache, outside the pre-cleaned shim dir", () => {
|
|
67
|
+
const out = runInstall({ CACHE_ENABLED: "false", TOOL_CACHE: "/opt/hostedtoolcache" });
|
|
68
|
+
assert.match(out, /--store-dir \/opt\/hostedtoolcache\/pnpm-store/);
|
|
69
|
+
assert.match(out, /--frozen-lockfile/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("cache 'true' leaves the store alone — setup-node resolves it to decide what to cache", () => {
|
|
73
|
+
const out = runInstall({ CACHE_ENABLED: "true", TOOL_CACHE: "/opt/hostedtoolcache" });
|
|
74
|
+
assert.doesNotMatch(out, /--store-dir/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("an explicit store-dir input wins, whether caching is on or off", () => {
|
|
78
|
+
for (const CACHE_ENABLED of ["false", "true"]) {
|
|
79
|
+
const out = runInstall({
|
|
80
|
+
CACHE_ENABLED,
|
|
81
|
+
STORE_DIR_INPUT: "/mnt/fast/store",
|
|
82
|
+
TOOL_CACHE: "/opt/hostedtoolcache",
|
|
83
|
+
});
|
|
84
|
+
assert.match(out, /--store-dir \/mnt\/fast\/store/, `cache: '${CACHE_ENABLED}'`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("no tool cache resolved falls back to pnpm's default rather than passing an empty path", () => {
|
|
89
|
+
const out = runInstall({ CACHE_ENABLED: "false", TOOL_CACHE: "" });
|
|
90
|
+
assert.doesNotMatch(out, /--store-dir/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("trust-lockfile composes with the store flag instead of replacing it", () => {
|
|
94
|
+
const out = runInstall({
|
|
95
|
+
CACHE_ENABLED: "false",
|
|
96
|
+
TOOL_CACHE: "/opt/hostedtoolcache",
|
|
97
|
+
TRUST_LOCKFILE: "true",
|
|
98
|
+
});
|
|
99
|
+
assert.match(out, /--trust-lockfile/);
|
|
100
|
+
assert.match(out, /--store-dir \/opt\/hostedtoolcache\/pnpm-store/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a store path containing a space stays one argument", () => {
|
|
104
|
+
// The reason the script uses positional parameters rather than string
|
|
105
|
+
// concatenation. Also why it cannot use a bash array: this runs on macOS
|
|
106
|
+
// bash 3.2, where "${arr[@]}" under `set -u` errors on an empty array.
|
|
107
|
+
const out = runInstall({ CACHE_ENABLED: "false", STORE_DIR_INPUT: "/mnt/my store" });
|
|
108
|
+
assert.match(out, /--store-dir \/mnt\/my store/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("the store never resolves inside the directory the pre-clean removes", () => {
|
|
112
|
+
// The whole point. PNPM_DEST is what "Clean stale pnpm install dir" deletes;
|
|
113
|
+
// a store under it is a store that cannot survive a job.
|
|
114
|
+
const out = runInstall({ CACHE_ENABLED: "false", TOOL_CACHE: "/opt/hostedtoolcache" });
|
|
115
|
+
const argv = out.match(/PNPM_ARGV: (.*)/)?.[1] ?? "";
|
|
116
|
+
const storeDir = argv.match(/--store-dir (\S+)/)?.[1] ?? "";
|
|
117
|
+
assert.notEqual(storeDir, "", "expected a --store-dir with caching disabled");
|
|
118
|
+
assert.ok(
|
|
119
|
+
!storeDir.includes("/pnpm/store") && !storeDir.includes("_temp"),
|
|
120
|
+
`store must not live under the pnpm shim dest; got ${storeDir}`,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("pr-quality threads toolchain-store-dir to the shared setup-toolchain anchor", () => {
|
|
125
|
+
const workflow = readFileSync(WORKFLOW, "utf8");
|
|
126
|
+
assert.match(
|
|
127
|
+
workflow,
|
|
128
|
+
/^ {6}toolchain-store-dir:$/m,
|
|
129
|
+
"workflow_call input toolchain-store-dir is missing",
|
|
130
|
+
);
|
|
131
|
+
assert.match(
|
|
132
|
+
workflow,
|
|
133
|
+
/store-dir: \$\{\{ inputs\.toolchain-store-dir \}\}/,
|
|
134
|
+
"the setup-toolchain anchor does not pass store-dir through",
|
|
135
|
+
);
|
|
136
|
+
// One anchor, aliased by the other jobs: threading it once must reach them all.
|
|
137
|
+
const aliases = workflow.match(/^ {6}- \*setup-toolchain$/gm) ?? [];
|
|
138
|
+
assert.ok(aliases.length > 0, "expected the setup-toolchain anchor to be aliased");
|
|
139
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-toolchain-cache-default.test.mjs — regression guard for the derived
|
|
4
|
+
* `toolchain-cache` default (Story #364).
|
|
5
|
+
*
|
|
6
|
+
* The bug this pins: `toolchain-cache` defaulted to `'true'`, which is only
|
|
7
|
+
* correct for a GitHub-hosted caller. On a self-hosted runner with a warm pnpm
|
|
8
|
+
* store the cache adds a POST-JOB save — a step that runs after every work step
|
|
9
|
+
* has already reported success, billed against the same `timeout-minutes` those
|
|
10
|
+
* steps spent. A slow save exhausts the ceiling, GitHub kills the job, and the
|
|
11
|
+
* kill is recorded as `cancelled` rather than `failure`, which `ci-required`
|
|
12
|
+
* maps to a red gate with every step green.
|
|
13
|
+
*
|
|
14
|
+
* The fix cannot live in the input's `default:` — a `workflow_call` default may
|
|
15
|
+
* not hold a `${{ }}` expression (check-workflow-portability.mjs Rule 2), and
|
|
16
|
+
* GitHub resolves defaults during interface validation, before `inputs.runner`
|
|
17
|
+
* exists. So the default is the LITERAL `'auto'` and the derivation happens at
|
|
18
|
+
* the use site.
|
|
19
|
+
*
|
|
20
|
+
* Asserting that by string-matching the expression would pin the spelling, not
|
|
21
|
+
* the behaviour: the failure mode here is a subtly wrong ternary that still
|
|
22
|
+
* looks right (Actions `&&`/`||` return OPERANDS, not booleans, and every
|
|
23
|
+
* non-empty string — including `'false'` — is truthy). So this extracts the real
|
|
24
|
+
* expression from each workflow and EVALUATES it under GitHub's own truthiness
|
|
25
|
+
* rules, the same read-then-execute approach as
|
|
26
|
+
* check-setup-toolchain-store.test.mjs.
|
|
27
|
+
*
|
|
28
|
+
* Run: node --test scripts/check-toolchain-cache-default.test.mjs
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import assert from "node:assert/strict";
|
|
32
|
+
import { test } from "node:test";
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
|
|
35
|
+
const QUALITY = ".github/workflows/pr-quality.yml";
|
|
36
|
+
const ADVISORY = ".github/workflows/advisory-scan.yml";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Extraction
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/** The `${{ … }}` body of the `cache:` value passed to setup-toolchain. */
|
|
43
|
+
function cacheExpression(text, file) {
|
|
44
|
+
const m = text.match(/^\s*cache:\s*\$\{\{(.+)\}\}\s*$/m);
|
|
45
|
+
assert.ok(m, `${file}: no \`cache: \${{ … }}\` value found at the setup-toolchain call site`);
|
|
46
|
+
return m[1].trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The literal `default:` of the named workflow_call input.
|
|
51
|
+
*
|
|
52
|
+
* Scans lines rather than building a `new RegExp` around `name`: a
|
|
53
|
+
* dynamically-constructed regex is a SAST finding (ReDoS surface) and buys
|
|
54
|
+
* nothing here, since the block boundary is just indentation.
|
|
55
|
+
*/
|
|
56
|
+
function inputDefault(text, name, file) {
|
|
57
|
+
const lines = text.split("\n");
|
|
58
|
+
const start = lines.indexOf(` ${name}:`);
|
|
59
|
+
assert.notEqual(start, -1, `${file}: workflow_call input \`${name}\` not found`);
|
|
60
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
61
|
+
if (lines[i].trim() === "") continue;
|
|
62
|
+
// Dedent to the input-name level or beyond → the block ended.
|
|
63
|
+
if (lines[i].match(/^(\s*)/)[1].length <= 6) break;
|
|
64
|
+
const d = lines[i].match(/^\s*default:\s*(.+)$/);
|
|
65
|
+
if (d) return d[1].trim();
|
|
66
|
+
}
|
|
67
|
+
return assert.fail(`${file}: input \`${name}\` has no default`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
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.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// The contract
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const workflows = [
|
|
216
|
+
{ file: QUALITY, text: readFileSync(QUALITY, "utf8") },
|
|
217
|
+
{ file: ADVISORY, text: readFileSync(ADVISORY, "utf8") },
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
for (const { file, text } of workflows) {
|
|
221
|
+
test(`${file}: the toolchain-cache default is a literal 'auto', not an expression`, () => {
|
|
222
|
+
// AC-1. An expression here is what the portability lint (Rule 2) rejects
|
|
223
|
+
// and what GitHub silently fails to evaluate at interface-validation time.
|
|
224
|
+
const value = inputDefault(text, "toolchain-cache", file);
|
|
225
|
+
assert.equal(value, "'auto'");
|
|
226
|
+
assert.doesNotMatch(value, /\$\{\{/, "a workflow_call default may not hold an expression");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test(`${file}: an unset toolchain-cache disables caching on a self-hosted runner`, () => {
|
|
230
|
+
// AC-2 — the whole point: no post-job store save, so no teardown eating the
|
|
231
|
+
// job's timeout and reporting as `cancelled` with every step green.
|
|
232
|
+
const expr = cacheExpression(text, file);
|
|
233
|
+
for (const runner of ["self-hosted", '["self-hosted","domio-runner"]', '["self-hosted"]']) {
|
|
234
|
+
assert.equal(
|
|
235
|
+
evaluate(expr, { "toolchain-cache": "auto", runner }),
|
|
236
|
+
"false",
|
|
237
|
+
`runner ${runner}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test(`${file}: an unset toolchain-cache keeps caching on for a hosted runner`, () => {
|
|
243
|
+
// AC-3 — hosted behaviour must not move. Detection is self-hosted-side
|
|
244
|
+
// precisely so a larger-runner custom label is not mistaken for one.
|
|
245
|
+
const expr = cacheExpression(text, file);
|
|
246
|
+
for (const runner of [
|
|
247
|
+
"ubuntu-latest",
|
|
248
|
+
"ubuntu-24.04",
|
|
249
|
+
"macos-14",
|
|
250
|
+
"windows-latest",
|
|
251
|
+
"ubuntu-latest-8-cores",
|
|
252
|
+
'["ubuntu-latest"]',
|
|
253
|
+
]) {
|
|
254
|
+
assert.equal(evaluate(expr, { "toolchain-cache": "auto", runner }), "true", `runner ${runner}`);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test(`${file}: an explicit toolchain-cache is passed through on either runner class`, () => {
|
|
259
|
+
// AC-4. 'false' is the one that breaks under a naive ternary — it is a
|
|
260
|
+
// non-empty string and therefore truthy, which is what makes it survive.
|
|
261
|
+
const expr = cacheExpression(text, file);
|
|
262
|
+
for (const runner of ["ubuntu-latest", '["self-hosted","domio-runner"]']) {
|
|
263
|
+
for (const pinned of ["true", "false"]) {
|
|
264
|
+
assert.equal(
|
|
265
|
+
evaluate(expr, { "toolchain-cache": pinned, runner }),
|
|
266
|
+
pinned,
|
|
267
|
+
`${pinned} on ${runner}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
test("both workflows derive the default identically", () => {
|
|
275
|
+
// AC-5. Two documented rows describing one behaviour must not drift apart —
|
|
276
|
+
// the reason this Story moves both workflows rather than only the quality one.
|
|
277
|
+
const [quality, advisory] = workflows.map(({ file, text }) => cacheExpression(text, file));
|
|
278
|
+
assert.equal(advisory, quality);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("pr-quality resolves the default at the shared setup-toolchain anchor", () => {
|
|
282
|
+
// One anchor, aliased by every other tier: deriving it once must reach them
|
|
283
|
+
// all, or a tier silently keeps the old always-on default.
|
|
284
|
+
const text = readFileSync(QUALITY, "utf8");
|
|
285
|
+
assert.match(text, /^ {6}- &setup-toolchain$/m, "the setup-toolchain anchor is missing");
|
|
286
|
+
const aliases = text.match(/^ {6}- \*setup-toolchain$/gm) ?? [];
|
|
287
|
+
assert.ok(aliases.length > 0, "expected the setup-toolchain anchor to be aliased");
|
|
288
|
+
assert.equal(
|
|
289
|
+
(text.match(/^\s*cache:\s*\$\{\{/gm) ?? []).length,
|
|
290
|
+
1,
|
|
291
|
+
"expected exactly one cache: call site — a second one would bypass the anchor",
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("both documented input rows state the derived default and the self-hosted reason", () => {
|
|
296
|
+
// AC-6. The rows are the consumer-facing contract; a fix documented in only
|
|
297
|
+
// one of the two tables leaves them contradicting each other.
|
|
298
|
+
const docs = readFileSync("docs/reusable-workflows.md", "utf8");
|
|
299
|
+
// Scoped to the two `Inputs` tables (`| name | type | default | …`) so the
|
|
300
|
+
// explainer section's own table is not counted as a third row.
|
|
301
|
+
const rows = docs.split("\n").filter((l) => /^\|\s*`toolchain-cache`\s*\|\s*string\s*\|/.test(l));
|
|
302
|
+
assert.equal(rows.length, 2, "expected one documented row per workflow");
|
|
303
|
+
for (const row of rows) {
|
|
304
|
+
assert.match(row, /`'auto'`/, "row does not state the 'auto' default");
|
|
305
|
+
assert.match(row, /self-hosted/i, "row does not explain the self-hosted derivation");
|
|
306
|
+
assert.doesNotMatch(row, /\|\s*`'true'`\s*\|/, "row still documents the old 'true' default");
|
|
307
|
+
}
|
|
308
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/yaml-step.mjs
|
|
3
|
+
*
|
|
4
|
+
* The single indentation-based YAML step extractor shared by the workflow and
|
|
5
|
+
* composite-action test suites under `scripts/` (Story #377).
|
|
6
|
+
*
|
|
7
|
+
* ## Why this is a shared module and not five copies
|
|
8
|
+
*
|
|
9
|
+
* Several suites in this repository do not merely READ a workflow — they pull
|
|
10
|
+
* the real `run:` body out of a named step and execute it against a stubbed
|
|
11
|
+
* binary, because what decides the outcome is a shell branch rather than the
|
|
12
|
+
* YAML around it. That read-then-execute pattern needs exactly two helpers,
|
|
13
|
+
* and they had been copy-pasted verbatim into five test files
|
|
14
|
+
* (check-affected-mode, check-environments-isolation-audit,
|
|
15
|
+
* check-gitleaks-allowlist, check-osv-scan-mode, check-setup-toolchain-store).
|
|
16
|
+
*
|
|
17
|
+
* The duplication was not cosmetic. These helpers decide WHICH BYTES each
|
|
18
|
+
* suite executes, so a copy that drifts and silently extracts the wrong block
|
|
19
|
+
* asserts against different text and still reports green — the same
|
|
20
|
+
* runs-but-verifies-nothing failure the suites themselves exist to prevent.
|
|
21
|
+
* One definition means a drift is a change to one file that every caller sees.
|
|
22
|
+
*
|
|
23
|
+
* ## Deliberately dependency-free, and deliberately test-only
|
|
24
|
+
*
|
|
25
|
+
* There is no YAML parser in this repository's dependency graph, by design:
|
|
26
|
+
* the guardrail scripts consumers copy into their CI must run on a bare Node
|
|
27
|
+
* runtime with no package install. So this is a line-oriented indentation
|
|
28
|
+
* reader, not a parser. It handles the two shapes the suites actually use — a
|
|
29
|
+
* `- name:`-keyed step block, and a `run: |` block scalar — and nothing else.
|
|
30
|
+
*
|
|
31
|
+
* Both helpers `assert` internally rather than returning a sentinel: a step
|
|
32
|
+
* name that no longer resolves is a test-authoring fault that must fail loudly
|
|
33
|
+
* at the point of extraction, not silently degrade into an empty script that
|
|
34
|
+
* passes every downstream assertion. That is why this module imports
|
|
35
|
+
* `node:assert/strict`, which is acceptable here because this is a TEST-ONLY
|
|
36
|
+
* lib module — nothing under `scripts/*.mjs` that runs in CI imports it.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import assert from "node:assert/strict";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract the text of a single step, keyed by a substring of its `name:`.
|
|
43
|
+
*
|
|
44
|
+
* A step spans from its `- ` bullet to the next sibling bullet at the same
|
|
45
|
+
* indent (or to the first dedent below it), so nested `with:` / `env:` mappings
|
|
46
|
+
* come along and a following step never does.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} text Workflow or composite-action YAML.
|
|
49
|
+
* @param {string} name A substring of the target step's `name:` value.
|
|
50
|
+
* @returns {string} The step block, newline-joined, including its `- ` bullet.
|
|
51
|
+
* @throws {assert.AssertionError} When no step name contains `name`.
|
|
52
|
+
*/
|
|
53
|
+
export function stepByName(text, name) {
|
|
54
|
+
const lines = text.split("\n");
|
|
55
|
+
const nameIdx = lines.findIndex((l) => /^\s+(- )?name:\s/.test(l) && l.includes(name));
|
|
56
|
+
assert.notEqual(nameIdx, -1, `step "${name}" not found`);
|
|
57
|
+
let start = -1;
|
|
58
|
+
for (let i = nameIdx; i >= 0; i--) {
|
|
59
|
+
if (/^\s*-\s/.test(lines[i])) {
|
|
60
|
+
start = i;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
assert.notEqual(start, -1, `opening bullet for step "${name}" not found`);
|
|
65
|
+
const bulletIndent = lines[start].match(/^(\s*)/)[1].length;
|
|
66
|
+
let end = lines.length;
|
|
67
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
68
|
+
if (/^\s*$/.test(lines[i])) continue;
|
|
69
|
+
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
70
|
+
if (indent < bulletIndent) {
|
|
71
|
+
end = i;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (indent === bulletIndent && /^\s*-\s/.test(lines[i])) {
|
|
75
|
+
end = i;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return lines.slice(start, end).join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The dedented body of a step's `run: |` block scalar.
|
|
84
|
+
*
|
|
85
|
+
* Blank lines are preserved as empty lines rather than skipped, so line numbers
|
|
86
|
+
* inside the extracted script still line up with the workflow — which is what
|
|
87
|
+
* makes a `bash -x` trace of the executed body readable against the source.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} stepBlock A step block, typically from {@link stepByName}.
|
|
90
|
+
* @returns {string} The `run:` body with the block-scalar indent removed.
|
|
91
|
+
* @throws {assert.AssertionError} When the step has no `run: |` block scalar.
|
|
92
|
+
*/
|
|
93
|
+
export function runScript(stepBlock) {
|
|
94
|
+
const lines = stepBlock.split("\n");
|
|
95
|
+
const start = lines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
|
|
96
|
+
assert.notEqual(start, -1, "`run: |` block not found");
|
|
97
|
+
const runIndent = lines[start].match(/^(\s*)/)[1].length;
|
|
98
|
+
const body = [];
|
|
99
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
100
|
+
if (/^\s*$/.test(lines[i])) {
|
|
101
|
+
body.push("");
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
105
|
+
if (indent <= runIndent) break;
|
|
106
|
+
body.push(lines[i].slice(runIndent + 2));
|
|
107
|
+
}
|
|
108
|
+
return body.join("\n");
|
|
109
|
+
}
|