mandrel-platform 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,6 +39,18 @@ const WORKFLOW = ".github/workflows/pr-quality.yml";
39
39
  const UPDATER = "scripts/update-semgrep-rules.mjs";
40
40
 
41
41
  const lockfile = readFileSync(LOCKFILE, "utf8");
42
+ const workflow = readFileSync(WORKFLOW, "utf8");
43
+
44
+ // semgrep's own `requires_python`, recorded per release. Verified on PyPI
45
+ // 2026-09-10: 1.97.0 was `>=3.8`, 1.136.0 `>=3.9`, and 1.137.0 raised it to
46
+ // `>=3.10` — which is why a runner on macOS system Python (3.9.6) could not
47
+ // install the 1.176.1 pin at all (issue #480).
48
+ //
49
+ // This table is what makes the floor in pr-quality.yml checkable without a
50
+ // network call: a bump to a release with no entry here fails loudly, so
51
+ // "look up the new requires_python" becomes a step of the bump rather than
52
+ // something discovered by a consumer's red CI.
53
+ const SEMGREP_PYTHON_FLOORS = new Map([["1.176.1", "3.10"]]);
42
54
 
43
55
  /**
44
56
  * Parse `name==version` requirement lines, ignoring comments and hash
@@ -118,7 +130,6 @@ test("the lockfile, the workflow, and the rules updater pin the same semgrep", (
118
130
  const lockVersion = REQS.get("semgrep");
119
131
  assert.ok(lockVersion, `${LOCKFILE}: semgrep must be pinned`);
120
132
 
121
- const workflow = readFileSync(WORKFLOW, "utf8");
122
133
  const wf = workflow.match(/SEMGREP_PIN='semgrep==([^']+)'/);
123
134
  assert.ok(wf, `${WORKFLOW}: SEMGREP_PIN not found`);
124
135
  assert.equal(wf[1], lockVersion, "workflow SEMGREP_PIN disagrees with the lockfile");
@@ -203,3 +214,130 @@ test("the header warns about the manylinux_2_34 wheel tag", () => {
203
214
  "the header must carry a regeneration command",
204
215
  );
205
216
  });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // 5. The interpreter floor on the non-lockfile install path (Story #482)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ test("pr-quality.yml declares an interpreter floor matching the pinned semgrep", () => {
223
+ const version = REQS.get("semgrep");
224
+ const declared = workflow.match(/SEMGREP_PYTHON_FLOOR='([^']+)'/);
225
+ assert.ok(
226
+ declared,
227
+ `${WORKFLOW}: SEMGREP_PYTHON_FLOOR must be declared beside SEMGREP_PIN — without it the SAST step cannot tell a too-old interpreter from a working one`,
228
+ );
229
+
230
+ const recorded = SEMGREP_PYTHON_FLOORS.get(version);
231
+ assert.ok(
232
+ recorded,
233
+ `no requires_python floor recorded for semgrep ${version} — read it off PyPI and add it to SEMGREP_PYTHON_FLOORS before bumping SEMGREP_PIN`,
234
+ );
235
+ assert.equal(
236
+ declared[1],
237
+ recorded,
238
+ `SEMGREP_PYTHON_FLOOR is ${declared[1]} but semgrep ${version} requires Python >= ${recorded}`,
239
+ );
240
+ });
241
+
242
+ test("the selector rides the same side-checkout as the lockfile", () => {
243
+ // The sparse-checkout is NON-CONE and lists exact paths, so a script the
244
+ // SAST step sources is absent at run time unless it is named here — and a
245
+ // missing `source` target kills the security tier for every consumer at
246
+ // once. Both files must sit in the one list.
247
+ const start = workflow.indexOf("- name: Checkout Semgrep lockfile");
248
+ assert.notEqual(start, -1, `${WORKFLOW}: the Semgrep side-checkout step was renamed or removed`);
249
+ const step = workflow.slice(start, workflow.indexOf("path: _mandrel-platform-semgrep", start));
250
+
251
+ assert.ok(
252
+ step.includes("scripts/semgrep-requirements.txt"),
253
+ "the side-checkout must still carry the lockfile",
254
+ );
255
+ assert.ok(
256
+ step.includes("scripts/select-semgrep-python.sh"),
257
+ "the side-checkout must carry the interpreter selector the SAST step sources",
258
+ );
259
+ assert.ok(
260
+ step.includes("sparse-checkout-cone-mode: false"),
261
+ "non-cone mode is what makes the exact-path list meaningful",
262
+ );
263
+ });
264
+
265
+ test("the SAST step selects an interpreter before it creates the venv", () => {
266
+ // A venv inherits the interpreter that built it, so a floor enforced after
267
+ // `-m venv` cannot fix anything. Order is the whole guarantee.
268
+ const select = workflow.indexOf("select-semgrep-python.sh");
269
+ const venv = workflow.indexOf("-m venv");
270
+ assert.notEqual(select, -1, `${WORKFLOW}: the SAST step must source the interpreter selector`);
271
+ assert.notEqual(venv, -1, `${WORKFLOW}: the SAST step must still create a venv`);
272
+ assert.ok(select < venv, "the selector must be sourced BEFORE the venv is created");
273
+
274
+ assert.ok(
275
+ workflow.includes('"${SEMGREP_PYTHON}" -m venv'),
276
+ "the venv must be built from the selected interpreter, not from bare python3",
277
+ );
278
+ });
279
+
280
+ test("the non-lockfile path installs exactly SEMGREP_PIN, never a resolved older release", () => {
281
+ // Downgrading to fit an old interpreter re-admits CVE-2026-0994: the newest
282
+ // py3.9-compatible semgrep (1.136.0) pins opentelemetry ~=1.25.0, which caps
283
+ // protobuf below 5.0, and every protobuf 4.x is affected. This path is not
284
+ // hash-pinned and its closure is not OSV-scanned, so it would be silent.
285
+ assert.ok(
286
+ workflow.includes('--retries 3 "${SEMGREP_PIN}"'),
287
+ "the fallback must install the exact pin",
288
+ );
289
+
290
+ // Matched as literal substrings rather than a regex alternating the
291
+ // comparison operators: CodeQL reads such a pattern as an attempted HTML-tag
292
+ // filter and raises js/bad-tag-filter at HIGH, which blocks the merge.
293
+ const LOOSE = ["semgrep<", "semgrep>", "semgrep~=", "semgrep!=", 'semgrep=="${'];
294
+ for (const loose of LOOSE) {
295
+ assert.ok(
296
+ !workflow.includes(loose),
297
+ `${WORKFLOW}: '${loose}' would let pip resolve a semgrep other than the pin`,
298
+ );
299
+ }
300
+ });
301
+
302
+ test("the Linux hash-pinned branch keeps its exact cp312 equality", () => {
303
+ // Widening this to a `>=` (proposed in issue #480) would route a cp313
304
+ // interpreter onto the lockfile's cp312-only wheels under
305
+ // `--only-binary :all:`, with no sdist fallback — the fleet-red the fallback
306
+ // exists to avoid. The equality is the guard, not the oversight.
307
+ assert.ok(
308
+ workflow.includes('[ "${pyver}" = "312" ]'),
309
+ `${WORKFLOW}: the cp312 test must stay an equality`,
310
+ );
311
+ assert.ok(
312
+ workflow.includes('The `= "312"` below is an EQUALITY on purpose'),
313
+ "the equality must carry a comment saying why a >= test would be wrong",
314
+ );
315
+ assert.ok(
316
+ workflow.includes("sdist fallback"),
317
+ "that comment must name the missing sdist fallback as the mechanism",
318
+ );
319
+ });
320
+
321
+ test("the floor added no workflow_call input and no new job permission", () => {
322
+ // A consumer-set semgrep pin would re-open the same un-scanned downgrade
323
+ // hole operator-side; `enable-sast: false` is the escape hatch. And a new
324
+ // job-level permission is a COMPILE-TIME break for every caller of this
325
+ // reusable workflow, not a runtime one.
326
+ assert.ok(!workflow.includes("semgrep-pin:"), "no semgrep-pin input — see the Story's non-goals");
327
+ assert.ok(!workflow.includes("python-version:"), "no python-version input — see the Story's non-goals");
328
+
329
+ const start = workflow.indexOf("name: Security (secret scan + SAST)");
330
+ assert.notEqual(start, -1, `${WORKFLOW}: the security job was renamed`);
331
+ const header = workflow.slice(start, workflow.indexOf("steps:", start));
332
+ const granted = header
333
+ .split("\n")
334
+ .map((l) => l.trim())
335
+ .filter((l) => l === "contents: read" || l === "actions: write");
336
+ assert.equal(
337
+ granted.length,
338
+ 2,
339
+ "the security job's permissions must remain exactly contents: read + actions: write",
340
+ );
341
+ assert.ok(!header.includes("id-token:"), "no new permission was needed for an interpreter floor");
342
+ assert.ok(!header.includes("packages:"), "no new permission was needed for an interpreter floor");
343
+ });
@@ -133,7 +133,7 @@ export const KEY_SCHEMA = Object.freeze({
133
133
  kind: "'var' | 'secret'",
134
134
  sensitivity: "'public' | 'secret'",
135
135
  residency:
136
- "object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
136
+ "object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers: (string | {worker, environments})[], kind}|null}",
137
137
  infisical:
138
138
  "{folder, environments} | {folders: (string | {folder, environments})[]} | 'unmanaged'",
139
139
  shape: `string? — one of ${SHAPE_NAMES.join(", ")}`,
@@ -456,6 +456,103 @@ function normalizeInfisicalResidency(raw, { at, name, environments }) {
456
456
  return { folders };
457
457
  }
458
458
 
459
+ /**
460
+ * Normalize `residency.cloudflare` to its canonical
461
+ * `{workers: [{worker, environments}], kind}` form.
462
+ *
463
+ * `workers` accepts a bare worker id — the shape every manifest written before
464
+ * Story #483 uses — or a `{worker, environments}` object, because a key can be
465
+ * deliberately resident on one Worker in one environment only: a peer-database
466
+ * credential scoped that tightly to bound its blast radius, or a recipient
467
+ * allowlist that exists only where non-production sending is gated. With no
468
+ * per-entry `environments`, `probeCloudflare` reconciled ONE expected-name list
469
+ * against EVERY environment, so a deliberate single-environment placement had
470
+ * to report `missing` from the others — ten findings on one consumer's correct
471
+ * manifest, every one of them false (Story #481).
472
+ *
473
+ * A bare entry keeps meaning "every environment". That is the load-bearing
474
+ * constraint rather than a convenience: every manifest in existence declares
475
+ * `workers` as a bare string array, so any other reading would break them all.
476
+ *
477
+ * Both authored shapes normalize to one array of `{worker, environments}` with
478
+ * `environments` defaulted and materialized to `manifest.environments`, so
479
+ * `probeCloudflare` has exactly one shape to read — the same
480
+ * normalize-at-parse treatment `residency.github` received in Story #459 and
481
+ * `infisical` in Story #464. Cloudflare is the surface that never got it, and
482
+ * matching them matters more than the field shape itself: three expressive
483
+ * residencies with one idiom, not three.
484
+ *
485
+ * One deliberate divergence from those two: an **empty** `environments` array
486
+ * is rejected rather than read as "resident nowhere". That state is
487
+ * indistinguishable from omitting the residency altogether, and silently
488
+ * accepting it is precisely how a manifest author comes to believe they have
489
+ * scoped something they have not — the same fail-closed posture this module
490
+ * takes on an unknown `shape`.
491
+ *
492
+ * @param {unknown} raw
493
+ * @param {{at: string, name: string, workers: Record<string, object>, environments: string[]}} ctx
494
+ * @returns {{workers: Array<{worker: string, environments: string[]}>, kind: string} | null}
495
+ */
496
+ function normalizeCloudflareResidency(raw, { at, name, workers, environments }) {
497
+ if (raw === undefined || raw === null) return null;
498
+ if (typeof raw !== "object" || Array.isArray(raw)) {
499
+ throw new Error(`${at}.residency.cloudflare must be an object with {workers, kind} (key ${name})`);
500
+ }
501
+ if (!Array.isArray(raw.workers) || raw.workers.length === 0) {
502
+ throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
503
+ }
504
+ if (raw.kind !== "secret" && raw.kind !== "var") {
505
+ throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
506
+ }
507
+
508
+ const seenWorkers = new Set();
509
+ const normalized = raw.workers.map((entry, j) => {
510
+ const where = `${at}.residency.cloudflare.workers[${j}]`;
511
+ let worker;
512
+ let authoredEnvs;
513
+ if (typeof entry === "string") {
514
+ worker = entry;
515
+ } else if (entry && typeof entry === "object" && !Array.isArray(entry)) {
516
+ worker = entry.worker;
517
+ authoredEnvs = entry.environments;
518
+ } else {
519
+ throw new Error(`${where} must be a worker id string or {worker, environments} (key ${name})`);
520
+ }
521
+
522
+ if (typeof worker !== "string" || !Object.hasOwn(workers, worker)) {
523
+ throw new Error(
524
+ `${at}.residency.cloudflare.workers references unknown worker id ${JSON.stringify(worker)} (key ${name})`
525
+ );
526
+ }
527
+ if (seenWorkers.has(worker)) {
528
+ throw new Error(
529
+ `${at}.residency.cloudflare repeats the worker "${worker}" — declare one entry per worker (key ${name})`
530
+ );
531
+ }
532
+ seenWorkers.add(worker);
533
+
534
+ if (authoredEnvs !== undefined) {
535
+ if (!Array.isArray(authoredEnvs) || !authoredEnvs.every((e) => typeof e === "string")) {
536
+ throw new Error(`${where}.environments must be an array of environment slugs (key ${name})`);
537
+ }
538
+ if (authoredEnvs.length === 0) {
539
+ throw new Error(
540
+ `${where}.environments must not be empty — omit it to mean every environment, or drop the entry (key ${name})`
541
+ );
542
+ }
543
+ for (const e of authoredEnvs) {
544
+ if (!environments.includes(e)) {
545
+ throw new Error(`${where}.environments names "${e}", absent from manifest.environments (key ${name})`);
546
+ }
547
+ }
548
+ }
549
+
550
+ return { worker, environments: authoredEnvs ? [...authoredEnvs] : [...environments] };
551
+ });
552
+
553
+ return { workers: normalized, kind: raw.kind };
554
+ }
555
+
459
556
  /**
460
557
  * @param {unknown} entry
461
558
  * @param {number} index
@@ -494,20 +591,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
494
591
 
495
592
  const github = normalizeGitHubResidency(residency.github, { at, name, environments });
496
593
 
497
- const cloudflare = residency.cloudflare ?? null;
498
- if (cloudflare !== null) {
499
- if (!Array.isArray(cloudflare.workers) || cloudflare.workers.length === 0) {
500
- throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
501
- }
502
- for (const id of cloudflare.workers) {
503
- if (!Object.hasOwn(workers, id)) {
504
- throw new Error(`${at}.residency.cloudflare.workers references unknown worker id "${id}" (key ${name})`);
505
- }
506
- }
507
- if (cloudflare.kind !== "secret" && cloudflare.kind !== "var") {
508
- throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
509
- }
510
- }
594
+ const cloudflare = normalizeCloudflareResidency(residency.cloudflare, { at, name, workers, environments });
511
595
 
512
596
  const infisical = normalizeInfisicalResidency(entry.infisical, { at, name, environments });
513
597
 
@@ -530,7 +614,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
530
614
  residency: {
531
615
  local,
532
616
  github,
533
- cloudflare: cloudflare ? { workers: [...cloudflare.workers], kind: cloudflare.kind } : null,
617
+ cloudflare,
534
618
  },
535
619
  infisical,
536
620
  shape: entry.shape ?? null,
@@ -980,7 +1064,13 @@ export function runOfflineChecks({ manifest, repoRoot }) {
980
1064
  checked.push(`wrangler:${id}`);
981
1065
  const present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
982
1066
  const expected = manifest.keys.filter(
983
- (k) => k.residency.cloudflare?.kind === "var" && k.residency.cloudflare.workers.includes(id)
1067
+ // Environment-agnostic by design: this check reports `environment: null`
1068
+ // and `parseWranglerVars` flattens `[env.X.vars]` into one set, so there
1069
+ // is no environment axis to narrow against. A var declared for ANY
1070
+ // environment stays expected in that worker's config.
1071
+ (k) =>
1072
+ k.residency.cloudflare?.kind === "var" &&
1073
+ k.residency.cloudflare.workers.some((w) => w.worker === id)
984
1074
  );
985
1075
  for (const key of expected) {
986
1076
  if (!present.has(key.name)) {
@@ -1417,10 +1507,37 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
1417
1507
  }
1418
1508
 
1419
1509
  /**
1510
+ * Reconcile the Cloudflare surface per `(worker, environment)` pair.
1511
+ *
1512
+ * `expected` is narrowed to the keys whose residency names BOTH this worker
1513
+ * and this environment, so a deliberate single-environment placement no longer
1514
+ * reports `missing` from the environments it never claimed (Story #481).
1515
+ *
1516
+ * Two consequences of that narrowing are load-bearing, and neither is
1517
+ * incidental:
1518
+ *
1519
+ * 1. **A worker is still probed in an environment it expects nothing in**,
1520
+ * as long as it expects something SOMEWHERE. Skipping it would take the
1521
+ * surface's most interesting finding with it: a production-only key
1522
+ * turning up in staging is undeclared presence, and only an
1523
+ * empty-`expected` reconcile against a non-empty `present` reports it.
1524
+ * Cross-environment orphans go unsuppressed here exactly as they do on
1525
+ * the GitHub and Infisical surfaces. A worker that declares nothing in
1526
+ * any environment is still skipped entirely — that is the manifest
1527
+ * saying it has no opinion, which is not the same statement.
1528
+ * 2. **A 404 is only a finding where something WAS expected.** A worker
1529
+ * deployed to one environment by design 404s in the other, and with
1530
+ * nothing declared there that agrees with the manifest rather than
1531
+ * contradicting it. Reporting it would re-introduce, one layer down, the
1532
+ * same false failure this narrowing removes.
1533
+ *
1420
1534
  * @param {object} ctx
1421
1535
  */
1422
1536
  async function probeCloudflare({ manifest, environments, cloudflare, surfaces, findings, unavailability = {} }) {
1423
1537
  const cfKeys = manifest.keys.filter((k) => k.residency.cloudflare?.kind === "secret");
1538
+ /** Does any key declare this worker in any environment at all? */
1539
+ const declaresWorker = (id) =>
1540
+ cfKeys.some((k) => k.residency.cloudflare.workers.some((w) => w.worker === id));
1424
1541
  if (!cloudflare) {
1425
1542
  surfaces.push({
1426
1543
  surface: "cloudflare",
@@ -1432,8 +1549,12 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
1432
1549
  try {
1433
1550
  for (const environment of environments) {
1434
1551
  for (const [id, worker] of Object.entries(manifest.workers)) {
1435
- const expected = cfKeys.filter((k) => k.residency.cloudflare.workers.includes(id)).map((k) => k.name);
1436
- if (expected.length === 0) continue;
1552
+ const expected = cfKeys
1553
+ .filter((k) =>
1554
+ k.residency.cloudflare.workers.some((w) => w.worker === id && w.environments.includes(environment))
1555
+ )
1556
+ .map((k) => k.name);
1557
+ if (!declaresWorker(id)) continue;
1437
1558
  const scriptName = resolveScriptName(worker.scriptName, environment);
1438
1559
  let present;
1439
1560
  try {
@@ -1441,15 +1562,20 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
1441
1562
  } catch (err) {
1442
1563
  if (!isAbsentStatus(err)) throw err;
1443
1564
  // A 404 is the one status that legitimately means "absent": the
1444
- // Worker has not been deployed to this environment yet.
1445
- findings.push({
1446
- severity: "fail",
1447
- kind: "missing",
1448
- key: null,
1449
- surface: "cloudflare",
1450
- environment,
1451
- detail: `Worker script "${scriptName}" does not exist (404) — ${expected.length} declared secret(s) cannot be verified`,
1452
- });
1565
+ // Worker has not been deployed to this environment yet. That is only
1566
+ // drift where the manifest expected something here; a worker
1567
+ // deliberately absent from an environment it declares nothing in is
1568
+ // agreement, not a finding.
1569
+ if (expected.length > 0) {
1570
+ findings.push({
1571
+ severity: "fail",
1572
+ kind: "missing",
1573
+ key: null,
1574
+ surface: "cloudflare",
1575
+ environment,
1576
+ detail: `Worker script "${scriptName}" does not exist (404) — ${expected.length} declared secret(s) cannot be verified`,
1577
+ });
1578
+ }
1453
1579
  continue;
1454
1580
  }
1455
1581
  findings.push(
@@ -1339,6 +1339,236 @@ test("a multi-folder key with a shape earns ONE verdict per environment, not one
1339
1339
  }
1340
1340
  });
1341
1341
 
1342
+ // ---------------------------------------------------------------------------
1343
+ // Cloudflare Worker residency — per-environment presence (Story #483)
1344
+ // ---------------------------------------------------------------------------
1345
+
1346
+ /**
1347
+ * A manifest whose workers carry no `config` and whose keys have no local,
1348
+ * GitHub or Infisical residency, so the offline arm over an empty repo root
1349
+ * contributes nothing and every finding under test comes from the Cloudflare
1350
+ * probe.
1351
+ */
1352
+ function cloudflareOnlyManifest({ workers, keys, environments = ["staging", "production"] }) {
1353
+ return parseManifest({
1354
+ environments,
1355
+ workers: Object.fromEntries(workers.map((id) => [id, { scriptName: `swarm-${id}-{env}` }])),
1356
+ keys: keys.map((k) => ({
1357
+ kind: "secret",
1358
+ sensitivity: "secret",
1359
+ residency: { local: null, github: null, cloudflare: { workers: k.workers, kind: "secret" } },
1360
+ infisical: "unmanaged",
1361
+ ...k,
1362
+ workers: undefined,
1363
+ })),
1364
+ });
1365
+ }
1366
+
1367
+ /** Mock `secretNames` from a `{"<worker>-<env>": [names]}` map. */
1368
+ function cloudflareProbe(present) {
1369
+ return {
1370
+ secretNames: async (scriptName) => {
1371
+ const key = scriptName.replace(/^swarm-/, "");
1372
+ return present[key] ?? [];
1373
+ },
1374
+ };
1375
+ }
1376
+
1377
+ async function cloudflareFindings({ manifest, present, environments = ["staging", "production"] }) {
1378
+ const root = makeRepo({});
1379
+ try {
1380
+ const report = await runDoctor({
1381
+ manifest,
1382
+ repoRoot: root,
1383
+ environments,
1384
+ cloudflare: cloudflareProbe(present),
1385
+ });
1386
+ return report.findings.filter((f) => f.surface === "cloudflare");
1387
+ } finally {
1388
+ rmSync(root, { recursive: true, force: true });
1389
+ }
1390
+ }
1391
+
1392
+ test("a bare worker id still means every environment — every manifest in existence says it that way", () => {
1393
+ const manifest = cloudflareOnlyManifest({
1394
+ workers: ["api"],
1395
+ keys: [{ name: "SHARED_TOKEN", workers: ["api"] }],
1396
+ });
1397
+ assert.deepEqual(manifest.keys[0].residency.cloudflare.workers, [
1398
+ { worker: "api", environments: ["staging", "production"] },
1399
+ ]);
1400
+ });
1401
+
1402
+ test("the object form narrows one entry while a bare sibling keeps defaulting to every environment", () => {
1403
+ const manifest = cloudflareOnlyManifest({
1404
+ workers: ["staff", "api"],
1405
+ keys: [{ name: "SHARED_TOKEN", workers: [{ worker: "staff", environments: ["production"] }, "api"] }],
1406
+ });
1407
+ assert.deepEqual(manifest.keys[0].residency.cloudflare.workers, [
1408
+ { worker: "staff", environments: ["production"] },
1409
+ { worker: "api", environments: ["staging", "production"] },
1410
+ ]);
1411
+ });
1412
+
1413
+ test("a production-only key present only in production reports NOTHING — the defect #481 filed", async () => {
1414
+ const findings = await cloudflareFindings({
1415
+ manifest: cloudflareOnlyManifest({
1416
+ workers: ["staff"],
1417
+ keys: [{ name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: ["production"] }] }],
1418
+ }),
1419
+ present: { "staff-production": ["PEER_DATABASE_URL"] },
1420
+ });
1421
+ assert.deepEqual(findings, []);
1422
+ });
1423
+
1424
+ test("cloudflare suppression does not cross environment — a production-only key in staging orphans", async () => {
1425
+ const findings = await cloudflareFindings({
1426
+ manifest: cloudflareOnlyManifest({
1427
+ workers: ["staff"],
1428
+ keys: [{ name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: ["production"] }] }],
1429
+ }),
1430
+ present: { "staff-production": ["PEER_DATABASE_URL"], "staff-staging": ["PEER_DATABASE_URL"] },
1431
+ });
1432
+ assert.equal(findings.length, 1);
1433
+ assert.equal(findings[0].kind, "orphan");
1434
+ assert.equal(findings[0].key, "PEER_DATABASE_URL");
1435
+ assert.equal(findings[0].environment, "staging");
1436
+ });
1437
+
1438
+ test("a narrowed entry still reports a REAL absence in the environment it does name", async () => {
1439
+ const findings = await cloudflareFindings({
1440
+ manifest: cloudflareOnlyManifest({
1441
+ workers: ["staff"],
1442
+ keys: [{ name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: ["production"] }] }],
1443
+ }),
1444
+ present: {},
1445
+ });
1446
+ assert.equal(findings.length, 1);
1447
+ assert.equal(findings[0].kind, "missing");
1448
+ assert.equal(findings[0].environment, "production");
1449
+ });
1450
+
1451
+ test("cloudflare worker residency fails closed on every malformed shape", () => {
1452
+ const bad = (workers) => () =>
1453
+ cloudflareOnlyManifest({ workers: ["api", "staff"], keys: [{ name: "SHARED_TOKEN", workers }] });
1454
+
1455
+ assert.throws(bad([]), /must be a non-empty array of worker ids/);
1456
+ assert.throws(bad([42]), /must be a worker id string or \{worker, environments\}/);
1457
+ assert.throws(bad(["nope"]), /references unknown worker id "nope"/);
1458
+ assert.throws(bad([{ worker: "nope", environments: ["staging"] }]), /references unknown worker id "nope"/);
1459
+ assert.throws(bad(["api", "api"]), /repeats the worker "api"/);
1460
+ assert.throws(bad(["api", { worker: "api", environments: ["staging"] }]), /repeats the worker "api"/);
1461
+ assert.throws(bad([{ worker: "api", environments: ["preview"] }]), /absent from manifest.environments/);
1462
+ assert.throws(bad([{ worker: "api", environments: [] }]), /must not be empty/);
1463
+ assert.throws(bad([{ worker: "api", environments: "staging" }]), /must be an array of environment slugs/);
1464
+ });
1465
+
1466
+ test("a worker deployed to one environment by design does not 404-fail in the other", async () => {
1467
+ // The narrowing's second consequence: probing a worker in an environment it
1468
+ // expects nothing in must not turn that worker's deliberate absence into a
1469
+ // finding, or the false failure comes back one layer down.
1470
+ const root = makeRepo({});
1471
+ try {
1472
+ const report = await runDoctor({
1473
+ manifest: cloudflareOnlyManifest({
1474
+ workers: ["staff"],
1475
+ keys: [{ name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: ["production"] }] }],
1476
+ }),
1477
+ repoRoot: root,
1478
+ environments: ["staging", "production"],
1479
+ cloudflare: {
1480
+ secretNames: async (scriptName) => {
1481
+ if (scriptName === "swarm-staff-staging") {
1482
+ const err = new Error("not found");
1483
+ err.httpStatus = 404;
1484
+ throw err;
1485
+ }
1486
+ return ["PEER_DATABASE_URL"];
1487
+ },
1488
+ },
1489
+ });
1490
+ assert.deepEqual(
1491
+ report.findings.filter((f) => f.surface === "cloudflare"),
1492
+ []
1493
+ );
1494
+ assert.equal(report.surfaces.find((s) => s.surface === "cloudflare").status, "checked");
1495
+ } finally {
1496
+ rmSync(root, { recursive: true, force: true });
1497
+ }
1498
+ });
1499
+
1500
+ test("the six single-environment keys from #481 report zero failures on a correct manifest", async () => {
1501
+ // The consumer evidence that filed the gap, reconstructed: ten false
1502
+ // `missing` findings across six keys whose single-environment placement is
1503
+ // deliberate. Every one of them must now be silent.
1504
+ const production = ["production"];
1505
+ const staging = ["staging"];
1506
+ const manifest = cloudflareOnlyManifest({
1507
+ workers: ["staff", "api", "web"],
1508
+ keys: [
1509
+ { name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: production }] },
1510
+ { name: "PEER_TURSO_AUTH_TOKEN", workers: [{ worker: "staff", environments: production }] },
1511
+ { name: "SENTRY_WEBHOOK_SIGNING_SECRET", workers: [{ worker: "api", environments: production }] },
1512
+ { name: "GITHUB_INTAKE_TOKEN", workers: [{ worker: "api", environments: production }] },
1513
+ {
1514
+ name: "EMAIL_RECIPIENT_ALLOWLIST",
1515
+ workers: ["api", "web", "staff"].map((worker) => ({ worker, environments: staging })),
1516
+ },
1517
+ {
1518
+ name: "SMS_RECIPIENT_ALLOWLIST",
1519
+ workers: ["api", "web", "staff"].map((worker) => ({ worker, environments: staging })),
1520
+ },
1521
+ ],
1522
+ });
1523
+ const findings = await cloudflareFindings({
1524
+ manifest,
1525
+ present: {
1526
+ "staff-production": ["PEER_DATABASE_URL", "PEER_TURSO_AUTH_TOKEN"],
1527
+ "api-production": ["SENTRY_WEBHOOK_SIGNING_SECRET", "GITHUB_INTAKE_TOKEN"],
1528
+ "api-staging": ["EMAIL_RECIPIENT_ALLOWLIST", "SMS_RECIPIENT_ALLOWLIST"],
1529
+ "web-staging": ["EMAIL_RECIPIENT_ALLOWLIST", "SMS_RECIPIENT_ALLOWLIST"],
1530
+ "staff-staging": ["EMAIL_RECIPIENT_ALLOWLIST", "SMS_RECIPIENT_ALLOWLIST"],
1531
+ },
1532
+ });
1533
+ assert.deepEqual(findings, []);
1534
+ });
1535
+
1536
+ test("KEY_SCHEMA names the per-environment worker entry so the script and the docs cannot drift", () => {
1537
+ assert.match(KEY_SCHEMA.residency, /worker, environments/);
1538
+ });
1539
+
1540
+ test("a var-residency key is unaffected by the environment axis at the wrangler [vars] check", () => {
1541
+ // The wrangler check has no environment axis — it reports `environment: null`
1542
+ // and `parseWranglerVars` flattens `[env.X.vars]` into one set — so a var
1543
+ // declared for ONE environment stays expected in that worker's config.
1544
+ const root = makeRepo({ wrangler: '[env.staging.vars]\nSTAGING_ONLY_FLAG = "1"\n' });
1545
+ try {
1546
+ const manifest = parseManifest({
1547
+ environments: ["staging", "production"],
1548
+ workers: { web: { config: "wrangler.toml", scriptName: "swarm-web-{env}" } },
1549
+ keys: [
1550
+ {
1551
+ name: "STAGING_ONLY_FLAG",
1552
+ kind: "var",
1553
+ sensitivity: "public",
1554
+ residency: {
1555
+ local: null,
1556
+ github: null,
1557
+ cloudflare: { workers: [{ worker: "web", environments: ["staging"] }], kind: "var" },
1558
+ },
1559
+ infisical: "unmanaged",
1560
+ },
1561
+ ],
1562
+ });
1563
+ const findings = runOfflineChecks({ manifest, repoRoot: root }).findings.filter(
1564
+ (f) => f.surface === "wrangler"
1565
+ );
1566
+ assert.deepEqual(findings, []);
1567
+ } finally {
1568
+ rmSync(root, { recursive: true, force: true });
1569
+ }
1570
+ });
1571
+
1342
1572
  test("MANIFEST_SCHEMA and KEY_SCHEMA describe the slug container and the folders array", () => {
1343
1573
  assert.ok(Object.hasOwn(MANIFEST_SCHEMA, "environmentSlugs"));
1344
1574
  assert.match(MANIFEST_SCHEMA.environmentSlugs, /infisical/);
@@ -1354,6 +1584,19 @@ test("the documented manifest schema block names the new shapes", () => {
1354
1584
  assert.match(doc, /"folders"/);
1355
1585
  });
1356
1586
 
1587
+ test("the docs carry a Cloudflare per-environment residency section beside its two siblings", () => {
1588
+ // Same bargain as the test above, for the third surface to take the
1589
+ // treatment: the doc must describe the entry form the script now accepts.
1590
+ const doc = readFileSync(join(HERE, "..", "docs", "reusable-workflows.md"), "utf8");
1591
+ // `includes` + a message, not `assert.match`: a regex miss here dumps the
1592
+ // whole 250KB document into the failure output and buries the reason.
1593
+ assert.ok(
1594
+ doc.includes("#### Cloudflare Worker residency: per-environment presence"),
1595
+ "docs/reusable-workflows.md must carry the Cloudflare per-environment residency section"
1596
+ );
1597
+ assert.ok(doc.includes('"worker": "staff"'), "the manifest-schema block must show the object entry form");
1598
+ });
1599
+
1357
1600
  test("no secret VALUE reaches stdout or stderr through the remapped-slug, multi-folder path", async () => {
1358
1601
  // The values-safety guarantee, re-asserted over the shapes #464 adds: a
1359
1602
  // remapped environment slug and a key resident in two folders. Same
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env bash
2
+ # select-semgrep-python.sh — choose the interpreter the pr-quality SAST step
3
+ # builds its Semgrep venv from, and refuse to run on one that is too old
4
+ # (Story #482).
5
+ #
6
+ # WHY THIS EXISTS
7
+ # ---------------
8
+ # The SAST step installs Semgrep two ways: a hash-pinned closure from
9
+ # `scripts/semgrep-requirements.txt` on Linux/cp312, and the bare top pin
10
+ # everywhere else. That second branch is deliberate — it is where a Python
11
+ # roll-FORWARD degrades to, so a CI image moving to 3.13 goes
12
+ # unpinned-but-green rather than fleet-red on ABI-incompatible cp312 wheels.
13
+ #
14
+ # But it carried no floor of its own, so an interpreter that is too OLD
15
+ # hard-failed instead. semgrep raised `requires_python` to `>=3.10` at 1.137.0,
16
+ # and macOS ships `/usr/bin/python3` = 3.9.6, so every consumer on a macOS
17
+ # self-hosted runner with system Python went red on `ci-required` with nothing
18
+ # but pip's resolver error:
19
+ #
20
+ # ERROR: Could not find a version that satisfies the requirement
21
+ # semgrep==1.176.1 (from versions: ..., 1.135.0, 1.136.0)
22
+ #
23
+ # which reads like a network or registry problem and never names the
24
+ # interpreter as the cause (issue #480, observed in Beestera/swarm-os#2496).
25
+ #
26
+ # WHY IT DOES NOT JUST INSTALL AN OLDER SEMGREP
27
+ # ---------------------------------------------
28
+ # Resolving "the newest semgrep this interpreter supports" is the obvious fix
29
+ # and it is a security regression. The newest release supporting Python 3.9 is
30
+ # 1.136.0, which hard-pins `opentelemetry-*~=1.25.0`; `opentelemetry-proto` at
31
+ # that version requires `protobuf<5.0`, and EVERY protobuf 4.x is affected by
32
+ # CVE-2026-0994 (CVSS 8.2) — the advisory cleared by the 1.176.1 bump
33
+ # (#477/#472). The same downgrade drags back `opentelemetry-instrumentation`
34
+ # 0.46b0, whose `pkg_resources` import is why `setuptools` used to be in the
35
+ # closure at all. And because THIS install path is deliberately not
36
+ # hash-pinned and its closure is not OSV-scanned, the downgrade would be
37
+ # silent. So the floor fails closed: a usable interpreter or a named error,
38
+ # never a quieter, older Semgrep.
39
+ #
40
+ # CONTRACT
41
+ # --------
42
+ # SOURCE this file (do NOT exec it) from a `shell: bash` step, BEFORE the venv
43
+ # is created — the venv inherits whichever interpreter builds it, so a check
44
+ # made afterwards is already too late.
45
+ #
46
+ # Inputs (read from the caller's shell / the step `env:` block):
47
+ # SEMGREP_PIN = the exact pip requirement the step installs,
48
+ # e.g. `semgrep==1.176.1` (diagnostics only)
49
+ # SEMGREP_PYTHON_FLOOR = minimum `major.minor`, e.g. `3.10` — semgrep's
50
+ # own `requires_python` for the pinned version
51
+ #
52
+ # Outputs (set on the caller's shell):
53
+ # SEMGREP_PYTHON = the interpreter to build the venv with
54
+ # SEMGREP_PYTHON_VERSION = its `major.minor`
55
+ #
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.
60
+
61
+ _semgrep_python_probe() {
62
+ # Echo "<major> <minor>" for the interpreter named by $1, or return non-zero
63
+ # when it is absent from PATH or not a runnable interpreter.
64
+ local cmd="$1"
65
+ command -v "${cmd}" >/dev/null 2>&1 || return 1
66
+ "${cmd}" -c 'import sys; print("%d %d" % sys.version_info[:2])' 2>/dev/null
67
+ }
68
+
69
+ select_semgrep_python() {
70
+ local floor="${SEMGREP_PYTHON_FLOOR:-}"
71
+ local pin="${SEMGREP_PIN:-<unset>}"
72
+ local floor_major floor_minor cmd probe major minor system_python
73
+
74
+ SEMGREP_PYTHON=""
75
+ SEMGREP_PYTHON_VERSION=""
76
+
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
+ floor_major="${floor%%.*}"
87
+ floor_minor="${floor#*.}"
88
+ floor_minor="${floor_minor%%.*}"
89
+
90
+ # Reported in the failure message: the interpreter a consumer would expect to
91
+ # be used, so the error names what they actually have rather than only what
92
+ # is required.
93
+ system_python="absent"
94
+
95
+ for cmd in python3 python3.13 python3.12 python3.11 python3.10; do
96
+ probe="$(_semgrep_python_probe "${cmd}")" || continue
97
+ read -r major minor <<<"${probe}"
98
+ [ -n "${major:-}" ] && [ -n "${minor:-}" ] || continue
99
+ if [ "${cmd}" = "python3" ]; then
100
+ system_python="${major}.${minor}"
101
+ fi
102
+ # Compare major and minor as SEPARATE integers. A concatenated "${major}${minor}"
103
+ # compares wrong across the tens boundary — "39" sorts above "310" as a
104
+ # string, and as an integer 39 is below 310 only by accident of digit count.
105
+ if [ "${major}" -gt "${floor_major}" ] ||
106
+ { [ "${major}" -eq "${floor_major}" ] && [ "${minor}" -ge "${floor_minor}" ]; }; then
107
+ SEMGREP_PYTHON="${cmd}"
108
+ SEMGREP_PYTHON_VERSION="${major}.${minor}"
109
+ echo "Semgrep interpreter: ${cmd} (Python ${SEMGREP_PYTHON_VERSION}); floor ${floor} for ${pin}."
110
+ return 0
111
+ fi
112
+ done
113
+
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."
116
+ 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
+ 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
+ return 1
119
+ }
120
+
121
+ # When EXECUTED directly (not sourced) — e.g. by the unit test — echo the
122
+ # selection as `KEY=value` lines and exit with the selection's own status, so
123
+ # both the happy path and the fail-closed path are assertable without a
124
+ # GitHub runner. `BASH_SOURCE[0] == $0` iff the file was run, not sourced.
125
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
126
+ if select_semgrep_python; then
127
+ printf 'SEMGREP_PYTHON=%s\n' "${SEMGREP_PYTHON}"
128
+ printf 'SEMGREP_PYTHON_VERSION=%s\n' "${SEMGREP_PYTHON_VERSION}"
129
+ exit 0
130
+ fi
131
+ exit 1
132
+ fi
133
+
134
+ select_semgrep_python
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * select-semgrep-python.test.mjs — node:test suite for the SAST interpreter
4
+ * floor (Story #482).
5
+ *
6
+ * The pr-quality SAST step `source`s `select-semgrep-python.sh` before it
7
+ * creates the Semgrep venv, so this script's decision IS which interpreter
8
+ * the fleet's blocking SAST tier installs against. The suite executes the
9
+ * script directly (it echoes `SEMGREP_PYTHON=…` lines and exits with the
10
+ * selection's status when run rather than sourced) against fixture PATHs
11
+ * built from stub interpreters, so every branch is assertable without a
12
+ * macOS runner or a real Python matrix.
13
+ *
14
+ * Why stubs rather than the host's real interpreters: the failure this closes
15
+ * only reproduces on a runner whose `python3` is BELOW the floor, which no CI
16
+ * tier here provides. A stub that answers `-c` with a chosen `major minor` is
17
+ * the whole interface the script depends on.
18
+ *
19
+ * Run: node --test scripts/select-semgrep-python.test.mjs
20
+ */
21
+
22
+ import assert from "node:assert/strict";
23
+ import { execFileSync } from "node:child_process";
24
+ import { mkdtempSync, statSync, writeFileSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { test } from "node:test";
29
+
30
+ const HERE = dirname(fileURLToPath(import.meta.url));
31
+ const SCRIPT = join(HERE, "select-semgrep-python.sh");
32
+
33
+ const PIN = "semgrep==1.176.1";
34
+ const FLOOR = "3.10";
35
+
36
+ /**
37
+ * Build a directory of stub interpreters and return its path. `versions` maps
38
+ * an interpreter name to the `major.minor` it should report; a stub ignores
39
+ * its arguments and echoes `"<major> <minor>"`, which is the only thing the
40
+ * script asks of it.
41
+ */
42
+ function stubPath(versions) {
43
+ const dir = mkdtempSync(join(tmpdir(), "semgrep-python-stubs-"));
44
+ for (const [name, version] of Object.entries(versions)) {
45
+ const [major, minor] = version.split(".");
46
+ writeFileSync(join(dir, name), `#!/bin/sh\necho "${major} ${minor}"\n`, { mode: 0o755 });
47
+ }
48
+ return dir;
49
+ }
50
+
51
+ /**
52
+ * Execute the selector with the given PATH and env. Returns the exit status
53
+ * and stdout — the script writes its `::error::` annotation to stdout, which
54
+ * is where GitHub reads workflow commands from.
55
+ */
56
+ function select({ path, floor = FLOOR, pin = PIN }) {
57
+ const env = { PATH: path };
58
+ if (floor !== null) env.SEMGREP_PYTHON_FLOOR = floor;
59
+ if (pin !== null) env.SEMGREP_PIN = pin;
60
+ try {
61
+ const stdout = execFileSync("/bin/bash", [SCRIPT], { encoding: "utf8", env });
62
+ return { status: 0, stdout };
63
+ } catch (err) {
64
+ return { status: err.status, stdout: err.stdout ?? "" };
65
+ }
66
+ }
67
+
68
+ /** Parse the `KEY=value` lines the script emits when it succeeds. */
69
+ function parse(stdout) {
70
+ const out = {};
71
+ for (const line of stdout.split("\n")) {
72
+ const eq = line.indexOf("=");
73
+ if (eq === -1) continue;
74
+ out[line.slice(0, eq)] = line.slice(eq + 1);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // 1. The script is a usable, sourceable artifact
81
+ // ---------------------------------------------------------------------------
82
+
83
+ test("the selector is executable", () => {
84
+ // The workflow sources it, but the suite executes it, and a non-executable
85
+ // file would pass `source` while failing every assertion below in a way
86
+ // that reads as a logic bug rather than a mode bug.
87
+ const mode = statSync(SCRIPT).mode;
88
+ assert.ok(mode & 0o111, "scripts/select-semgrep-python.sh must be executable");
89
+ });
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // 2. Selection order — a compliant runner is unchanged
93
+ // ---------------------------------------------------------------------------
94
+
95
+ test("a python3 at the floor is selected as-is", () => {
96
+ const r = select({ path: stubPath({ python3: "3.10" }) });
97
+ assert.equal(r.status, 0);
98
+ const out = parse(r.stdout);
99
+ assert.equal(out.SEMGREP_PYTHON, "python3");
100
+ assert.equal(out.SEMGREP_PYTHON_VERSION, "3.10");
101
+ });
102
+
103
+ test("python3 wins even when newer versioned interpreters are also present", () => {
104
+ // A runner that already works must not silently change interpreter — that
105
+ // would be a behaviour change shipped to the whole fleet as a side effect.
106
+ const r = select({ path: stubPath({ python3: "3.12", "python3.13": "3.13" }) });
107
+ assert.equal(r.status, 0);
108
+ assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // 3. Discovery — the reported consumer-side remedy, performed by the workflow
113
+ // ---------------------------------------------------------------------------
114
+
115
+ test("a below-floor python3 falls through to a qualifying python3.N", () => {
116
+ // The exact shape of issue #480: macOS /usr/bin/python3 is 3.9.6 while a
117
+ // brew-installed 3.12 sits on PATH under its versioned name.
118
+ const r = select({ path: stubPath({ python3: "3.9", "python3.12": "3.12" }) });
119
+ assert.equal(r.status, 0);
120
+ const out = parse(r.stdout);
121
+ assert.equal(out.SEMGREP_PYTHON, "python3.12");
122
+ assert.equal(out.SEMGREP_PYTHON_VERSION, "3.12");
123
+ });
124
+
125
+ test("versioned candidates are probed newest-first", () => {
126
+ const r = select({
127
+ path: stubPath({ python3: "3.9", "python3.11": "3.11", "python3.13": "3.13" }),
128
+ });
129
+ assert.equal(r.status, 0);
130
+ assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3.13");
131
+ });
132
+
133
+ test("3.9 is rejected and 3.10 accepted — the floor compares minor numerically", () => {
134
+ // Guards the tens-boundary trap: as strings "39" sorts ABOVE "310", so a
135
+ // concatenated comparison would accept 3.9 and reject the floor itself.
136
+ const rejected = select({ path: stubPath({ python3: "3.9" }) });
137
+ assert.equal(rejected.status, 1);
138
+ const accepted = select({ path: stubPath({ python3: "3.10" }) });
139
+ assert.equal(accepted.status, 0);
140
+ });
141
+
142
+ test("a future major version satisfies the floor", () => {
143
+ const r = select({ path: stubPath({ python3: "4.0" }) });
144
+ assert.equal(r.status, 0);
145
+ assert.equal(parse(r.stdout).SEMGREP_PYTHON_VERSION, "4.0");
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // 4. Fail closed, with an actionable message
150
+ // ---------------------------------------------------------------------------
151
+
152
+ test("no qualifying interpreter fails with an ::error:: naming floor, pin and version found", () => {
153
+ const r = select({ path: stubPath({ python3: "3.9" }) });
154
+ assert.equal(r.status, 1, "the step must fail rather than install something older");
155
+
156
+ const error = r.stdout.split("\n").find((l) => l.startsWith("::error::"));
157
+ assert.ok(error, "the failure must be a GitHub ::error:: annotation, not bare output");
158
+ assert.ok(error.includes(FLOOR), `the annotation must name the floor (${FLOOR}): ${error}`);
159
+ assert.ok(error.includes(PIN), `the annotation must name the pin (${PIN}): ${error}`);
160
+ assert.ok(
161
+ error.includes("3.9"),
162
+ `the annotation must name the interpreter version actually found: ${error}`,
163
+ );
164
+ });
165
+
166
+ test("the failure names the PATH remedy and the enable-sast escape hatch", () => {
167
+ const r = select({ path: stubPath({ python3: "3.9" }) });
168
+ assert.ok(r.stdout.includes("PATH"), "the remedy must name PATH");
169
+ assert.ok(
170
+ r.stdout.includes("enable-sast"),
171
+ "the remedy must name the enable-sast escape hatch, since no semgrep-pin input exists",
172
+ );
173
+ });
174
+
175
+ test("the failure records why semgrep is not downgraded instead", () => {
176
+ // The rationale belongs in the runner output: the next person to hit this
177
+ // reads the log, not the Story, and "just pin an older semgrep" is the
178
+ // wrong fix for a documented reason.
179
+ const r = select({ path: stubPath({ python3: "3.9" }) });
180
+ assert.ok(r.stdout.includes("CVE-2026-0994"), "must name the advisory a downgrade re-admits");
181
+ assert.ok(r.stdout.includes("1.136.0"), "must name the last py3.9-compatible release");
182
+ });
183
+
184
+ test("an absent python3 is reported as absent, not as a version", () => {
185
+ const r = select({ path: stubPath({}) });
186
+ assert.equal(r.status, 1);
187
+ const error = r.stdout.split("\n").find((l) => l.startsWith("::error::"));
188
+ assert.ok(error.includes("absent"), `expected 'absent' in: ${error}`);
189
+ });
190
+
191
+ test("a non-interpreter on PATH under an interpreter name is skipped, not trusted", () => {
192
+ // `command -v` finding the name is not proof it answers `-c`. A stub that
193
+ // exits non-zero must be passed over rather than selected with an empty
194
+ // version.
195
+ const dir = mkdtempSync(join(tmpdir(), "semgrep-python-stubs-"));
196
+ writeFileSync(join(dir, "python3"), "#!/bin/sh\nexit 127\n", { mode: 0o755 });
197
+ writeFileSync(join(dir, "python3.12"), '#!/bin/sh\necho "3 12"\n', { mode: 0o755 });
198
+ const r = select({ path: dir });
199
+ assert.equal(r.status, 0);
200
+ assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3.12");
201
+ });
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // 5. The floor itself cannot go missing quietly
205
+ // ---------------------------------------------------------------------------
206
+
207
+ test("an unset floor fails closed rather than accepting any interpreter", () => {
208
+ const r = select({ path: stubPath({ python3: "3.9" }), floor: null });
209
+ assert.equal(r.status, 1);
210
+ const error = r.stdout.split("\n").find((l) => l.startsWith("::error::"));
211
+ assert.ok(error.includes("SEMGREP_PYTHON_FLOOR"), `expected the floor named in: ${error}`);
212
+ });
213
+
214
+ test("a malformed floor fails closed", () => {
215
+ const r = select({ path: stubPath({ python3: "3.12" }), floor: "latest" });
216
+ assert.equal(r.status, 1, "a malformed floor must not be treated as satisfied");
217
+ });