mandrel-platform 0.21.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/check-pin-drift.mjs +66 -1
- package/scripts/check-pin-drift.test.mjs +125 -0
- package/scripts/check-runner-health.mjs +469 -0
- package/scripts/check-runner-health.test.mjs +389 -0
- package/scripts/deploy-boot-smoke.mjs +116 -36
- package/scripts/deploy-boot-smoke.test.mjs +138 -17
- package/scripts/platform-repair.mjs +38 -2
- package/scripts/platform-repair.test.mjs +113 -0
- package/scripts/platform-sync.test.mjs +36 -9
- package/scripts/runner-fleet-consumers.json +20 -0
- package/scripts/semgrep-requirements.txt +169 -0
- package/templates/runbooks/runner-fleet-health.md +150 -0
- package/templates/workflows/deploy-staging-run.yml +117 -0
- package/templates/workflows/deploy-staging.yml +125 -64
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
probeUrl,
|
|
12
12
|
runSmoke,
|
|
13
13
|
resolveFailedFile,
|
|
14
|
+
defaultDeriveSubdomain,
|
|
15
|
+
writeRollbackState,
|
|
14
16
|
} from "./deploy-boot-smoke.mjs";
|
|
15
17
|
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
@@ -29,26 +31,67 @@ test("parseSmokePaths enforces a leading slash", () => {
|
|
|
29
31
|
});
|
|
30
32
|
|
|
31
33
|
// ---------------------------------------------------------------------------
|
|
32
|
-
// extractSubdomain
|
|
34
|
+
// extractSubdomain — parses the Cloudflare REST subdomain response (M14)
|
|
33
35
|
// ---------------------------------------------------------------------------
|
|
34
36
|
|
|
35
|
-
test("extractSubdomain
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
assert.equal(extractSubdomain(whoami), "dsj1984");
|
|
37
|
+
test("extractSubdomain reads result.name from the REST subdomain response", () => {
|
|
38
|
+
assert.equal(
|
|
39
|
+
extractSubdomain('{"success":true,"errors":[],"messages":[],"result":{"name":"dsj1984"}}'),
|
|
40
|
+
"dsj1984"
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("extractSubdomain strips a trailing .workers.dev when the API echoes the host", () => {
|
|
45
|
+
assert.equal(extractSubdomain('{"success":true,"result":{"name":"dsj1984.workers.dev"}}'), "dsj1984");
|
|
45
46
|
});
|
|
46
47
|
|
|
47
|
-
test("extractSubdomain returns null
|
|
48
|
-
assert.equal(extractSubdomain("
|
|
48
|
+
test("extractSubdomain returns null for unsuccessful, malformed, or missing bodies", () => {
|
|
49
|
+
assert.equal(extractSubdomain('{"success":false,"result":null}'), null);
|
|
50
|
+
assert.equal(extractSubdomain('{"result":{"name":""}}'), null);
|
|
51
|
+
assert.equal(extractSubdomain('{"result":{"name":42}}'), null);
|
|
52
|
+
assert.equal(extractSubdomain('{"result":{}}'), null);
|
|
53
|
+
assert.equal(extractSubdomain('{"result":"dsj1984"}'), null);
|
|
54
|
+
assert.equal(extractSubdomain("<html>not json</html>"), null);
|
|
49
55
|
assert.equal(extractSubdomain(""), null);
|
|
50
56
|
});
|
|
51
57
|
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// defaultDeriveSubdomain — the REST GET /accounts/{id}/workers/subdomain call
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
test("defaultDeriveSubdomain calls the workers/subdomain endpoint with a bearer token", async () => {
|
|
63
|
+
let seenUrl;
|
|
64
|
+
let seenOptions;
|
|
65
|
+
const fetchImpl = async (url, options) => {
|
|
66
|
+
seenUrl = url;
|
|
67
|
+
seenOptions = options;
|
|
68
|
+
return { ok: true, text: async () => '{"success":true,"result":{"name":"dsj1984"}}' };
|
|
69
|
+
};
|
|
70
|
+
const slug = await defaultDeriveSubdomain({ accountId: "acct-123", apiToken: "tok-abc" }, fetchImpl);
|
|
71
|
+
assert.equal(slug, "dsj1984");
|
|
72
|
+
assert.equal(
|
|
73
|
+
seenUrl,
|
|
74
|
+
"https://api.cloudflare.com/client/v4/accounts/acct-123/workers/subdomain"
|
|
75
|
+
);
|
|
76
|
+
assert.equal(seenOptions.method, "GET");
|
|
77
|
+
assert.equal(seenOptions.headers.Authorization, "Bearer tok-abc");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("defaultDeriveSubdomain returns null without creds, on non-2xx, and on network error", async () => {
|
|
81
|
+
assert.equal(await defaultDeriveSubdomain({ accountId: "", apiToken: "tok" }, async () => ({})), null);
|
|
82
|
+
assert.equal(await defaultDeriveSubdomain({ accountId: "a", apiToken: "" }, async () => ({})), null);
|
|
83
|
+
assert.equal(
|
|
84
|
+
await defaultDeriveSubdomain({ accountId: "a", apiToken: "t" }, async () => ({ ok: false, text: async () => "" })),
|
|
85
|
+
null
|
|
86
|
+
);
|
|
87
|
+
assert.equal(
|
|
88
|
+
await defaultDeriveSubdomain({ accountId: "a", apiToken: "t" }, async () => {
|
|
89
|
+
throw new Error("ECONNRESET");
|
|
90
|
+
}),
|
|
91
|
+
null
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
52
95
|
// ---------------------------------------------------------------------------
|
|
53
96
|
// parseVersionField — the jq/JSON.parse replacement for grep-for-"version"
|
|
54
97
|
// ---------------------------------------------------------------------------
|
|
@@ -315,21 +358,30 @@ test("runSmoke fails without a rollback list when no subdomain is derivable", as
|
|
|
315
358
|
const { log, lines } = collectLogs();
|
|
316
359
|
const result = await runSmoke(
|
|
317
360
|
{ DEPLOYED_WORKERS: "api", SMOKE_PATHS: "/health" },
|
|
318
|
-
{ log,
|
|
361
|
+
{ log, deriveSubdomain: async () => null, probe: async () => ({ status: 200, body: "{}" }) }
|
|
319
362
|
);
|
|
320
363
|
assert.equal(result.exitCode, 1);
|
|
321
364
|
assert.deepEqual(result.failedWorkers, []);
|
|
322
365
|
assert.ok(lines.some((l) => l.includes("Could not derive workers.dev subdomain")));
|
|
323
366
|
});
|
|
324
367
|
|
|
325
|
-
test("runSmoke derives the subdomain
|
|
368
|
+
test("runSmoke derives the subdomain via the REST endpoint when not provided", async () => {
|
|
326
369
|
const { log } = collectLogs();
|
|
327
370
|
const probed = [];
|
|
371
|
+
let seenCreds;
|
|
328
372
|
const result = await runSmoke(
|
|
329
|
-
{
|
|
373
|
+
{
|
|
374
|
+
DEPLOYED_WORKERS: "api",
|
|
375
|
+
SMOKE_PATHS: "/health",
|
|
376
|
+
CLOUDFLARE_ACCOUNT_ID: "acct-123",
|
|
377
|
+
CLOUDFLARE_API_TOKEN: "tok-abc",
|
|
378
|
+
},
|
|
330
379
|
{
|
|
331
380
|
log,
|
|
332
|
-
|
|
381
|
+
deriveSubdomain: async (creds) => {
|
|
382
|
+
seenCreds = creds;
|
|
383
|
+
return "dsj1984";
|
|
384
|
+
},
|
|
333
385
|
probe: async (url) => {
|
|
334
386
|
probed.push(url);
|
|
335
387
|
return { status: 200, body: "{}" };
|
|
@@ -338,6 +390,8 @@ test("runSmoke derives the subdomain from whoami output when not provided", asyn
|
|
|
338
390
|
);
|
|
339
391
|
assert.equal(result.exitCode, 0);
|
|
340
392
|
assert.deepEqual(probed, ["https://api.dsj1984.workers.dev/health"]);
|
|
393
|
+
// The REST creds are threaded through from the environment.
|
|
394
|
+
assert.deepEqual(seenCreds, { accountId: "acct-123", apiToken: "tok-abc" });
|
|
341
395
|
});
|
|
342
396
|
|
|
343
397
|
// ---------------------------------------------------------------------------
|
|
@@ -379,3 +433,70 @@ test("resolveFailedFile creates a private temp dir when SMOKE_FAILED_FILE is uns
|
|
|
379
433
|
assert.equal(path, "/var/folders/xyz/deploy-boot-smoke-Zzz999/smoke-failed-workers.txt");
|
|
380
434
|
assert.notEqual(path, "/tmp/smoke-failed-workers.txt");
|
|
381
435
|
});
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// writeRollbackState — the shared terminal writer (crash-path fix, M2)
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
test("writeRollbackState writes the sorted list and the smoke_failed flag", () => {
|
|
442
|
+
const writes = [];
|
|
443
|
+
const appends = [];
|
|
444
|
+
writeRollbackState("/tmp/failed.txt", ["api", "worker-cron"], {
|
|
445
|
+
githubEnv: "/tmp/gh-env",
|
|
446
|
+
writeFile: (path, data) => writes.push({ path, data }),
|
|
447
|
+
appendFile: (path, data) => appends.push({ path, data }),
|
|
448
|
+
});
|
|
449
|
+
assert.deepEqual(writes, [{ path: "/tmp/failed.txt", data: "api\nworker-cron\n" }]);
|
|
450
|
+
assert.deepEqual(appends, [{ path: "/tmp/gh-env", data: "smoke_failed=true\n" }]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("writeRollbackState is a no-op for an empty worker list", () => {
|
|
454
|
+
let wrote = false;
|
|
455
|
+
let appended = false;
|
|
456
|
+
writeRollbackState("/tmp/failed.txt", [], {
|
|
457
|
+
githubEnv: "/tmp/gh-env",
|
|
458
|
+
writeFile: () => {
|
|
459
|
+
wrote = true;
|
|
460
|
+
},
|
|
461
|
+
appendFile: () => {
|
|
462
|
+
appended = true;
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
assert.equal(wrote, false);
|
|
466
|
+
assert.equal(appended, false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("writeRollbackState skips the GITHUB_ENV append when githubEnv is unset (still writes the list)", () => {
|
|
470
|
+
const writes = [];
|
|
471
|
+
let appended = false;
|
|
472
|
+
writeRollbackState("/tmp/failed.txt", ["api"], {
|
|
473
|
+
writeFile: (path, data) => writes.push({ path, data }),
|
|
474
|
+
appendFile: () => {
|
|
475
|
+
appended = true;
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
assert.deepEqual(writes, [{ path: "/tmp/failed.txt", data: "api\n" }]);
|
|
479
|
+
assert.equal(appended, false);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// Crash-path terminal write (M2): runSmoke throwing must still mark every
|
|
484
|
+
// deployed worker for rollback. main() catches and calls writeRollbackState
|
|
485
|
+
// with uniqueSorted(parseCsv(DEPLOYED_WORKERS)); this asserts the exact
|
|
486
|
+
// derivation main() feeds the writer on the crash path.
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
test("crash-path derives the full deployed-worker rollback set (uniqueSorted + parseCsv)", () => {
|
|
490
|
+
// Mirrors main()'s catch block: on an unhandled error, EVERY deployed
|
|
491
|
+
// worker is marked (no per-worker attribution survives a crash).
|
|
492
|
+
const deployed = uniqueSorted(parseCsv("worker-cron, api ,worker-cron"));
|
|
493
|
+
const writes = [];
|
|
494
|
+
const appends = [];
|
|
495
|
+
writeRollbackState("/tmp/failed.txt", deployed, {
|
|
496
|
+
githubEnv: "/tmp/gh-env",
|
|
497
|
+
writeFile: (path, data) => writes.push({ path, data }),
|
|
498
|
+
appendFile: (path, data) => appends.push({ path, data }),
|
|
499
|
+
});
|
|
500
|
+
assert.deepEqual(writes, [{ path: "/tmp/failed.txt", data: "api\nworker-cron\n" }]);
|
|
501
|
+
assert.deepEqual(appends, [{ path: "/tmp/gh-env", data: "smoke_failed=true\n" }]);
|
|
502
|
+
});
|
|
@@ -68,7 +68,12 @@ import { tmpdir } from "node:os";
|
|
|
68
68
|
import { dirname, join, resolve } from "node:path";
|
|
69
69
|
import { fileURLToPath } from "node:url";
|
|
70
70
|
|
|
71
|
-
import {
|
|
71
|
+
import {
|
|
72
|
+
allConsumersErrored,
|
|
73
|
+
buildReport,
|
|
74
|
+
isFullSha,
|
|
75
|
+
pinDriftTokenProvided,
|
|
76
|
+
} from "./check-pin-drift.mjs";
|
|
72
77
|
import { defaultGhRunner } from "./lib/gh-json.mjs";
|
|
73
78
|
import { parseSemver } from "./lib/semver-duration.mjs";
|
|
74
79
|
|
|
@@ -602,6 +607,13 @@ export function runRepair({
|
|
|
602
607
|
}) {
|
|
603
608
|
// Reuse the detector to classify every consumer (single SSOT for drift).
|
|
604
609
|
const report = buildReport(config, runGh, nowMs);
|
|
610
|
+
// M11: mirror check-pin-drift's dead-credential signal. When EVERY consumer
|
|
611
|
+
// row errored the detector could read no repo at all — the signature of a
|
|
612
|
+
// provided-but-dead PIN_DRIFT_TOKEN (an expired PAT). The caller pairs this
|
|
613
|
+
// with `tokenProvided` to fail the run instead of silently reporting a green
|
|
614
|
+
// "no repairable drift" (which is what an all-error sweep degrades to, since
|
|
615
|
+
// every error row classifies `repairable: false, reason: "error"`).
|
|
616
|
+
const allErrored = allConsumersErrored(report);
|
|
605
617
|
const latestTag = report.latestRelease?.tag ?? null;
|
|
606
618
|
const targetSha = report.latestRelease?.sha ?? null;
|
|
607
619
|
// The pin target is the latest release tag (so the `# <ref>` annotation reads
|
|
@@ -648,7 +660,14 @@ export function runRepair({
|
|
|
648
660
|
}
|
|
649
661
|
}
|
|
650
662
|
|
|
651
|
-
return {
|
|
663
|
+
return {
|
|
664
|
+
ref: effectiveRef,
|
|
665
|
+
targetSha,
|
|
666
|
+
dryRun,
|
|
667
|
+
hasToken: Boolean(token),
|
|
668
|
+
allErrored,
|
|
669
|
+
rows,
|
|
670
|
+
};
|
|
652
671
|
}
|
|
653
672
|
|
|
654
673
|
// ---------------------------------------------------------------------------
|
|
@@ -743,6 +762,23 @@ export function runCli({
|
|
|
743
762
|
}
|
|
744
763
|
}
|
|
745
764
|
}
|
|
765
|
+
|
|
766
|
+
// M11: the repair loop reads consumers with the SAME cross-repo credential the
|
|
767
|
+
// dashboard uses (PIN_DRIFT_TOKEN → GH_TOKEN). When that token was PROVIDED
|
|
768
|
+
// but every detector row errored, the credential is dead (expired PAT) rather
|
|
769
|
+
// than not-yet-provisioned — every consumer degraded to `error` / repairable
|
|
770
|
+
// false, which otherwise renders a reassuring green "no repairable drift". Fail
|
|
771
|
+
// the run loudly so the dead credential is fixed. The absent-token bootstrap
|
|
772
|
+
// (pinDriftTokenProvided false) keeps its exit-0 read-only behavior.
|
|
773
|
+
if (pinDriftTokenProvided(env) && report.allErrored) {
|
|
774
|
+
stderr.write(
|
|
775
|
+
"::error::[platform-repair] PIN_DRIFT_TOKEN was provided but every " +
|
|
776
|
+
"cross-repo consumer read errored — the credential is dead (likely an " +
|
|
777
|
+
"expired fine-grained PAT), not a not-yet-provisioned bootstrap. Rotate " +
|
|
778
|
+
"the token. See docs/runbooks/pin-drift-dashboard.md.\n",
|
|
779
|
+
);
|
|
780
|
+
return 1;
|
|
781
|
+
}
|
|
746
782
|
return 0;
|
|
747
783
|
}
|
|
748
784
|
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import assert from "node:assert/strict";
|
|
24
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
24
27
|
import { test } from "node:test";
|
|
25
28
|
|
|
26
29
|
import {
|
|
@@ -32,6 +35,7 @@ import {
|
|
|
32
35
|
parsePrNumberFromUrl,
|
|
33
36
|
renderRepairPrBody,
|
|
34
37
|
renderRepairReport,
|
|
38
|
+
runCli,
|
|
35
39
|
runRepair,
|
|
36
40
|
} from "./platform-repair.mjs";
|
|
37
41
|
|
|
@@ -269,6 +273,12 @@ function makeGh({ consumerWorkflow, npmVersion, openPrs = {}, calls }) {
|
|
|
269
273
|
|
|
270
274
|
const noopGit = () => "";
|
|
271
275
|
|
|
276
|
+
/** A minimal write-sink that records everything written, for stdout/stderr. */
|
|
277
|
+
function capture() {
|
|
278
|
+
const chunks = [];
|
|
279
|
+
return { write: (s) => chunks.push(s), text: () => chunks.join("") };
|
|
280
|
+
}
|
|
281
|
+
|
|
272
282
|
function laggingConfig() {
|
|
273
283
|
return {
|
|
274
284
|
platformRepo: PLATFORM_REPO,
|
|
@@ -456,3 +466,106 @@ test("renderRepairReport tabulates outcomes and a repaired section", () => {
|
|
|
456
466
|
assert.ok(text.includes("Repaired (1)"));
|
|
457
467
|
assert.ok(text.includes("#101"));
|
|
458
468
|
});
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// M11 — provided-but-dead read credential vs. not-yet-provisioned bootstrap.
|
|
472
|
+
// The repair loop reads consumers with the SAME cross-repo PAT the dashboard
|
|
473
|
+
// uses (PIN_DRIFT_TOKEN → GH_TOKEN). When that PAT is provided-but-dead every
|
|
474
|
+
// detector row errors → every consumer classifies `error`/repairable-false,
|
|
475
|
+
// which otherwise renders a reassuring green "no repairable drift". runCli must
|
|
476
|
+
// hard-fail on that when the token was provided, and stay exit-0 when it was
|
|
477
|
+
// absent (bootstrap). Mirrors scripts/check-runner-health.mjs error-row
|
|
478
|
+
// handling. (temp/audits/workflow-robustness-review-2026-07-05.md M11)
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* A gh runner where the platform's own release resolution succeeds but EVERY
|
|
483
|
+
* cross-repo consumer read fails non-404 (auth/transport) — the shape of a dead
|
|
484
|
+
* cross-repo PAT. No PR surface is reached because every consumer errors out
|
|
485
|
+
* before repair.
|
|
486
|
+
*/
|
|
487
|
+
function makeAllConsumersFailGh() {
|
|
488
|
+
return (args) => {
|
|
489
|
+
const path = args[1];
|
|
490
|
+
if (args[0] !== "api") {
|
|
491
|
+
throw new Error(`unexpected non-api gh call under dead credential: ${args.join(" ")}`);
|
|
492
|
+
}
|
|
493
|
+
if (path === `repos/${PLATFORM_REPO}/releases/latest`) {
|
|
494
|
+
return JSON.stringify({ tag_name: "mandrel-platform-v1.2.3", published_at: "2020-01-01T00:00:00Z" });
|
|
495
|
+
}
|
|
496
|
+
if (path === `repos/${PLATFORM_REPO}/git/ref/tags/mandrel-platform-v1.2.3`) {
|
|
497
|
+
return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
|
|
498
|
+
}
|
|
499
|
+
if (/\/contents\/\.github\/workflows/.test(path)) {
|
|
500
|
+
// 403 (not 404) → fail-closed error row, mirroring an expired PAT.
|
|
501
|
+
const err = new Error("gh: Forbidden (HTTP 403)");
|
|
502
|
+
err.stderr = "gh: Forbidden (HTTP 403)\n";
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
if (/^repos\/[^/]+\/[^/]+$/.test(path)) {
|
|
506
|
+
return JSON.stringify({ default_branch: "main" });
|
|
507
|
+
}
|
|
508
|
+
throw new Error(`unexpected gh api path: ${path}`);
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function deadCredConfigFile() {
|
|
513
|
+
const dir = mkdtempSync(join(tmpdir(), "platform-repair-dead-"));
|
|
514
|
+
const p = join(dir, "consumers.json");
|
|
515
|
+
writeFileSync(
|
|
516
|
+
p,
|
|
517
|
+
JSON.stringify({
|
|
518
|
+
platformRepo: PLATFORM_REPO,
|
|
519
|
+
consumers: [
|
|
520
|
+
{ name: "domio", repo: "dsj1984/domio" },
|
|
521
|
+
{ name: "athportal", repo: "dsj1984/athportal" },
|
|
522
|
+
],
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
return { dir, p };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
test("runCli: PROVIDED-but-dead PIN_DRIFT_TOKEN (every consumer read errors) exits 1 with ::error::", () => {
|
|
529
|
+
const { dir, p } = deadCredConfigFile();
|
|
530
|
+
const stderr = capture();
|
|
531
|
+
try {
|
|
532
|
+
const code = runCli({
|
|
533
|
+
argv: ["--config", p],
|
|
534
|
+
env: { PIN_DRIFT_TOKEN: "ghp_expired" },
|
|
535
|
+
runGh: makeAllConsumersFailGh(),
|
|
536
|
+
runGit: noopGit,
|
|
537
|
+
runSync: () => {
|
|
538
|
+
throw new Error("must not sync under a dead credential");
|
|
539
|
+
},
|
|
540
|
+
stdout: capture(),
|
|
541
|
+
stderr,
|
|
542
|
+
summaryPath: undefined,
|
|
543
|
+
});
|
|
544
|
+
assert.equal(code, 1);
|
|
545
|
+
assert.match(stderr.text(), /::error::/);
|
|
546
|
+
assert.match(stderr.text(), /credential is dead/);
|
|
547
|
+
} finally {
|
|
548
|
+
rmSync(dir, { recursive: true, force: true });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("runCli: ABSENT PIN_DRIFT_TOKEN bootstrap (every consumer read errors, token unset) stays exit 0", () => {
|
|
553
|
+
const { dir, p } = deadCredConfigFile();
|
|
554
|
+
try {
|
|
555
|
+
const code = runCli({
|
|
556
|
+
argv: ["--config", p],
|
|
557
|
+
env: {}, // PIN_DRIFT_TOKEN absent → not-yet-provisioned bootstrap.
|
|
558
|
+
runGh: makeAllConsumersFailGh(),
|
|
559
|
+
runGit: noopGit,
|
|
560
|
+
runSync: () => {
|
|
561
|
+
throw new Error("must not sync during bootstrap");
|
|
562
|
+
},
|
|
563
|
+
stdout: capture(),
|
|
564
|
+
stderr: capture(),
|
|
565
|
+
summaryPath: undefined,
|
|
566
|
+
});
|
|
567
|
+
assert.equal(code, 0);
|
|
568
|
+
} finally {
|
|
569
|
+
rmSync(dir, { recursive: true, force: true });
|
|
570
|
+
}
|
|
571
|
+
});
|
|
@@ -570,22 +570,49 @@ test("--check-ruleset requires --consumer-repo", () => {
|
|
|
570
570
|
}, /consumer-repo/);
|
|
571
571
|
});
|
|
572
572
|
|
|
573
|
-
test("apply materializes the canonical deploy-staging
|
|
573
|
+
test("apply materializes the canonical deploy-staging dispatcher + run templates (Story #272)", () => {
|
|
574
574
|
const out = JSON.parse(run([]));
|
|
575
|
-
const
|
|
576
|
-
|
|
575
|
+
const dispatcher = join(consumer, ".github", "workflows", "deploy-staging.yml");
|
|
576
|
+
const runner = join(consumer, ".github", "workflows", "deploy-staging-run.yml");
|
|
577
|
+
assert.ok(existsSync(dispatcher), "deploy-staging.yml (dispatcher) materialized");
|
|
578
|
+
assert.ok(existsSync(runner), "deploy-staging-run.yml (deploy) materialized");
|
|
577
579
|
assert.ok(
|
|
578
580
|
out.workflowStubs.created.some((f) => f.endsWith("deploy-staging.yml")),
|
|
579
|
-
"deploy-staging.yml reported as created"
|
|
581
|
+
"deploy-staging.yml (dispatcher) reported as created"
|
|
582
|
+
);
|
|
583
|
+
assert.ok(
|
|
584
|
+
out.workflowStubs.created.some((f) => f.endsWith("deploy-staging-run.yml")),
|
|
585
|
+
"deploy-staging-run.yml (deploy) reported as created"
|
|
586
|
+
);
|
|
587
|
+
const dispatcherBody = readFileSync(dispatcher, "utf8");
|
|
588
|
+
const runnerBody = readFileSync(runner, "utf8");
|
|
589
|
+
// Both halves carry the never-clobber template marker.
|
|
590
|
+
assert.ok(
|
|
591
|
+
dispatcherBody.includes("Canonical staging-deploy caller template"),
|
|
592
|
+
"dispatcher carries the template marker"
|
|
593
|
+
);
|
|
594
|
+
assert.ok(
|
|
595
|
+
runnerBody.includes("Canonical staging-deploy caller template"),
|
|
596
|
+
"run template carries the template marker"
|
|
597
|
+
);
|
|
598
|
+
// The dispatcher fires on CI-green (workflow_run) and DISPATCHES the run
|
|
599
|
+
// workflow — it must NOT call the deploy directly, since a workflow_run
|
|
600
|
+
// deploy skips every environment: job (Story #272).
|
|
601
|
+
assert.ok(
|
|
602
|
+
dispatcherBody.includes("workflow_run") &&
|
|
603
|
+
dispatcherBody.includes("gh workflow run deploy-staging-run.yml"),
|
|
604
|
+
"dispatcher fires on workflow_run and dispatches deploy-staging-run.yml"
|
|
580
605
|
);
|
|
581
|
-
const body = readFileSync(stub, "utf8");
|
|
582
606
|
assert.ok(
|
|
583
|
-
|
|
584
|
-
"
|
|
607
|
+
!dispatcherBody.includes("dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml"),
|
|
608
|
+
"dispatcher does NOT uses: deploy-cloudflare.yml directly (that would skip environment: jobs on workflow_run)"
|
|
585
609
|
);
|
|
610
|
+
// The deploy half runs on workflow_dispatch (where environment: jobs execute)
|
|
611
|
+
// and uses the shared reusable workflow.
|
|
586
612
|
assert.ok(
|
|
587
|
-
|
|
588
|
-
|
|
613
|
+
runnerBody.includes("workflow_dispatch") &&
|
|
614
|
+
runnerBody.includes("dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml"),
|
|
615
|
+
"run template deploys on workflow_dispatch via the shared deploy-cloudflare.yml"
|
|
589
616
|
);
|
|
590
617
|
});
|
|
591
618
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Data-driven expected roster for scripts/check-runner-health.mjs (Story #258). Each entry is one repo whose self-hosted runner fleet the scheduled runner-fleet-health.yml workflow monitors. Adding/removing a runner needs only an edit here — the checker reads GET /repos/{owner}/{repo}/actions/runners and compares live status against `expectedCount` + `labels`. All nine runners (including Beestera/swarm-os's three) are co-resident on one operator Mac (2026-07-03 runner audit, repo-ops matrix §1a); if that host sleeps, reboots, fills its disk, or a launchd service dies, every listed repo's CI silently queues with no alert until this monitor catches it.",
|
|
3
|
+
"$comment_swarm_os": "Beestera/swarm-os is deliberately NOT listed: no dsj1984-owned PAT can read another org's runner API (fine-grained PATs are bound to one resource owner; the Beestera org rejects classic PATs), so its row would permanently false-positive as 0/3 degraded. Host-level coverage is retained regardless — its runners share the Mac with the rows below, so a wedged host still trips domio/athportal. What is NOT covered: swarm-os's individual launchd services dying while the host stays healthy, and its stale-queue check. Re-add the entry if a Beestera-owned credential (fine-grained PAT or GitHub App) plus per-repo token support ever lands.",
|
|
4
|
+
"$comment_staleQueuedMinutes": "A queued/waiting workflow run older than this many minutes with no online runner matching its labels is flagged as a queue-staleness signal (optional per-repo override via `staleQueuedMinutes`).",
|
|
5
|
+
"defaultStaleQueuedMinutes": 20,
|
|
6
|
+
"repos": [
|
|
7
|
+
{
|
|
8
|
+
"name": "domio",
|
|
9
|
+
"repo": "dsj1984/domio",
|
|
10
|
+
"expectedCount": 3,
|
|
11
|
+
"labels": ["self-hosted", "macOS", "ARM64", "domio-runner"]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "athportal",
|
|
15
|
+
"repo": "dsj1984/athportal",
|
|
16
|
+
"expectedCount": 3,
|
|
17
|
+
"labels": ["self-hosted", "macOS", "ARM64", "athportal-runner"]
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Hash-pinned lockfile for the pr-quality SAST (semgrep) step.
|
|
2
|
+
#
|
|
3
|
+
# Target platform : Linux x86_64 / CPython 3.12 (GitHub Actions ubuntu-latest)
|
|
4
|
+
# Tool : semgrep 1.97.0 (+ complete transitive closure)
|
|
5
|
+
# Consumed via : pip install --require-hashes -r scripts/semgrep-requirements.txt
|
|
6
|
+
#
|
|
7
|
+
# Every requirement below is pinned with `==` and carries at least one
|
|
8
|
+
# sha256 hash, as `--require-hashes` demands. The closure was resolved for
|
|
9
|
+
# the linux/cp312 target specifically (via `pip download` with explicit
|
|
10
|
+
# --platform manylinux/musllinux + --python-version 3.12 --abi cp312), NOT
|
|
11
|
+
# from the local interpreter, so the native wheels (semgrep, protobuf,
|
|
12
|
+
# rpds-py, wrapt, charset-normalizer, ruamel.yaml.clib) are the linux x86_64
|
|
13
|
+
# cp312-compatible artifacts.
|
|
14
|
+
#
|
|
15
|
+
# setuptools is intentionally included: the workflow installs it alongside
|
|
16
|
+
# semgrep so opentelemetry's transitive `pkg_resources` import works on
|
|
17
|
+
# Python >=3.12 (which no longer ships setuptools by default).
|
|
18
|
+
#
|
|
19
|
+
# Regenerate with `pip download semgrep==<ver> setuptools --only-binary=:all:
|
|
20
|
+
# --platform manylinux2014_x86_64 --platform manylinux_2_17_x86_64
|
|
21
|
+
# --platform any --python-version 3.12 --implementation cp --abi cp312`.
|
|
22
|
+
|
|
23
|
+
attrs==26.1.0 \
|
|
24
|
+
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309
|
|
25
|
+
|
|
26
|
+
boltons==21.0.0 \
|
|
27
|
+
--hash=sha256:b9bb7b58b2b420bbe11a6025fdef6d3e5edc9f76a42fb467afe7ca212ef9948b
|
|
28
|
+
|
|
29
|
+
bracex==3.0 \
|
|
30
|
+
--hash=sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5
|
|
31
|
+
|
|
32
|
+
certifi==2026.6.17 \
|
|
33
|
+
--hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
|
|
34
|
+
|
|
35
|
+
charset-normalizer==3.4.7 \
|
|
36
|
+
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116
|
|
37
|
+
|
|
38
|
+
click==8.4.2 \
|
|
39
|
+
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
|
|
40
|
+
|
|
41
|
+
click-option-group==0.5.9 \
|
|
42
|
+
--hash=sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080
|
|
43
|
+
|
|
44
|
+
colorama==0.4.6 \
|
|
45
|
+
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
|
46
|
+
|
|
47
|
+
defusedxml==0.7.1 \
|
|
48
|
+
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
|
|
49
|
+
|
|
50
|
+
deprecated==1.3.1 \
|
|
51
|
+
--hash=sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f
|
|
52
|
+
|
|
53
|
+
exceptiongroup==1.2.2 \
|
|
54
|
+
--hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b
|
|
55
|
+
|
|
56
|
+
face==26.0.1 \
|
|
57
|
+
--hash=sha256:ab0a83c37c9789dce658a67a9a80eafaa113c9ec37c5a9d950ff5480542a062d
|
|
58
|
+
|
|
59
|
+
glom==22.1.0 \
|
|
60
|
+
--hash=sha256:5339da206bf3532e01a83a35aca202960ea885156986d190574b779598e9e772
|
|
61
|
+
|
|
62
|
+
googleapis-common-protos==1.75.0 \
|
|
63
|
+
--hash=sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed
|
|
64
|
+
|
|
65
|
+
idna==3.18 \
|
|
66
|
+
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2
|
|
67
|
+
|
|
68
|
+
importlib-metadata==7.1.0 \
|
|
69
|
+
--hash=sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570
|
|
70
|
+
|
|
71
|
+
jsonschema==4.26.0 \
|
|
72
|
+
--hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
|
|
73
|
+
|
|
74
|
+
jsonschema-specifications==2025.9.1 \
|
|
75
|
+
--hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe
|
|
76
|
+
|
|
77
|
+
markdown-it-py==4.2.0 \
|
|
78
|
+
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
|
|
79
|
+
|
|
80
|
+
mdurl==0.1.2 \
|
|
81
|
+
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8
|
|
82
|
+
|
|
83
|
+
opentelemetry-api==1.25.0 \
|
|
84
|
+
--hash=sha256:757fa1aa020a0f8fa139f8959e53dec2051cc26b832e76fa839a6d76ecefd737
|
|
85
|
+
|
|
86
|
+
opentelemetry-exporter-otlp-proto-common==1.25.0 \
|
|
87
|
+
--hash=sha256:15637b7d580c2675f70246563363775b4e6de947871e01d0f4e3881d1848d693
|
|
88
|
+
|
|
89
|
+
opentelemetry-exporter-otlp-proto-http==1.25.0 \
|
|
90
|
+
--hash=sha256:2eca686ee11b27acd28198b3ea5e5863a53d1266b91cda47c839d95d5e0541a6
|
|
91
|
+
|
|
92
|
+
opentelemetry-instrumentation==0.46b0 \
|
|
93
|
+
--hash=sha256:89cd721b9c18c014ca848ccd11181e6b3fd3f6c7669e35d59c48dc527408c18b
|
|
94
|
+
|
|
95
|
+
opentelemetry-instrumentation-requests==0.46b0 \
|
|
96
|
+
--hash=sha256:a8c2472800d8686f3f286cd524b8746b386154092e85a791ba14110d1acc9b81
|
|
97
|
+
|
|
98
|
+
opentelemetry-proto==1.25.0 \
|
|
99
|
+
--hash=sha256:f07e3341c78d835d9b86665903b199893befa5e98866f63d22b00d0b7ca4972f
|
|
100
|
+
|
|
101
|
+
opentelemetry-sdk==1.25.0 \
|
|
102
|
+
--hash=sha256:d97ff7ec4b351692e9d5a15af570c693b8715ad78b8aafbec5c7100fe966b4c9
|
|
103
|
+
|
|
104
|
+
opentelemetry-semantic-conventions==0.46b0 \
|
|
105
|
+
--hash=sha256:6daef4ef9fa51d51855d9f8e0ccd3a1bd59e0e545abe99ac6203804e36ab3e07
|
|
106
|
+
|
|
107
|
+
opentelemetry-util-http==0.46b0 \
|
|
108
|
+
--hash=sha256:8dc1949ce63caef08db84ae977fdc1848fe6dc38e6bbaad0ae3e6ecd0d451629
|
|
109
|
+
|
|
110
|
+
packaging==26.2 \
|
|
111
|
+
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e
|
|
112
|
+
|
|
113
|
+
peewee==3.19.0 \
|
|
114
|
+
--hash=sha256:de220b94766e6008c466e00ce4ba5299b9a832117d9eb36d45d0062f3cfd7417
|
|
115
|
+
|
|
116
|
+
protobuf==4.25.9 \
|
|
117
|
+
--hash=sha256:438c636de8fb706a0de94a12a268ef1ae8f5ba5ae655a7671fcda5968ba3c9be
|
|
118
|
+
|
|
119
|
+
pygments==2.20.0 \
|
|
120
|
+
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
|
121
|
+
|
|
122
|
+
referencing==0.37.0 \
|
|
123
|
+
--hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231
|
|
124
|
+
|
|
125
|
+
requests==2.34.2 \
|
|
126
|
+
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0
|
|
127
|
+
|
|
128
|
+
rich==13.5.3 \
|
|
129
|
+
--hash=sha256:9257b468badc3d347e146a4faa268ff229039d4c2d176ab0cffb4c4fbc73d5d9
|
|
130
|
+
|
|
131
|
+
rpds-py==2026.6.3 \
|
|
132
|
+
--hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6
|
|
133
|
+
|
|
134
|
+
ruamel.yaml==0.17.40 \
|
|
135
|
+
--hash=sha256:b16b6c3816dff0a93dca12acf5e70afd089fa5acb80604afd1ffa8b465b7722c
|
|
136
|
+
|
|
137
|
+
ruamel.yaml.clib==0.2.15 \
|
|
138
|
+
--hash=sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9
|
|
139
|
+
|
|
140
|
+
semgrep==1.97.0 \
|
|
141
|
+
--hash=sha256:996fe0b2bfac3a4d4511e470fdf5f3bca96b1f794f398e0336c8388802c218de
|
|
142
|
+
|
|
143
|
+
# PINNED to 80.9.0 (the last release that still SHIPS `pkg_resources`):
|
|
144
|
+
# setuptools 81+ removed the vendored `pkg_resources` module, but
|
|
145
|
+
# semgrep 1.97.0's transitive `opentelemetry-instrumentation==0.46b0` imports
|
|
146
|
+
# it at load. A py3.12 venv seeds no setuptools, so the lockfile must supply a
|
|
147
|
+
# pkg_resources-bearing one — 83.0.0 broke the SAST step at runtime with
|
|
148
|
+
# `ModuleNotFoundError: No module named 'pkg_resources'`. Do NOT bump past 80.x
|
|
149
|
+
# without confirming pkg_resources is present (or bumping opentelemetry off it).
|
|
150
|
+
setuptools==80.9.0 \
|
|
151
|
+
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922
|
|
152
|
+
|
|
153
|
+
tomli==2.0.2 \
|
|
154
|
+
--hash=sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38
|
|
155
|
+
|
|
156
|
+
typing-extensions==4.16.0 \
|
|
157
|
+
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
|
|
158
|
+
|
|
159
|
+
urllib3==2.7.0 \
|
|
160
|
+
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
|
161
|
+
|
|
162
|
+
wcmatch==8.5.2 \
|
|
163
|
+
--hash=sha256:17d3ad3758f9d0b5b4dedc770b65420d4dac62e680229c287bf24c9db856a478
|
|
164
|
+
|
|
165
|
+
wrapt==1.17.3 \
|
|
166
|
+
--hash=sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828
|
|
167
|
+
|
|
168
|
+
zipp==4.1.0 \
|
|
169
|
+
--hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f
|