mandrel-platform 1.13.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/package.json +4 -2
- package/scripts/audit-check.mjs +210 -47
- package/scripts/audit-check.test.mjs +479 -49
- package/scripts/check-action-download-retries.mjs +155 -11
- package/scripts/check-action-download-retries.test.mjs +168 -3
- package/scripts/check-advisory-scan-setup.test.mjs +186 -5
- package/scripts/check-coverage-threshold.mjs +112 -17
- package/scripts/check-coverage-threshold.test.mjs +335 -0
- package/scripts/check-husky-hook-modes.test.mjs +134 -0
- package/scripts/check-runner-runs-on.test.mjs +223 -2
- package/scripts/check-semgrep-lockfile.test.mjs +238 -7
- package/scripts/check-workflow-lint-tier.test.mjs +176 -0
- package/scripts/check-workflow-portability.mjs +6 -3
- package/scripts/check-workflow-portability.test.mjs +1 -1
- package/scripts/env-doctor.mjs +493 -62
- package/scripts/env-doctor.test.mjs +529 -3
- package/scripts/fixtures/npm-audit-v2.json +66 -0
- package/scripts/install-git-hooks.mjs +123 -0
- package/scripts/install-git-hooks.test.mjs +160 -0
- package/scripts/issue-intake.test.mjs +351 -21
- package/scripts/runner-toggle.test.mjs +407 -0
- package/scripts/select-semgrep-python.sh +98 -17
- package/scripts/select-semgrep-python.test.mjs +139 -12
- package/scripts/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* runner-toggle.test.mjs — node:test suite for the operator scale tool shipped
|
|
4
|
+
* at `docs/runbooks/runner-toggle.sh` (Story #492).
|
|
5
|
+
*
|
|
6
|
+
* WHAT THIS SUITE IS FOR
|
|
7
|
+
*
|
|
8
|
+
* The tool's one destructive action is `svc.sh stop`, a bare `launchctl unload`
|
|
9
|
+
* that CANCELS whatever job the runner is running. The only thing standing
|
|
10
|
+
* between a scale-down and a cancelled job is `is_busy`, so `is_busy` being
|
|
11
|
+
* right is the whole safety story — and until this suite existed, nothing in
|
|
12
|
+
* CI executed a line of this script.
|
|
13
|
+
*
|
|
14
|
+
* The bug it was written against: `is_busy` was `pgrep -qf "$1/bin/Runner.Worker"`,
|
|
15
|
+
* and `pgrep -f`'s pattern is an extended REGEX over the process table, not a
|
|
16
|
+
* path. A fleet path holding `+`, `(` or `[` therefore matched the wrong set of
|
|
17
|
+
* processes in BOTH directions, and the suite pins both because only one of
|
|
18
|
+
* them is loud:
|
|
19
|
+
*
|
|
20
|
+
* • The literal path `…/rnr+x(1)/bin/Runner.Worker` does NOT match the regex
|
|
21
|
+
* built from that same path (`r+` is "one or more r", `(1)` is a group), so
|
|
22
|
+
* a genuinely busy runner read as idle — and a scale-down cancelled a
|
|
23
|
+
* running job with no prompt at all. This is the silent, dangerous one.
|
|
24
|
+
* • That regex DOES match `…/rnrx1/bin/Runner.Worker`, a different runner, so
|
|
25
|
+
* an idle runner read as busy and the operator was asked to wait for a job
|
|
26
|
+
* that did not exist.
|
|
27
|
+
*
|
|
28
|
+
* `canary: the old regex form really did confuse these two paths` asserts that
|
|
29
|
+
* property directly against bash's own ERE engine, so the fixture pair below
|
|
30
|
+
* can never quietly stop being a regex-vs-literal discriminator.
|
|
31
|
+
*
|
|
32
|
+
* A `ps … | grep -F "$needle"` pipeline is the obvious-looking fix and is worse
|
|
33
|
+
* than the bug: grep's own command line contains the needle, so it matches
|
|
34
|
+
* itself and reports EVERY runner as busy — and a stubbed `ps` fixture would
|
|
35
|
+
* not catch it, because the stub's table has no grep line in it. That is why
|
|
36
|
+
* the implementation is a pure-bash `case` and why `no grep/pgrep survives in
|
|
37
|
+
* the busy path` is a source scan rather than an execution test.
|
|
38
|
+
*
|
|
39
|
+
* HOW THE REAL FUNCTION IS EXERCISED
|
|
40
|
+
*
|
|
41
|
+
* The script SOURCES cleanly — everything below its `BASH_SOURCE[0] == $0`
|
|
42
|
+
* guard is the interactive body — so these tests call the shipped `is_busy`
|
|
43
|
+
* itself rather than a copy: no prompt, no launchd, no fleet on disk. `ps` is
|
|
44
|
+
* resolved from PATH, so a stub script earlier on PATH supplies the process
|
|
45
|
+
* table. Nothing here mutates the machine, and no test needs a real runner.
|
|
46
|
+
*
|
|
47
|
+
* INTERPRETER COVERAGE
|
|
48
|
+
*
|
|
49
|
+
* The fleet is macOS, where `/bin/bash` is 3.2 (Apple cannot ship a GPL3 bash),
|
|
50
|
+
* so `resolveBash()` follows `runner-env-drift.test.mjs`: prefer `/bin/bash`,
|
|
51
|
+
* fall back to PATH `bash`, override with `RUNNER_KIT_BASH`, and report which
|
|
52
|
+
* one actually ran. `ci.yml`'s `runner-kit-bash32` job pins `/bin/bash` and
|
|
53
|
+
* also runs `bash -n` over the script, so a 3.2-only syntax regression surfaces
|
|
54
|
+
* in CI rather than at an operator's prompt.
|
|
55
|
+
*
|
|
56
|
+
* Run: node --test scripts/runner-toggle.test.mjs
|
|
57
|
+
* RUNNER_KIT_BASH=/bin/bash node --test scripts/runner-toggle.test.mjs
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import assert from "node:assert/strict";
|
|
61
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
62
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
63
|
+
import { tmpdir } from "node:os";
|
|
64
|
+
import { dirname, join } from "node:path";
|
|
65
|
+
import { fileURLToPath } from "node:url";
|
|
66
|
+
import { after, test } from "node:test";
|
|
67
|
+
|
|
68
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
69
|
+
const SCRIPT = join(HERE, "..", "docs", "runbooks", "runner-toggle.sh");
|
|
70
|
+
const RUNBOOK = join(HERE, "..", "docs", "runbooks", "runner-fleet.md");
|
|
71
|
+
const CI_WORKFLOW = join(HERE, "..", ".github", "workflows", "ci.yml");
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The fixture pair the regex bug turned on. `METACHAR_ROOT` is a real fleet
|
|
75
|
+
* path shape (a folder name with `+` and parentheses in it); `DECOY_ROOT` is
|
|
76
|
+
* the DIFFERENT path that the regex built from `METACHAR_ROOT` happens to
|
|
77
|
+
* match. They differ only in those metacharacters.
|
|
78
|
+
*/
|
|
79
|
+
const FLEET = "/Users/ci/github-runners/beestera-runners";
|
|
80
|
+
const METACHAR_ROOT = `${FLEET}/rnr+x(1)`;
|
|
81
|
+
const DECOY_ROOT = `${FLEET}/rnrx1`;
|
|
82
|
+
|
|
83
|
+
/** A worker command line as the runner really spawns it: full path, then args. */
|
|
84
|
+
const worker = (root) => `${root}/bin/Runner.Worker spawnclient 110 113`;
|
|
85
|
+
/** The always-on listener — present for every loaded runner, busy or not. */
|
|
86
|
+
const listener = (root) => `${root}/bin/Runner.Listener run --startuptype service`;
|
|
87
|
+
|
|
88
|
+
/** Sandboxes created by the suite, torn down in `after`. */
|
|
89
|
+
const SANDBOXES = [];
|
|
90
|
+
|
|
91
|
+
after(() => {
|
|
92
|
+
for (const dir of SANDBOXES) {
|
|
93
|
+
rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the interpreter every execution test runs under, and record WHICH
|
|
99
|
+
* one. Bare `bash` from PATH silently varies by host (3.2 on a stock Mac, 5.x
|
|
100
|
+
* on ubuntu), so a suite that inherits it cannot say what its passes prove.
|
|
101
|
+
*
|
|
102
|
+
* @returns {{ cmd: string, banner: string, major: number|null }}
|
|
103
|
+
*/
|
|
104
|
+
function resolveBash() {
|
|
105
|
+
const candidates = process.env.RUNNER_KIT_BASH ? [process.env.RUNNER_KIT_BASH] : ["/bin/bash", "bash"];
|
|
106
|
+
for (const cmd of candidates) {
|
|
107
|
+
let banner;
|
|
108
|
+
try {
|
|
109
|
+
banner = execFileSync(cmd, ["--version"], { encoding: "utf8" }).split("\n")[0].trim();
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const m = /version (\d+)\./.exec(banner);
|
|
114
|
+
return { cmd, banner, major: m ? Number(m[1]) : null };
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`no usable bash interpreter (tried ${candidates.join(", ")}) — this suite executes a shell script`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const BASH = resolveBash();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build a directory holding a stub `ps`, to be prepended to PATH.
|
|
123
|
+
*
|
|
124
|
+
* The stub ignores its arguments and prints `lines` — that is the whole process
|
|
125
|
+
* table as far as the script is concerned. `fail: true` makes it exit non-zero
|
|
126
|
+
* instead, which is how the "unreadable process table" path is driven.
|
|
127
|
+
*
|
|
128
|
+
* @param {string[]} lines
|
|
129
|
+
* @param {{ fail?: boolean }} [opts]
|
|
130
|
+
* @returns {string} absolute bin directory
|
|
131
|
+
*/
|
|
132
|
+
function stubPs(lines, opts = {}) {
|
|
133
|
+
const sandbox = mkdtempSync(join(tmpdir(), "runner-toggle-"));
|
|
134
|
+
SANDBOXES.push(sandbox);
|
|
135
|
+
const binDir = join(sandbox, "bin");
|
|
136
|
+
mkdirSync(binDir, { recursive: true });
|
|
137
|
+
|
|
138
|
+
// A quoted heredoc: the table is emitted verbatim, metacharacters and all.
|
|
139
|
+
const body = opts.fail
|
|
140
|
+
? '#!/bin/sh\necho "ps: fixture failure" >&2\nexit 1\n'
|
|
141
|
+
: `#!/bin/sh\ncat <<'PS_TABLE_EOF'\n${lines.join("\n")}\nPS_TABLE_EOF\n`;
|
|
142
|
+
const stub = join(binDir, "ps");
|
|
143
|
+
writeFileSync(stub, body);
|
|
144
|
+
chmodSync(stub, 0o755);
|
|
145
|
+
return binDir;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Source the shipped script and call the real `is_busy` for `root`, with the
|
|
150
|
+
* process table supplied by a stub `ps` on PATH.
|
|
151
|
+
*
|
|
152
|
+
* `$0` is the probe name rather than the script, so `BASH_SOURCE[0] == $0` is
|
|
153
|
+
* false and the interactive body stays asleep.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} root — the runner directory to ask about
|
|
156
|
+
* @param {string[]} table — stub `ps` output
|
|
157
|
+
* @param {{ fail?: boolean }} [opts]
|
|
158
|
+
* @returns {{ verdict: string, status: number, stdout: string, stderr: string }}
|
|
159
|
+
*/
|
|
160
|
+
function probeIsBusy(root, table, opts = {}) {
|
|
161
|
+
const binDir = stubPs(table, opts);
|
|
162
|
+
const res = spawnSync(
|
|
163
|
+
BASH.cmd,
|
|
164
|
+
[
|
|
165
|
+
"-c",
|
|
166
|
+
'. "$1"; if is_busy "$2"; then echo BUSY; else echo IDLE; fi',
|
|
167
|
+
"runner-toggle-probe",
|
|
168
|
+
SCRIPT,
|
|
169
|
+
root,
|
|
170
|
+
],
|
|
171
|
+
{
|
|
172
|
+
encoding: "utf8",
|
|
173
|
+
timeout: 60_000,
|
|
174
|
+
input: "",
|
|
175
|
+
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` },
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
const stdout = res.stdout ?? "";
|
|
179
|
+
return {
|
|
180
|
+
verdict: stdout.trim().split("\n").pop() ?? "",
|
|
181
|
+
status: res.status ?? 1,
|
|
182
|
+
stdout,
|
|
183
|
+
stderr: res.stderr ?? "",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The script source with whole-line comments stripped — for source scans. */
|
|
188
|
+
function sourceWithoutComments() {
|
|
189
|
+
return readFileSync(SCRIPT, "utf8")
|
|
190
|
+
.split("\n")
|
|
191
|
+
.filter((line) => !/^\s*#/.test(line))
|
|
192
|
+
.join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The body of the `is_busy` function, comments included. */
|
|
196
|
+
function isBusyBody() {
|
|
197
|
+
const source = readFileSync(SCRIPT, "utf8");
|
|
198
|
+
const start = source.indexOf("is_busy() {");
|
|
199
|
+
assert.notEqual(start, -1, "is_busy must still be a named function — the test sources and calls it");
|
|
200
|
+
const end = source.indexOf("\n}\n", start);
|
|
201
|
+
assert.notEqual(end, -1, "could not find the end of is_busy");
|
|
202
|
+
return source.slice(start, end);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
test("AC-1: a busy runner whose path holds regex metacharacters reports BUSY", () => {
|
|
206
|
+
// The dangerous direction. Under the old `pgrep -f` form this answered IDLE,
|
|
207
|
+
// and a scale-down then cancelled a running job without ever prompting.
|
|
208
|
+
const { verdict, stderr } = probeIsBusy(METACHAR_ROOT, [
|
|
209
|
+
"/sbin/launchd",
|
|
210
|
+
listener(METACHAR_ROOT),
|
|
211
|
+
worker(METACHAR_ROOT),
|
|
212
|
+
listener(DECOY_ROOT),
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
assert.equal(verdict, "BUSY", `a runner with its own Runner.Worker in the table is busy\nstderr: ${stderr}`);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("AC-1: a different runner matching only as a regex reports IDLE", () => {
|
|
219
|
+
// The loud direction: the table holds a worker for DECOY_ROOT alone, which
|
|
220
|
+
// the regex built from METACHAR_ROOT matches and a literal comparison does
|
|
221
|
+
// not. Answering BUSY here would block a legitimate scale-down and make the
|
|
222
|
+
// operator wait for a job that is not theirs.
|
|
223
|
+
const { verdict, stderr } = probeIsBusy(METACHAR_ROOT, [
|
|
224
|
+
"/sbin/launchd",
|
|
225
|
+
listener(METACHAR_ROOT),
|
|
226
|
+
worker(DECOY_ROOT),
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
assert.equal(verdict, "IDLE", `only the exact worker path counts as busy\nstderr: ${stderr}`);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("canary: the old regex form really did confuse these two paths", () => {
|
|
233
|
+
// Guards the fixture pair itself. If a later edit made METACHAR_ROOT and
|
|
234
|
+
// DECOY_ROOT stop being a regex-vs-literal discriminator, the two tests above
|
|
235
|
+
// would keep passing while proving nothing. Asserted against bash's own ERE
|
|
236
|
+
// engine — the same one `pgrep -f` uses — rather than a JS approximation.
|
|
237
|
+
const res = spawnSync(
|
|
238
|
+
BASH.cmd,
|
|
239
|
+
[
|
|
240
|
+
"-c",
|
|
241
|
+
'if [[ "$1" =~ $2 ]]; then echo DECOY_MATCHES; else echo decoy-no; fi\n' +
|
|
242
|
+
'if [[ "$3" =~ $2 ]]; then echo real-yes; else echo REAL_MISSES; fi',
|
|
243
|
+
"regex-canary",
|
|
244
|
+
worker(DECOY_ROOT),
|
|
245
|
+
`${METACHAR_ROOT}/bin/Runner.Worker`,
|
|
246
|
+
worker(METACHAR_ROOT),
|
|
247
|
+
],
|
|
248
|
+
{ encoding: "utf8", timeout: 60_000 },
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
assert.equal(res.status, 0, `canary failed to run: ${res.stderr}`);
|
|
252
|
+
assert.match(
|
|
253
|
+
res.stdout,
|
|
254
|
+
/DECOY_MATCHES/,
|
|
255
|
+
"the decoy path must still match the regex form, or the IDLE test proves nothing",
|
|
256
|
+
);
|
|
257
|
+
assert.match(
|
|
258
|
+
res.stdout,
|
|
259
|
+
/REAL_MISSES/,
|
|
260
|
+
"the real path must still be MISSED by the regex form, or the BUSY test proves nothing",
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("AC-1: a longer path that merely starts with the worker path is not this runner", () => {
|
|
265
|
+
// `Runner.WorkerX` and `<root>-2/bin/Runner.Worker` are both substrings-adjacent
|
|
266
|
+
// to the needle. A whole-token comparison is what keeps them out.
|
|
267
|
+
const { verdict } = probeIsBusy(`${FLEET}/rnr1`, [
|
|
268
|
+
`${FLEET}/rnr1/bin/Runner.WorkerX serve`,
|
|
269
|
+
worker(`${FLEET}/rnr1-2`),
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
assert.equal(verdict, "IDLE", "a different executable and a different runner are both not this runner's job");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("AC-1: a loaded but idle runner (listener only, no worker) reports IDLE", () => {
|
|
276
|
+
const { verdict } = probeIsBusy(METACHAR_ROOT, ["/sbin/launchd", listener(METACHAR_ROOT)]);
|
|
277
|
+
|
|
278
|
+
assert.equal(verdict, "IDLE", "the listener is always running — only Runner.Worker means mid-job");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("AC-1: a fleet path containing a space is matched literally too", () => {
|
|
282
|
+
// The needle is compared as a quoted token, so an argv-splitting bug here
|
|
283
|
+
// would show up as a wrong answer rather than a syntax error.
|
|
284
|
+
const spaced = "/Users/ci/github runners/fleet a/rnr 1";
|
|
285
|
+
const busy = probeIsBusy(spaced, [listener(spaced), worker(spaced)]);
|
|
286
|
+
const idle = probeIsBusy(spaced, [listener(spaced), worker("/Users/ci/github runners/fleet a/rnr 2")]);
|
|
287
|
+
|
|
288
|
+
assert.equal(busy.verdict, "BUSY", `a path with spaces must still match itself\nstderr: ${busy.stderr}`);
|
|
289
|
+
assert.equal(idle.verdict, "IDLE", "a sibling runner's worker must not mark this one busy");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("an unreadable process table reports BUSY, never idle", () => {
|
|
293
|
+
// Fail-safe direction: the only decision this answer gates is a stop that
|
|
294
|
+
// cancels a job, so "I could not tell" must never be spelled "idle".
|
|
295
|
+
const { verdict, stderr } = probeIsBusy(METACHAR_ROOT, [], { fail: true });
|
|
296
|
+
|
|
297
|
+
assert.equal(verdict, "BUSY", "a ps failure must not be readable as an idle runner");
|
|
298
|
+
assert.match(stderr, /could not read the process table/, "the operator must be told why every runner looks busy");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("no grep/pgrep survives in the busy path", () => {
|
|
302
|
+
// `ps … | grep -F "$needle"` matches grep's OWN command line and reads every
|
|
303
|
+
// runner as busy — and a stubbed-`ps` fixture cannot catch that, because the
|
|
304
|
+
// stub's table contains no grep line. Hence a source scan.
|
|
305
|
+
const body = isBusyBody();
|
|
306
|
+
|
|
307
|
+
assert.equal(/\bgrep\b/.test(body.replace(/^\s*#.*$/gm, "")), false, "a grep in the pipeline matches itself");
|
|
308
|
+
assert.equal(/\bpgrep\b/.test(sourceWithoutComments()), false, "pgrep -f takes a regex, not a path");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("AC-2: sourcing the script is side-effect free — no output, no prompt", () => {
|
|
312
|
+
// stdin is closed. Were the interactive body still running at source time,
|
|
313
|
+
// its `read -r -p` prompt would land on stderr and the tty guard's refusal on
|
|
314
|
+
// stderr too, so an empty pair of streams is a real assertion here.
|
|
315
|
+
const binDir = stubPs(["/sbin/launchd"]);
|
|
316
|
+
const res = spawnSync(BASH.cmd, ["-c", '. "$1"', "runner-toggle-probe", SCRIPT], {
|
|
317
|
+
encoding: "utf8",
|
|
318
|
+
timeout: 60_000,
|
|
319
|
+
input: "",
|
|
320
|
+
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` },
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
assert.equal(res.status, 0, `sourcing must succeed: ${res.stderr}`);
|
|
324
|
+
assert.equal(res.stdout, "", "sourcing printed to stdout — the helpers must be definitions only");
|
|
325
|
+
assert.equal(res.stderr, "", "sourcing printed to stderr — a prompt, a tty refusal or a launchd read leaked");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("AC-2: the guard does not disable the tool — running it without a terminal still refuses", () => {
|
|
329
|
+
// The other half of the guard: a source-only script would be a regression of
|
|
330
|
+
// its own. Executed (not sourced) with no tty, the interactive body must run
|
|
331
|
+
// far enough to refuse.
|
|
332
|
+
const res = spawnSync(BASH.cmd, [SCRIPT], { encoding: "utf8", timeout: 60_000, input: "" });
|
|
333
|
+
|
|
334
|
+
assert.equal(res.status, 1, "executing without a terminal must still exit 1");
|
|
335
|
+
assert.match(res.stderr, /needs a terminal on stdin/, "the interactive body must still run when the file is RUN");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("AC-4: the script is syntactically valid under the resolved bash and keeps its strict mode", () => {
|
|
339
|
+
const res = spawnSync(BASH.cmd, ["-n", SCRIPT], { encoding: "utf8", timeout: 60_000 });
|
|
340
|
+
|
|
341
|
+
assert.equal(res.status, 0, `bash -n failed:\n${res.stderr}`);
|
|
342
|
+
assert.match(
|
|
343
|
+
sourceWithoutComments(),
|
|
344
|
+
/^set -euo pipefail$/m,
|
|
345
|
+
"strict mode is what makes an unset variable or a failed svc.sh call visible",
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("AC-4: the script still ships executable", () => {
|
|
350
|
+
assert.equal(
|
|
351
|
+
(statSync(SCRIPT).mode & 0o100) !== 0,
|
|
352
|
+
true,
|
|
353
|
+
"the runbook installs this with a plain cp — it must carry its own execute bit",
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("the script uses no bash-4-only construct (source scan — interpreter-independent)", () => {
|
|
358
|
+
// The fleet runs macOS /bin/bash 3.2. Each of these fails with a SYNTAX error
|
|
359
|
+
// rather than a wrong answer, i.e. at the operator's next invocation.
|
|
360
|
+
const code = sourceWithoutComments();
|
|
361
|
+
|
|
362
|
+
assert.equal(/declare\s+-A/.test(code), false, "associative arrays are bash 4+");
|
|
363
|
+
assert.equal(/\bmapfile\b/.test(code), false, "mapfile is bash 4+");
|
|
364
|
+
assert.equal(/\breadarray\b/.test(code), false, "readarray is bash 4+");
|
|
365
|
+
assert.equal(/\$\{[A-Za-z_][A-Za-z0-9_]*\^\^?\}/.test(code), false, "case-conversion expansion is bash 4+");
|
|
366
|
+
assert.equal(/\$\{[A-Za-z_][A-Za-z0-9_]*,,?\}/.test(code), false, "case-conversion expansion is bash 4+");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("AC-3: the runner-kit CI job covers this script under the system bash", () => {
|
|
370
|
+
// `assert.ok` rather than `assert.match`: a failing `match` would print the
|
|
371
|
+
// whole workflow as the actual value and bury the line that is wrong.
|
|
372
|
+
const workflow = readFileSync(CI_WORKFLOW, "utf8");
|
|
373
|
+
|
|
374
|
+
assert.ok(workflow.includes("runner-kit-bash32:"), "the macOS bash 3.2 job must still exist");
|
|
375
|
+
assert.ok(
|
|
376
|
+
workflow.includes("node --test scripts/runner-toggle.test.mjs"),
|
|
377
|
+
"this suite must run in CI — it is the only thing that executes the toggle script",
|
|
378
|
+
);
|
|
379
|
+
assert.ok(
|
|
380
|
+
workflow.includes("bash -n docs/runbooks/runner-toggle.sh"),
|
|
381
|
+
"a 3.2 syntax regression must surface in CI, not at an operator's prompt",
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("AC-4: the runbook describes the busy check as a literal path match", () => {
|
|
386
|
+
const runbook = readFileSync(RUNBOOK, "utf8");
|
|
387
|
+
|
|
388
|
+
assert.ok(/literal/i.test(runbook), "the runbook must say the busy check matches the worker path literally");
|
|
389
|
+
assert.ok(
|
|
390
|
+
/Runner\.Worker/.test(runbook),
|
|
391
|
+
"an operator reading the runbook should know which process decides the busy verdict",
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("the suite reports which interpreter its execution tests actually prove", () => {
|
|
396
|
+
// A passing suite must never be readable as "bash 3.2 verified" when it ran
|
|
397
|
+
// under bash 5. This gates on the resolution being KNOWN and reported, not on
|
|
398
|
+
// the version — ubuntu CI legitimately has only bash 5.
|
|
399
|
+
assert.match(BASH.banner, /GNU bash, version \d+\./, "could not identify the interpreter");
|
|
400
|
+
assert.notEqual(BASH.major, null, "interpreter major version is unparseable");
|
|
401
|
+
console.log(
|
|
402
|
+
` ℹ execution tests ran under: ${BASH.cmd} — ${BASH.banner}` +
|
|
403
|
+
(BASH.major === 3
|
|
404
|
+
? " [fleet-equivalent bash 3.x]"
|
|
405
|
+
: " [NOT the fleet's 3.x — 3.2-only regressions cannot surface in this run]"),
|
|
406
|
+
);
|
|
407
|
+
});
|
|
@@ -48,15 +48,35 @@
|
|
|
48
48
|
# e.g. `semgrep==1.176.1` (diagnostics only)
|
|
49
49
|
# SEMGREP_PYTHON_FLOOR = minimum `major.minor`, e.g. `3.10` — semgrep's
|
|
50
50
|
# own `requires_python` for the pinned version
|
|
51
|
+
# SEMGREP_LOCKFILE_ABI = optional `major.minor` the hash-pinned lockfile
|
|
52
|
+
# was resolved for, e.g. `3.12` (Story #495)
|
|
51
53
|
#
|
|
52
54
|
# Outputs (set on the caller's shell):
|
|
53
55
|
# SEMGREP_PYTHON = the interpreter to build the venv with
|
|
54
56
|
# SEMGREP_PYTHON_VERSION = its `major.minor`
|
|
55
57
|
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
58
|
+
# PROBE ORDER
|
|
59
|
+
# -----------
|
|
60
|
+
# `python3` first, so a compliant runner behaves exactly as it did before this
|
|
61
|
+
# file existed, then the versioned names newest-first.
|
|
62
|
+
#
|
|
63
|
+
# On LINUX with SEMGREP_LOCKFILE_ABI set, the interpreter matching that ABI is
|
|
64
|
+
# probed FIRST instead. That ordering is what keeps the fleet on the
|
|
65
|
+
# hash-pinned install: the lockfile's wheels are cp312-only, so the moment a
|
|
66
|
+
# CI image rolls its bare `python3` to 3.13 the default order silently routes
|
|
67
|
+
# every consumer onto the un-hash-pinned, un-OSV-scanned fallback while a
|
|
68
|
+
# perfectly good `python3.12` sits unused on the same PATH. The ABI candidate
|
|
69
|
+
# is not privileged beyond order — it still has to clear the floor, and a
|
|
70
|
+
# runner that simply does not have it falls through to the normal list.
|
|
71
|
+
# Darwin keeps the plain order: no darwin hashes are generated, so there is no
|
|
72
|
+
# ABI worth steering toward.
|
|
73
|
+
#
|
|
74
|
+
# The OS is read through a bare `uname` resolved on PATH rather than a builtin,
|
|
75
|
+
# which is what lets the unit suite hand this script a Linux or a Darwin
|
|
76
|
+
# runner deterministically from a fixture directory.
|
|
77
|
+
#
|
|
78
|
+
# Returns non-zero after emitting a `::error::` when nothing on PATH qualifies;
|
|
79
|
+
# under the caller's `set -e` that fails the step.
|
|
60
80
|
|
|
61
81
|
_semgrep_python_probe() {
|
|
62
82
|
# Echo "<major> <minor>" for the interpreter named by $1, or return non-zero
|
|
@@ -66,33 +86,94 @@ _semgrep_python_probe() {
|
|
|
66
86
|
"${cmd}" -c 'import sys; print("%d %d" % sys.version_info[:2])' 2>/dev/null
|
|
67
87
|
}
|
|
68
88
|
|
|
89
|
+
_semgrep_is_uint() {
|
|
90
|
+
# True when $1 is a non-empty run of digits. Used to validate BOTH fields of
|
|
91
|
+
# a `major.minor` input before either reaches `[ ... -ge ... ]`, which is
|
|
92
|
+
# where a non-numeric field would otherwise die as bash's own
|
|
93
|
+
# "integer expression expected" — a message that names the shell rather than
|
|
94
|
+
# the offending value.
|
|
95
|
+
case "$1" in
|
|
96
|
+
"" | *[!0-9]*) return 1 ;;
|
|
97
|
+
esac
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_semgrep_fail() {
|
|
102
|
+
# GitHub parses workflow commands out of the step's output, so the
|
|
103
|
+
# `::error::` annotation goes to stdout. The same text is mirrored to stderr
|
|
104
|
+
# WITHOUT that prefix — so the runner does not raise the annotation twice —
|
|
105
|
+
# because a caller that captures only the error stream (a shell redirect, or
|
|
106
|
+
# this script's unit suite) would otherwise be handed an empty reason.
|
|
107
|
+
printf '::error::%s\n' "$1"
|
|
108
|
+
printf '%s\n' "$1" >&2
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_semgrep_python_candidates() {
|
|
112
|
+
# Echo the probe order for this runner, space-separated. The bare `uname` is
|
|
113
|
+
# resolved on PATH on purpose (see the header): a fixture shim can then
|
|
114
|
+
# supply the OS, which is the only way the Linux-only branch below is
|
|
115
|
+
# testable off a Linux host.
|
|
116
|
+
local abi="${SEMGREP_LOCKFILE_ABI:-}"
|
|
117
|
+
local default_order="python3 python3.13 python3.12 python3.11 python3.10"
|
|
118
|
+
local os abi_major abi_minor
|
|
119
|
+
|
|
120
|
+
os="$(uname -s 2>/dev/null || true)"
|
|
121
|
+
[ "${os}" = "Linux" ] || {
|
|
122
|
+
printf '%s' "${default_order}"
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
abi_major="${abi%%.*}"
|
|
127
|
+
abi_minor="${abi#*.}"
|
|
128
|
+
abi_minor="${abi_minor%%.*}"
|
|
129
|
+
# An unset or malformed ABI is NOT an error: the lockfile is an optimisation
|
|
130
|
+
# on this path, and the floor below is the thing that actually fails closed.
|
|
131
|
+
if [ -z "${abi}" ] || [ "${abi}" = "${abi_major}" ] ||
|
|
132
|
+
! _semgrep_is_uint "${abi_major}" || ! _semgrep_is_uint "${abi_minor}"; then
|
|
133
|
+
printf '%s' "${default_order}"
|
|
134
|
+
return 0
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
printf '%s' "python${abi_major}.${abi_minor} ${default_order}"
|
|
138
|
+
}
|
|
139
|
+
|
|
69
140
|
select_semgrep_python() {
|
|
70
141
|
local floor="${SEMGREP_PYTHON_FLOOR:-}"
|
|
71
142
|
local pin="${SEMGREP_PIN:-<unset>}"
|
|
72
|
-
local floor_major floor_minor cmd probe major minor system_python
|
|
143
|
+
local floor_major floor_minor cmd probe major minor system_python candidates
|
|
73
144
|
|
|
74
145
|
SEMGREP_PYTHON=""
|
|
75
146
|
SEMGREP_PYTHON_VERSION=""
|
|
76
147
|
|
|
77
|
-
# A missing or malformed floor must not silently degrade to "anything goes":
|
|
78
|
-
# that is the exact fail-open this file exists to close.
|
|
79
|
-
case "${floor}" in
|
|
80
|
-
[0-9]*.[0-9]*) ;;
|
|
81
|
-
*)
|
|
82
|
-
echo "::error::SEMGREP_PYTHON_FLOOR is unset or malformed ('${floor}') — it must be a major.minor version such as 3.10. Without it this step cannot tell whether the runner's Python is new enough to install ${pin}, and it will not guess."
|
|
83
|
-
return 1
|
|
84
|
-
;;
|
|
85
|
-
esac
|
|
86
148
|
floor_major="${floor%%.*}"
|
|
87
149
|
floor_minor="${floor#*.}"
|
|
88
150
|
floor_minor="${floor_minor%%.*}"
|
|
89
151
|
|
|
152
|
+
# A missing or malformed floor must not silently degrade to "anything goes":
|
|
153
|
+
# that is the exact fail-open this file exists to close. Both fields are
|
|
154
|
+
# validated as WHOLE integers. A shape test like `[0-9]*.[0-9]*` pins only
|
|
155
|
+
# the FIRST character of each field, so a typo such as `3.1O` (letter O)
|
|
156
|
+
# passes it, survives to `[ "${minor}" -ge "1O" ]`, and fails there with
|
|
157
|
+
# bash's "integer expression expected" — after which the loop falls through
|
|
158
|
+
# and the step blames the runner's interpreters for a typo in its own input.
|
|
159
|
+
if [ -z "${floor}" ] || [ "${floor}" = "${floor_major}" ] ||
|
|
160
|
+
! _semgrep_is_uint "${floor_major}" || ! _semgrep_is_uint "${floor_minor}"; then
|
|
161
|
+
_semgrep_fail "SEMGREP_PYTHON_FLOOR is unset or malformed ('${floor}') — it must be a major.minor version such as 3.10, both fields whole numbers. Without it this step cannot tell whether the runner's Python is new enough to install ${pin}, and it will not guess."
|
|
162
|
+
return 1
|
|
163
|
+
fi
|
|
164
|
+
|
|
90
165
|
# Reported in the failure message: the interpreter a consumer would expect to
|
|
91
166
|
# be used, so the error names what they actually have rather than only what
|
|
92
167
|
# is required.
|
|
93
168
|
system_python="absent"
|
|
94
169
|
|
|
95
|
-
|
|
170
|
+
# Probe order — ABI-first on Linux when the lockfile's ABI is known, the
|
|
171
|
+
# historical order everywhere else. See the header's PROBE ORDER section.
|
|
172
|
+
candidates="$(_semgrep_python_candidates)"
|
|
173
|
+
|
|
174
|
+
# shellcheck disable=SC2086 # deliberate word-splitting: the candidate list
|
|
175
|
+
# is this file's own space-separated output, never user input.
|
|
176
|
+
for cmd in ${candidates}; do
|
|
96
177
|
probe="$(_semgrep_python_probe "${cmd}")" || continue
|
|
97
178
|
read -r major minor <<<"${probe}"
|
|
98
179
|
[ -n "${major:-}" ] && [ -n "${minor:-}" ] || continue
|
|
@@ -111,8 +192,8 @@ select_semgrep_python() {
|
|
|
111
192
|
fi
|
|
112
193
|
done
|
|
113
194
|
|
|
114
|
-
|
|
115
|
-
echo "Probed, in order:
|
|
195
|
+
_semgrep_fail "${pin} requires Python >= ${floor}, but no interpreter on this runner's PATH satisfies it (python3 is ${system_python})."
|
|
196
|
+
echo "Probed, in order: ${candidates}."
|
|
116
197
|
echo "Remedy: put a Python >= ${floor} earlier on the runner's PATH than /usr/bin — e.g. 'brew install python@3.12' plus a python3 symlink in a directory the runner's .path lists first — or set 'enable-sast: false' to skip the Semgrep sub-step."
|
|
117
198
|
echo "Semgrep is deliberately NOT downgraded to fit an older interpreter: the newest release supporting Python 3.9 (1.136.0) pins opentelemetry ~=1.25.0, which caps protobuf below 5.0, and every protobuf 4.x is affected by CVE-2026-0994 (CVSS 8.2). This install path is not hash-pinned and its closure is not OSV-scanned, so the downgrade would be silent."
|
|
118
199
|
return 1
|