mandrel-platform 0.24.0 → 0.26.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.
@@ -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
+ });