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.
@@ -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
- # Candidates are probed in order — `python3` first, so a compliant runner
57
- # behaves exactly as it did before this file existed, then the versioned
58
- # names newest-first. Returns non-zero after emitting a `::error::` when
59
- # nothing on PATH qualifies; under the caller's `set -e` that fails the step.
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
- for cmd in python3 python3.13 python3.12 python3.11 python3.10; do
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
- echo "::error::${pin} requires Python >= ${floor}, but no interpreter on this runner's PATH satisfies it (python3 is ${system_python})."
115
- echo "Probed, in order: python3 python3.13 python3.12 python3.11 python3.10."
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