mandrel-platform 0.24.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.24.0",
3
+ "version": "0.25.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": {
@@ -44,7 +44,7 @@
44
44
  "provenance": true
45
45
  },
46
46
  "dependencies": {
47
- "mandrel": "^1.81.0"
47
+ "mandrel": "^1.83.0"
48
48
  },
49
49
  "scripts": {
50
50
  "typecheck": "node --input-type=module --eval 'process.exit(0)'",
@@ -979,18 +979,61 @@ export function hasDrift(report) {
979
979
  return report.results.some((r) => r.error || r.drift);
980
980
  }
981
981
 
982
+ /**
983
+ * Is EVERY configured consumer an `error` row (M11)? This is the signature of a
984
+ * dead cross-repo credential: a PIN_DRIFT_TOKEN that was *provided* but has
985
+ * expired (fine-grained PATs always expire) can no longer read ANY consumer, so
986
+ * every `fetchConsumerWorkflows` call fails closed to an `error` row. Contrast
987
+ * the not-yet-provisioned bootstrap case: there the token is *absent*, the run
988
+ * legitimately can't read the private consumers, and that benign state must keep
989
+ * its current exit-0 behavior. The caller distinguishes the two by whether the
990
+ * token was provided (see `runCli`'s `tokenProvided`); this predicate only
991
+ * answers "did every row error", which — given a provided token — means the
992
+ * credential died rather than "no drift".
993
+ *
994
+ * Requires at least one consumer (an empty registry is vacuously not a
995
+ * dead-credential signal).
996
+ *
997
+ * @param {{ results: Array<{ error?: string }> }} report
998
+ * @returns {boolean}
999
+ */
1000
+ export function allConsumersErrored(report) {
1001
+ const results = Array.isArray(report.results) ? report.results : [];
1002
+ return results.length > 0 && results.every((r) => Boolean(r.error));
1003
+ }
1004
+
982
1005
  // ---------------------------------------------------------------------------
983
1006
  // CLI entry
984
1007
  // ---------------------------------------------------------------------------
985
1008
 
1009
+ /**
1010
+ * Whether the cross-repo `PIN_DRIFT_TOKEN` was PROVIDED (non-empty) to the run
1011
+ * (M11). The scheduled/dispatch workflow reads consumers over the `gh` CLI with
1012
+ * `GH_TOKEN: ${{ secrets.PIN_DRIFT_TOKEN || github.token }}` — the built-in
1013
+ * `github.token` fallback can only read THIS repo, so cross-repo reads fail
1014
+ * closed to `error` rows both when the token is absent (bootstrap) AND when it
1015
+ * is provided-but-dead (expired PAT). This env var is the only signal that
1016
+ * distinguishes the two: the workflow sets it to the raw secret so an empty
1017
+ * value ⇒ absent (bootstrap, benign) and a non-empty value ⇒ provided (a
1018
+ * total error sweep then means the credential died).
1019
+ *
1020
+ * @param {Record<string, string | undefined>} env
1021
+ * @returns {boolean}
1022
+ */
1023
+ export function pinDriftTokenProvided(env) {
1024
+ return typeof env.PIN_DRIFT_TOKEN === "string" && env.PIN_DRIFT_TOKEN.length > 0;
1025
+ }
1026
+
986
1027
  /**
987
1028
  * @param {{
988
1029
  * argv?: string[],
989
1030
  * cwd?: string,
990
1031
  * stdout?: { write: (s: string) => void },
991
1032
  * stderr?: { write: (s: string) => void },
1033
+ * env?: Record<string, string | undefined>,
992
1034
  * runGh?: (args: string[]) => string,
993
1035
  * summaryPath?: string | undefined,
1036
+ * tokenProvided?: boolean,
994
1037
  * nowMs?: number,
995
1038
  * }} [opts]
996
1039
  * @returns {number} exit code
@@ -1000,8 +1043,10 @@ export function runCli({
1000
1043
  cwd = process.cwd(),
1001
1044
  stdout = process.stdout,
1002
1045
  stderr = process.stderr,
1046
+ env = process.env,
1003
1047
  runGh = defaultGhRunner,
1004
1048
  summaryPath = process.env.GITHUB_STEP_SUMMARY,
1049
+ tokenProvided = pinDriftTokenProvided(env),
1005
1050
  nowMs = Date.now(),
1006
1051
  } = {}) {
1007
1052
  const { config: configRel, json, strict } = parseArgv(argv);
@@ -1025,9 +1070,19 @@ export function runCli({
1025
1070
 
1026
1071
  const report = buildReport(config, runGh, nowMs);
1027
1072
  const drift = hasDrift(report);
1073
+ // M11: a PROVIDED-but-dead PIN_DRIFT_TOKEN (expired PAT) can no longer read
1074
+ // ANY consumer, so every row fails closed to `error`. That is a credential
1075
+ // failure, NOT the benign not-yet-provisioned bootstrap (token absent) — fail
1076
+ // the run unconditionally (even without --strict, which the scheduled path
1077
+ // can never pass) so the death is loud instead of a green no-op. The absent-
1078
+ // token bootstrap keeps its current behavior: tokenProvided is false, so this
1079
+ // branch never fires and the run exits per the drift/--strict rules below.
1080
+ const deadCredential = tokenProvided && allConsumersErrored(report);
1028
1081
 
1029
1082
  if (json) {
1030
- stdout.write(`${JSON.stringify({ kind: "pin-drift-report", drift, ...report }, null, 2)}\n`);
1083
+ stdout.write(
1084
+ `${JSON.stringify({ kind: "pin-drift-report", drift, deadCredential, ...report }, null, 2)}\n`,
1085
+ );
1031
1086
  } else {
1032
1087
  const text = renderReport(report);
1033
1088
  stdout.write(`${text}\n`);
@@ -1042,6 +1097,16 @@ export function runCli({
1042
1097
  }
1043
1098
  }
1044
1099
 
1100
+ if (deadCredential) {
1101
+ stderr.write(
1102
+ "::error::[pin-drift] PIN_DRIFT_TOKEN was provided but every cross-repo " +
1103
+ "consumer read errored — the credential is dead (likely an expired " +
1104
+ "fine-grained PAT), not a not-yet-provisioned bootstrap. Rotate the " +
1105
+ "token. See docs/runbooks/pin-drift-dashboard.md.\n",
1106
+ );
1107
+ return 1;
1108
+ }
1109
+
1045
1110
  if (strict && drift) {
1046
1111
  stderr.write(`[pin-drift] ❌ drift detected (--strict)\n`);
1047
1112
  return 1;
@@ -20,6 +20,7 @@ import { join } from "node:path";
20
20
  import { test } from "node:test";
21
21
 
22
22
  import {
23
+ allConsumersErrored,
23
24
  buildReport,
24
25
  classifyNpmPin,
25
26
  classifyStaleLiterals,
@@ -34,6 +35,7 @@ import {
34
35
  isWithinReleaseAgeWindow,
35
36
  parseDurationMs,
36
37
  parseSemver,
38
+ pinDriftTokenProvided,
37
39
  renderReport,
38
40
  resolveLatestRelease,
39
41
  runCli,
@@ -883,3 +885,126 @@ test("runCli --strict DOES exit 1 once the held release ages out", () => {
883
885
  rmSync(cfgDir, { recursive: true, force: true });
884
886
  }
885
887
  });
888
+
889
+ // ---------------------------------------------------------------------------
890
+ // M11 — provided-but-dead PIN_DRIFT_TOKEN (expired PAT) vs. not-yet-provisioned
891
+ // bootstrap. A dead credential can read NO consumer, so every row fails closed
892
+ // to an `error` row; that must hard-fail EVEN without --strict (the scheduled
893
+ // path can never pass --strict). The absent-token bootstrap — identical row
894
+ // shape but token unset — must keep its benign exit-0.
895
+ // (temp/audits/workflow-robustness-review-2026-07-05.md M11)
896
+ // ---------------------------------------------------------------------------
897
+
898
+ const DEAD_CRED_CONFIG = {
899
+ platformRepo: PLATFORM,
900
+ consumers: [
901
+ { name: "c1", repo: "o/c1", branch: "main" },
902
+ { name: "c2", repo: "o/c2", branch: "main" },
903
+ ],
904
+ };
905
+
906
+ /**
907
+ * A gh runner where the platform's own release resolution succeeds (that read
908
+ * uses the built-in token, which CAN read this repo) but EVERY cross-repo
909
+ * consumer workflow-listing call fails with a non-404 (auth/transport) — the
910
+ * exact shape of a dead cross-repo PAT. Every consumer therefore becomes an
911
+ * `error` row.
912
+ */
913
+ function makeAllConsumersFailRunGh() {
914
+ return (args) => {
915
+ const path = args[1];
916
+ if (path === `repos/${PLATFORM}/releases/latest`) {
917
+ return JSON.stringify({ tag_name: TAG });
918
+ }
919
+ if (path === `repos/${PLATFORM}/git/ref/tags/${TAG}`) {
920
+ return JSON.stringify({ object: { sha: LATEST_SHA, type: "commit" } });
921
+ }
922
+ if (/\/contents\/\.github\/workflows/.test(path)) {
923
+ // 403/5xx (not 404) → fail-closed to an `error` row, mirroring an expired
924
+ // PAT that can no longer read the private consumer repo.
925
+ throw ghHttpError(403, "Forbidden");
926
+ }
927
+ throw new Error(`unexpected gh api path: ${path}`);
928
+ };
929
+ }
930
+
931
+ test("pinDriftTokenProvided: non-empty ⇒ true, empty/absent ⇒ false", () => {
932
+ assert.equal(pinDriftTokenProvided({ PIN_DRIFT_TOKEN: "ghp_live" }), true);
933
+ assert.equal(pinDriftTokenProvided({ PIN_DRIFT_TOKEN: "" }), false);
934
+ assert.equal(pinDriftTokenProvided({}), false);
935
+ });
936
+
937
+ test("allConsumersErrored: true only when every row errored and ≥1 consumer", () => {
938
+ const report = buildReport(DEAD_CRED_CONFIG, makeAllConsumersFailRunGh());
939
+ assert.equal(allConsumersErrored(report), true);
940
+ // A single clean row flips it back to false — that is drift/partial, not a
941
+ // dead credential.
942
+ assert.equal(
943
+ allConsumersErrored({ results: [{ error: "x" }, { drift: true }] }),
944
+ false,
945
+ );
946
+ assert.equal(allConsumersErrored({ results: [] }), false);
947
+ });
948
+
949
+ test("runCli: PROVIDED-but-dead PIN_DRIFT_TOKEN (all rows error) exits 1 with ::error:: even without --strict", () => {
950
+ cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-dead-"));
951
+ const p = join(cfgDir, "consumers.json");
952
+ writeFileSync(p, JSON.stringify(DEAD_CRED_CONFIG));
953
+ const stderr = capture();
954
+ try {
955
+ const code = runCli({
956
+ argv: ["--config", p], // NOTE: no --strict.
957
+ env: { PIN_DRIFT_TOKEN: "ghp_expired" },
958
+ runGh: makeAllConsumersFailRunGh(),
959
+ stdout: capture(),
960
+ stderr,
961
+ summaryPath: undefined,
962
+ });
963
+ assert.equal(code, 1);
964
+ assert.match(stderr.text(), /::error::/);
965
+ assert.match(stderr.text(), /credential is dead/);
966
+ } finally {
967
+ rmSync(cfgDir, { recursive: true, force: true });
968
+ }
969
+ });
970
+
971
+ test("runCli: dead-credential also surfaces in the --json envelope (deadCredential:true)", () => {
972
+ cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-dead-json-"));
973
+ const p = join(cfgDir, "consumers.json");
974
+ writeFileSync(p, JSON.stringify(DEAD_CRED_CONFIG));
975
+ const stdout = capture();
976
+ try {
977
+ const code = runCli({
978
+ argv: ["--config", p, "--json"],
979
+ env: { PIN_DRIFT_TOKEN: "ghp_expired" },
980
+ runGh: makeAllConsumersFailRunGh(),
981
+ stdout,
982
+ stderr: capture(),
983
+ summaryPath: undefined,
984
+ });
985
+ assert.equal(code, 1);
986
+ const envelope = JSON.parse(stdout.text());
987
+ assert.equal(envelope.deadCredential, true);
988
+ } finally {
989
+ rmSync(cfgDir, { recursive: true, force: true });
990
+ }
991
+ });
992
+
993
+ test("runCli: ABSENT PIN_DRIFT_TOKEN bootstrap (all rows error, token unset) stays exit 0 — benign", () => {
994
+ cfgDir = mkdtempSync(join(tmpdir(), "pin-drift-bootstrap-"));
995
+ const p = join(cfgDir, "consumers.json");
996
+ writeFileSync(p, JSON.stringify(DEAD_CRED_CONFIG));
997
+ try {
998
+ const code = runCli({
999
+ argv: ["--config", p], // no --strict, no token.
1000
+ env: {}, // PIN_DRIFT_TOKEN absent → not-yet-provisioned bootstrap.
1001
+ runGh: makeAllConsumersFailRunGh(),
1002
+ stdout: capture(),
1003
+ stderr: capture(),
1004
+ summaryPath: undefined,
1005
+ });
1006
+ assert.equal(code, 0);
1007
+ } finally {
1008
+ rmSync(cfgDir, { recursive: true, force: true });
1009
+ }
1010
+ });
@@ -40,10 +40,15 @@
40
40
  * appended to $GITHUB_ENV so the workflow's `Rollback failed workers` step
41
41
  * fires. Exit code 1 on any smoke failure.
42
42
  *
43
- * Wrangler: the workers.dev subdomain derivation shells out to the
44
- * consumer's lockfile-pinned `pnpm exec wrangler whoami` (installed by
45
- * setup-toolchain, preflighted via its `require-wrangler` input). This
46
- * script never fetches a registry-latest wrangler.
43
+ * Subdomain derivation (M14): the workers.dev account subdomain is derived
44
+ * via the Cloudflare REST endpoint
45
+ * `GET /accounts/{account_id}/workers/subdomain` using the in-scope
46
+ * CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID NOT `wrangler whoami`. This
47
+ * removes the root-wrangler dependency for probe-only consumers: every real
48
+ * consumer is a pnpm workspace with wrangler in an app sub-package (not the
49
+ * root), so the former `pnpm exec wrangler whoami` derivation failed the
50
+ * smoke preflight AFTER the worker already deployed. The REST call needs no
51
+ * wrangler at all.
47
52
  *
48
53
  * Environment contract (all read from process.env):
49
54
  * DEPLOYED_WORKERS csv of deployed worker names (required)
@@ -53,6 +58,8 @@
53
58
  * WORKERS_DEV_SUBDOMAIN explicit workers.dev slug (optional)
54
59
  * VERIFY_COMMIT_SHA 'true' to assert the health JSON version field
55
60
  * EXPECTED_SHA the SHA verify-commit-sha asserts (github.sha)
61
+ * CLOUDFLARE_API_TOKEN CF API token for the subdomain REST derivation
62
+ * CLOUDFLARE_ACCOUNT_ID CF account id for the subdomain REST derivation
56
63
  * SMOKE_FAILED_FILE rollback-list path (default: a freshly-created
57
64
  * private temp dir via mkdtemp — never a predictable
58
65
  * world-writable path; CI sets this explicitly)
@@ -61,13 +68,16 @@
61
68
  * Exit codes:
62
69
  * 0 — every probe passed.
63
70
  * 1 — a probe failed (rollback list written), or the probe target could
64
- * not be resolved (no subdomain derivable — no rollback list).
71
+ * not be resolved (no subdomain derivable — no rollback list), or an
72
+ * unhandled error crashed the run (terminal-write-before-exit — the
73
+ * rollback file + smoke_failed=true flag are still written so the
74
+ * workflow's rollback step fires, M2).
65
75
  */
66
76
 
67
77
  import { writeFileSync, appendFileSync, mkdtempSync } from "node:fs";
68
78
  import { tmpdir } from "node:os";
69
79
  import { join } from "node:path";
70
- import { execFileSync, spawnSync } from "node:child_process";
80
+ import { spawnSync } from "node:child_process";
71
81
 
72
82
  // ---------------------------------------------------------------------------
73
83
  // Pure helpers (unit-tested)
@@ -87,12 +97,28 @@ export function parseSmokePaths(csv) {
87
97
  }
88
98
 
89
99
  /**
90
- * Extract the workers.dev account subdomain slug from `wrangler whoami`
91
- * output (the first `<slug>.workers.dev` token). Returns null when absent.
100
+ * Extract the workers.dev account subdomain slug from a Cloudflare REST
101
+ * `GET /accounts/{account_id}/workers/subdomain` response body (M14). The API
102
+ * returns `{ success, result: { name: "<slug>" } }`; this reads the top-level
103
+ * `result.name`. Returns null when the body is not JSON, not the expected
104
+ * shape, unsuccessful, or the name is missing / not a non-empty string.
105
+ * Deliberately tolerant of a full `<slug>.workers.dev` value (some responses
106
+ * echo the host) by stripping a trailing `.workers.dev`.
92
107
  */
93
- export function extractSubdomain(whoamiOutput) {
94
- const m = String(whoamiOutput ?? "").match(/([A-Za-z0-9-]+)\.workers\.dev/);
95
- return m ? m[1] : null;
108
+ export function extractSubdomain(responseBody) {
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(responseBody);
112
+ } catch {
113
+ return null;
114
+ }
115
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
116
+ if (parsed.success === false) return null;
117
+ const result = parsed.result;
118
+ if (result === null || typeof result !== "object" || Array.isArray(result)) return null;
119
+ const name = result.name;
120
+ if (typeof name !== "string" || name.length === 0) return null;
121
+ return name.replace(/\.workers\.dev$/, "");
96
122
  }
97
123
 
98
124
  /**
@@ -199,7 +225,7 @@ export async function runSmoke(env, deps = {}) {
199
225
  log = (line) => process.stdout.write(`${line}\n`),
200
226
  probe = probeUrl,
201
227
  runShell = defaultRunShell,
202
- whoami = defaultWhoami,
228
+ deriveSubdomain = defaultDeriveSubdomain,
203
229
  } = deps;
204
230
 
205
231
  const workers = parseCsv(env.DEPLOYED_WORKERS);
@@ -228,15 +254,20 @@ export async function runSmoke(env, deps = {}) {
228
254
  // ----- Built-in probe -----
229
255
  // Resolve the workers.dev subdomain slug. NEVER the account ID (a UUID) —
230
256
  // workers.dev subdomains are keyed by the account name/slug. Prefer the
231
- // explicit input, else derive from `wrangler whoami`.
257
+ // explicit input, else derive from the Cloudflare REST endpoint
258
+ // GET /accounts/{account_id}/workers/subdomain (M14 — no wrangler needed).
232
259
  let subdomain = (env.WORKERS_DEV_SUBDOMAIN ?? "").trim();
233
260
  if (!subdomain && !smokeBaseUrl) {
234
- log("::group::Deriving workers.dev subdomain from wrangler whoami");
235
- subdomain = extractSubdomain(whoami());
261
+ log("::group::Deriving workers.dev subdomain from the Cloudflare REST API");
262
+ subdomain = await deriveSubdomain({
263
+ accountId: (env.CLOUDFLARE_ACCOUNT_ID ?? "").trim(),
264
+ apiToken: (env.CLOUDFLARE_API_TOKEN ?? "").trim(),
265
+ });
236
266
  if (!subdomain) {
237
267
  log(
238
- "::error::Could not derive workers.dev subdomain from 'wrangler whoami'. " +
239
- "Pass workers_dev_subdomain or smoke_base_url explicitly."
268
+ "::error::Could not derive workers.dev subdomain from the Cloudflare REST API " +
269
+ "(GET /accounts/{account_id}/workers/subdomain). Check CLOUDFLARE_ACCOUNT_ID / " +
270
+ "CLOUDFLARE_API_TOKEN, or pass workers_dev_subdomain / smoke_base_url explicitly."
240
271
  );
241
272
  log("::endgroup::");
242
273
  // No rollback list: the target could not be resolved, so nothing was
@@ -306,18 +337,30 @@ function defaultRunShell(command, extraEnv) {
306
337
  return res.status ?? 1;
307
338
  }
308
339
 
309
- function defaultWhoami() {
310
- // Consumer lockfile-pinned wrangler (`pnpm exec wrangler`), installed and
311
- // preflighted by setup-toolchain (require-wrangler). Failure tolerated
312
- // an empty output falls through to the "could not derive" error above.
340
+ /**
341
+ * Derive the workers.dev subdomain slug via the Cloudflare REST endpoint
342
+ * `GET /accounts/{account_id}/workers/subdomain` (M14). Uses the in-scope
343
+ * CLOUDFLARE_* creds no wrangler. Any failure (missing creds, network
344
+ * error, non-2xx, unparsable body) is tolerated and returns null, which the
345
+ * caller surfaces as the "could not derive" error above. fetchImpl is
346
+ * injectable for the test suite.
347
+ */
348
+ export async function defaultDeriveSubdomain({ accountId, apiToken }, fetchImpl = fetch) {
349
+ if (!accountId || !apiToken) return null;
350
+ const url = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/workers/subdomain`;
313
351
  try {
314
- return execFileSync("pnpm", ["exec", "wrangler", "whoami"], {
315
- encoding: "utf8",
316
- stdio: ["ignore", "pipe", "ignore"],
317
- maxBuffer: 8 * 1024 * 1024,
352
+ const res = await fetchImpl(url, {
353
+ method: "GET",
354
+ headers: {
355
+ Authorization: `Bearer ${apiToken}`,
356
+ Accept: "application/json",
357
+ },
358
+ signal: AbortSignal.timeout(15000),
318
359
  });
360
+ if (!res.ok) return null;
361
+ return extractSubdomain(await res.text());
319
362
  } catch {
320
- return "";
363
+ return null;
321
364
  }
322
365
  }
323
366
 
@@ -341,18 +384,55 @@ export function resolveFailedFile(env, mkdtempImpl = mkdtempSync) {
341
384
  return join(mkdtempImpl(join(tmpdir(), "deploy-boot-smoke-")), "smoke-failed-workers.txt");
342
385
  }
343
386
 
387
+ /**
388
+ * Write the rollback terminal state: the failed-worker list to `failedFile`
389
+ * (overwrite, never append) and `smoke_failed=true` to $GITHUB_ENV so the
390
+ * workflow's rollback step fires. No-op when `failedWorkers` is empty.
391
+ * Extracted so both the normal smoke-failure path AND the crash path (M2 —
392
+ * terminal-write-before-exit) share one writer, and so the write is
393
+ * unit-testable via injected fs seams.
394
+ *
395
+ * Overwrite rationale: a reused self-hosted runner may carry a stale list
396
+ * from a previous run, and rolling back workers this run never deployed would
397
+ * widen the blast radius.
398
+ */
399
+ export function writeRollbackState(
400
+ failedFile,
401
+ failedWorkers,
402
+ { githubEnv, writeFile = writeFileSync, appendFile = appendFileSync } = {}
403
+ ) {
404
+ if (!failedWorkers || failedWorkers.length === 0) return;
405
+ writeFile(failedFile, `${failedWorkers.join("\n")}\n`);
406
+ if (githubEnv) {
407
+ appendFile(githubEnv, "smoke_failed=true\n");
408
+ }
409
+ }
410
+
344
411
  async function main() {
345
412
  const failedFile = resolveFailedFile(process.env);
346
- const { exitCode, failedWorkers } = await runSmoke(process.env);
347
-
348
- if (failedWorkers.length > 0) {
349
- // Overwrite (never append): a reused self-hosted runner may carry a
350
- // stale list from a previous run, and rolling back workers this run
351
- // never deployed would widen the blast radius.
352
- writeFileSync(failedFile, `${failedWorkers.join("\n")}\n`);
353
- if (process.env.GITHUB_ENV) {
354
- appendFileSync(process.env.GITHUB_ENV, "smoke_failed=true\n");
355
- }
413
+ let exitCode;
414
+ try {
415
+ const result = await runSmoke(process.env);
416
+ exitCode = result.exitCode;
417
+ writeRollbackState(failedFile, result.failedWorkers, {
418
+ githubEnv: process.env.GITHUB_ENV,
419
+ });
420
+ } catch (err) {
421
+ // Crash path (M2): an unhandled error must NOT leave a deployed worker
422
+ // serving unverified code with no rollback. Write the terminal rollback
423
+ // state — rolling back EVERY deployed worker, since the crash gives no
424
+ // per-worker attribution — BEFORE exiting non-zero, so the workflow's
425
+ // rollback step still fires. This is the terminal-write-before-exit
426
+ // guarantee the former (write-only-on-a-clean-return) shape lacked.
427
+ process.stdout.write(
428
+ `::error::deploy-boot-smoke crashed: ${err?.message ?? err}. ` +
429
+ "Marking every deployed worker for rollback.\n"
430
+ );
431
+ const deployed = uniqueSorted(parseCsv(process.env.DEPLOYED_WORKERS));
432
+ writeRollbackState(failedFile, deployed, {
433
+ githubEnv: process.env.GITHUB_ENV,
434
+ });
435
+ exitCode = 1;
356
436
  }
357
437
  process.exit(exitCode);
358
438
  }
@@ -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 finds the first workers.dev slug in whoami output", () => {
36
- const whoami = [
37
- "Getting User settings...",
38
- "👋 You are logged in!",
39
- "┌──────────────┬──────────────────────────┐",
40
- "│ Account Name │ dsj1984's Account │",
41
- "│ Subdomain │ dsj1984.workers.dev │",
42
- "└──────────────┴──────────────────────────┘",
43
- ].join("\n");
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 when no slug is present", () => {
48
- assert.equal(extractSubdomain("no subdomain here"), null);
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, whoami: () => "not logged in", probe: async () => ({ status: 200, body: "{}" }) }
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 from whoami output when not provided", async () => {
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
- { DEPLOYED_WORKERS: "api", SMOKE_PATHS: "/health" },
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
- whoami: () => "│ Subdomain │ dsj1984.workers.dev │",
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 { buildReport, isFullSha } from "./check-pin-drift.mjs";
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 { ref: effectiveRef, targetSha, dryRun, hasToken: Boolean(token), rows };
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
+ });
@@ -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
@@ -51,16 +51,56 @@ on:
51
51
  permissions:
52
52
  contents: read
53
53
 
54
- # Serialize staging deploys: only the freshest dispatch should reach the
55
- # staging surfaces. The shared deploy-cloudflare.yml additionally serializes
56
- # per-environment.
54
+ # Serialize staging deploys: QUEUE, don't cancel (Story #284 / audit H2). An
55
+ # in-flight `wrangler d1 migrations apply` must never be cancelled mid-apply by
56
+ # the next dispatch — a half-applied forward-only migration has no automatic
57
+ # restore. `cancel-in-progress: false` lets a second dispatch wait for the
58
+ # first to finish. (The dispatcher half keeps `cancel-in-progress: true`:
59
+ # cancelling a superseded PENDING dispatch is safe.) The shared
60
+ # deploy-cloudflare.yml additionally serializes per-environment.
57
61
  concurrency:
58
62
  group: deploy-staging-run
59
- cancel-in-progress: true
63
+ cancel-in-progress: false
60
64
 
61
65
  jobs:
66
+ # M4: sha-drift preflight (Story #284). The CI-green gate is TOCTOU-racy — a
67
+ # commit A goes green and dispatches, but commit B (CI pending, possibly
68
+ # later red) may already be on main, so the deploy would ship B under A's
69
+ # green credential. When `sha` is supplied (dispatcher path), assert the main
70
+ # tip still equals it and FAIL loudly on drift, naming both SHAs. A job with
71
+ # `uses:` cannot carry `steps:`, so this preflight is a SEPARATE preceding
72
+ # job that the `uses:` deploy job `needs:`. Manual dispatches without `sha`
73
+ # skip the assertion (the operator is deploying the current tip on purpose).
74
+ sha-drift-preflight:
75
+ name: Sha-drift preflight (deploy what CI verified)
76
+ runs-on: ubuntu-latest
77
+ timeout-minutes: 5
78
+ steps:
79
+ - name: Checkout main tip
80
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
81
+ with:
82
+ ref: main
83
+ persist-credentials: false
84
+ - name: Assert HEAD matches the CI-verified sha
85
+ env:
86
+ INPUT_SHA: ${{ inputs.sha }}
87
+ shell: bash
88
+ run: |
89
+ set -euo pipefail
90
+ if [ -z "${INPUT_SHA}" ]; then
91
+ echo "No sha input — manual dispatch deploying the current main tip; skipping sha-drift preflight."
92
+ exit 0
93
+ fi
94
+ head="$(git rev-parse HEAD)"
95
+ if [ "${head}" != "${INPUT_SHA}" ]; then
96
+ echo "::error::sha drift: main tip is ${head} but the dispatched (CI-verified) sha is ${INPUT_SHA}. main advanced after CI went green; refusing to deploy an unverified tip." >&2
97
+ exit 1
98
+ fi
99
+ echo "sha-drift preflight OK: main tip ${head} matches the CI-verified sha ${INPUT_SHA}."
100
+
62
101
  deploy:
63
102
  name: Staging deploy (shared deploy-cloudflare.yml)
103
+ needs: [sha-drift-preflight]
64
104
  uses: dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml@<MANDREL_PLATFORM_SHA> # <MANDREL_PLATFORM_TAG>
65
105
  with:
66
106
  environment: staging
@@ -71,10 +71,23 @@ jobs:
71
71
  name: Dispatch staging deploy on CI-green
72
72
  runs-on: ubuntu-latest
73
73
  timeout-minutes: 5
74
- # CI-green gate: dispatch the deploy ONLY when the upstream CI run
75
- # concluded 'success'. `workflow_run` fires on both success and failure, so
76
- # this guard is load-bearing — without it a red main would still deploy.
77
- if: ${{ github.event.workflow_run.conclusion == 'success' }}
74
+ # CI-green gate three load-bearing conditions, all required (Story #284):
75
+ # 1. `conclusion == 'success'` `workflow_run` fires on both success and
76
+ # failure; without this a red main would still deploy.
77
+ # 2. `event == 'push'` — the upstream CI run must itself have been a push
78
+ # to the repo, not a `pull_request` run. `workflow_run.branches:
79
+ # [main]` filters on the *head branch NAME*, so a fork PR whose head
80
+ # branch is literally named `main` with green CI otherwise satisfies
81
+ # the branch filter and the success guard (audit M3).
82
+ # 3. `head_repository.full_name == github.repository` — the CI run must
83
+ # have originated from THIS repo, not a fork. Belt-and-suspenders with
84
+ # condition 2: even a same-name fork branch cannot spoof same-repo
85
+ # provenance, so an external contributor can no longer attacker-time a
86
+ # staging deploy off their fork's green CI.
87
+ if: >-
88
+ ${{ github.event.workflow_run.conclusion == 'success'
89
+ && github.event.workflow_run.event == 'push'
90
+ && github.event.workflow_run.head_repository.full_name == github.repository }}
78
91
  steps:
79
92
  - name: Dispatch deploy-staging-run.yml (workflow_dispatch)
80
93
  env:
@@ -84,8 +97,54 @@ jobs:
84
97
  shell: bash
85
98
  run: |
86
99
  set -euo pipefail
87
- gh workflow run deploy-staging-run.yml \
88
- --repo "${REPO}" \
89
- --ref main \
90
- -f sha="${SHA}"
91
- echo "Dispatched staging deploy for ${SHA} (deploy runs on workflow_dispatch so environment: jobs execute)."
100
+
101
+ # L3: the dispatch is otherwise fire-and-forget — a transient GitHub
102
+ # API 5xx fails `gh workflow run`, and the only signal is a red run in
103
+ # a low-visibility dispatcher while that green commit never reaches
104
+ # staging. Retry with backoff, then VERIFY a run was actually created
105
+ # (a 2xx from the dispatch API is not proof a run materialized).
106
+ dispatched=false
107
+ for attempt in 1 2 3; do
108
+ if gh workflow run deploy-staging-run.yml \
109
+ --repo "${REPO}" \
110
+ --ref main \
111
+ -f sha="${SHA}"; then
112
+ dispatched=true
113
+ break
114
+ fi
115
+ echo "::warning::gh workflow run attempt ${attempt} failed; retrying after backoff."
116
+ sleep $((attempt * 5))
117
+ done
118
+
119
+ if [ "${dispatched}" != "true" ]; then
120
+ echo "::error::Failed to dispatch deploy-staging-run.yml after 3 attempts for ${SHA}." >&2
121
+ exit 1
122
+ fi
123
+
124
+ # Verify a runner workflow run was actually created. The dispatch API
125
+ # returns before the run row is queryable, so poll `gh run list` for a
126
+ # recent workflow_dispatch run of the runner workflow. Fail loudly if
127
+ # none appears — a silent non-creation is exactly the failure mode
128
+ # this check exists to surface.
129
+ created=false
130
+ for attempt in 1 2 3 4 5; do
131
+ count="$(gh run list \
132
+ --repo "${REPO}" \
133
+ --workflow deploy-staging-run.yml \
134
+ --event workflow_dispatch \
135
+ --limit 5 \
136
+ --json databaseId --jq 'length' 2>/dev/null || echo 0)"
137
+ if [ "${count:-0}" -gt 0 ]; then
138
+ created=true
139
+ break
140
+ fi
141
+ echo "Run not visible yet (attempt ${attempt}); waiting for the run row to appear."
142
+ sleep $((attempt * 3))
143
+ done
144
+
145
+ if [ "${created}" != "true" ]; then
146
+ echo "::error::Dispatched deploy-staging-run.yml for ${SHA} but no runner workflow run appeared via 'gh run list'." >&2
147
+ exit 1
148
+ fi
149
+
150
+ echo "Dispatched and verified staging deploy for ${SHA} (deploy runs on workflow_dispatch so environment: jobs execute)."