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.
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-runner-health.test.mjs — node:test suite for the scheduled
4
+ * runner-fleet health monitor (Story #258).
5
+ *
6
+ * The checker exposes pure helpers plus an injectable `runGh` seam, so the
7
+ * whole pipeline is exercised offline with canned GitHub responses — no
8
+ * network, no `gh` auth.
9
+ *
10
+ * Run: node scripts/check-runner-health.test.mjs (or `node --test scripts/`)
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { test } from "node:test";
18
+
19
+ import {
20
+ buildReport,
21
+ classifyRunners,
22
+ fetchQueuedRuns,
23
+ fetchRunners,
24
+ hasUnhealthy,
25
+ isRepoHealthy,
26
+ isStaleQueuedRun,
27
+ parseArgv,
28
+ renderReport,
29
+ runCli,
30
+ runnerMatchesLabels,
31
+ } from "./check-runner-health.mjs";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // parseArgv
35
+ // ---------------------------------------------------------------------------
36
+
37
+ test("parseArgv defaults to the fleet consumer config", () => {
38
+ const opts = parseArgv([]);
39
+ assert.equal(opts.config, "scripts/runner-fleet-consumers.json");
40
+ assert.equal(opts.json, false);
41
+ });
42
+
43
+ test("parseArgv parses --config and --json", () => {
44
+ const opts = parseArgv(["--config", "custom.json", "--json"]);
45
+ assert.equal(opts.config, "custom.json");
46
+ assert.equal(opts.json, true);
47
+ });
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // runnerMatchesLabels
51
+ // ---------------------------------------------------------------------------
52
+
53
+ test("runnerMatchesLabels is case-insensitive and order-independent", () => {
54
+ assert.equal(
55
+ runnerMatchesLabels(["Self-Hosted", "macOS", "ARM64", "domio-runner"], [
56
+ "self-hosted",
57
+ "arm64",
58
+ ]),
59
+ true,
60
+ );
61
+ });
62
+
63
+ test("runnerMatchesLabels fails when an expected label is missing", () => {
64
+ assert.equal(
65
+ runnerMatchesLabels(["self-hosted", "macOS"], ["self-hosted", "ARM64"]),
66
+ false,
67
+ );
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // classifyRunners
72
+ // ---------------------------------------------------------------------------
73
+
74
+ const EXPECTED = { expectedCount: 3, labels: ["self-hosted", "macOS", "ARM64", "domio-runner"] };
75
+
76
+ test("classifyRunners reports a fully healthy fleet", () => {
77
+ const runners = [1, 2, 3].map((n) => ({
78
+ id: n,
79
+ name: `domio-runner-${n}`,
80
+ status: "online",
81
+ labels: [{ name: "self-hosted" }, { name: "macOS" }, { name: "ARM64" }, { name: "domio-runner" }],
82
+ }));
83
+ const v = classifyRunners(runners, EXPECTED);
84
+ assert.equal(v.total, 3);
85
+ assert.equal(v.online, 3);
86
+ assert.equal(v.matchingOnline, 3);
87
+ assert.equal(v.shortfall, 0);
88
+ assert.equal(v.hasShortfall, false);
89
+ assert.equal(v.hasOffline, false);
90
+ });
91
+
92
+ test("classifyRunners flags an offline runner", () => {
93
+ const runners = [
94
+ { id: 1, name: "domio-runner-1", status: "online", labels: [{ name: "self-hosted" }, { name: "macOS" }, { name: "ARM64" }, { name: "domio-runner" }] },
95
+ { id: 2, name: "domio-runner-2", status: "offline", labels: [{ name: "self-hosted" }, { name: "macOS" }, { name: "ARM64" }, { name: "domio-runner" }] },
96
+ ];
97
+ const v = classifyRunners(runners, EXPECTED);
98
+ assert.equal(v.hasOffline, true);
99
+ assert.equal(v.offline.length, 1);
100
+ assert.equal(v.offline[0].name, "domio-runner-2");
101
+ assert.equal(v.matchingOnline, 1);
102
+ assert.equal(v.hasShortfall, true);
103
+ assert.equal(v.shortfall, 2);
104
+ });
105
+
106
+ test("classifyRunners flags a count shortfall even with all-online but wrong labels", () => {
107
+ const runners = [
108
+ { id: 1, name: "other-runner", status: "online", labels: [{ name: "self-hosted" }, { name: "linux" }] },
109
+ ];
110
+ const v = classifyRunners(runners, EXPECTED);
111
+ assert.equal(v.hasOffline, false);
112
+ assert.equal(v.matchingOnline, 0);
113
+ assert.equal(v.hasShortfall, true);
114
+ assert.equal(v.shortfall, 3);
115
+ });
116
+
117
+ test("classifyRunners handles an empty runner list", () => {
118
+ const v = classifyRunners([], EXPECTED);
119
+ assert.equal(v.total, 0);
120
+ assert.equal(v.hasShortfall, true);
121
+ assert.equal(v.shortfall, 3);
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // isStaleQueuedRun
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const NOW = Date.parse("2026-07-03T12:00:00Z");
129
+
130
+ test("isStaleQueuedRun flags an old queued run with no matching online runner", () => {
131
+ const run = {
132
+ status: "queued",
133
+ created_at: "2026-07-03T11:00:00Z", // 60 min ago
134
+ labels: ["self-hosted", "macOS", "ARM64", "domio-runner"],
135
+ };
136
+ assert.equal(isStaleQueuedRun(run, [], 20, NOW), true);
137
+ });
138
+
139
+ test("isStaleQueuedRun does not flag a recently queued run", () => {
140
+ const run = {
141
+ status: "queued",
142
+ created_at: "2026-07-03T11:55:00Z", // 5 min ago
143
+ labels: ["self-hosted", "macOS", "ARM64", "domio-runner"],
144
+ };
145
+ assert.equal(isStaleQueuedRun(run, [], 20, NOW), false);
146
+ });
147
+
148
+ test("isStaleQueuedRun does not flag when a matching online runner exists", () => {
149
+ const run = {
150
+ status: "queued",
151
+ created_at: "2026-07-03T11:00:00Z",
152
+ labels: ["self-hosted", "macOS", "ARM64", "domio-runner"],
153
+ };
154
+ const onlineLabelSets = [["self-hosted", "macOS", "ARM64", "domio-runner"]];
155
+ assert.equal(isStaleQueuedRun(run, onlineLabelSets, 20, NOW), false);
156
+ });
157
+
158
+ test("isStaleQueuedRun ignores non-queued/waiting runs", () => {
159
+ const run = { status: "completed", created_at: "2026-07-03T09:00:00Z" };
160
+ assert.equal(isStaleQueuedRun(run, [], 20, NOW), false);
161
+ });
162
+
163
+ test("isStaleQueuedRun does not flag when no label info is available", () => {
164
+ const run = { status: "queued", created_at: "2026-07-03T09:00:00Z", labels: [] };
165
+ assert.equal(isStaleQueuedRun(run, [], 20, NOW), false);
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // isRepoHealthy
170
+ // ---------------------------------------------------------------------------
171
+
172
+ test("isRepoHealthy is true only with no offline/shortfall/stale", () => {
173
+ const healthyVerdict = { hasOffline: false, hasShortfall: false };
174
+ assert.equal(isRepoHealthy(healthyVerdict, []), true);
175
+ assert.equal(isRepoHealthy({ ...healthyVerdict, hasOffline: true }, []), false);
176
+ assert.equal(isRepoHealthy({ ...healthyVerdict, hasShortfall: true }, []), false);
177
+ assert.equal(isRepoHealthy(healthyVerdict, [{ id: 1 }]), false);
178
+ });
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // renderReport
182
+ // ---------------------------------------------------------------------------
183
+
184
+ test("renderReport renders a healthy fleet with no Degraded section", () => {
185
+ const report = {
186
+ results: [
187
+ {
188
+ name: "domio",
189
+ repo: "dsj1984/domio",
190
+ verdict: { matchingOnline: 3, shortfall: 0, offline: [], hasOffline: false, hasShortfall: false },
191
+ staleRuns: [],
192
+ healthy: true,
193
+ },
194
+ ],
195
+ };
196
+ const text = renderReport(report);
197
+ assert.match(text, /✅ healthy/);
198
+ assert.match(text, /✅ Fleet healthy/);
199
+ assert.doesNotMatch(text, /### Degraded/);
200
+ });
201
+
202
+ test("renderReport surfaces offline / shortfall / stale-run detail lines", () => {
203
+ const report = {
204
+ results: [
205
+ {
206
+ name: "domio",
207
+ repo: "dsj1984/domio",
208
+ verdict: {
209
+ matchingOnline: 1,
210
+ shortfall: 2,
211
+ offline: [{ id: 2, name: "domio-runner-2", status: "offline" }],
212
+ hasOffline: true,
213
+ hasShortfall: true,
214
+ },
215
+ staleRuns: [{ id: 42, html_url: "https://github.com/dsj1984/domio/actions/runs/42", created_at: "x" }],
216
+ healthy: false,
217
+ },
218
+ ],
219
+ };
220
+ const text = renderReport(report);
221
+ assert.match(text, /❌ degraded/);
222
+ assert.match(text, /OFFLINE runner\(s\)/);
223
+ assert.match(text, /SHORTFALL/);
224
+ assert.match(text, /STALE QUEUED RUN/);
225
+ assert.match(text, /### Degraded/);
226
+ assert.match(text, /templates\/runbooks\/runner-fleet-health\.md/);
227
+ });
228
+
229
+ test("renderReport surfaces a fetch error row", () => {
230
+ const report = { results: [{ name: "domio", repo: "dsj1984/domio", error: "boom" }] };
231
+ const text = renderReport(report);
232
+ assert.match(text, /⚠️ error/);
233
+ assert.match(text, /error — boom/);
234
+ });
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // fetchRunners / fetchQueuedRuns (injectable runGh)
238
+ // ---------------------------------------------------------------------------
239
+
240
+ test("fetchRunners returns the runners array from the API response", () => {
241
+ const runGh = () =>
242
+ JSON.stringify({ total_count: 1, runners: [{ id: 1, name: "r1", status: "online", labels: [] }] });
243
+ const runners = fetchRunners("dsj1984/domio", runGh);
244
+ assert.equal(runners.length, 1);
245
+ assert.equal(runners[0].name, "r1");
246
+ });
247
+
248
+ test("fetchRunners returns [] on a 404", () => {
249
+ const runGh = () => {
250
+ const err = new Error("gh: Not Found (HTTP 404)");
251
+ throw err;
252
+ };
253
+ assert.deepEqual(fetchRunners("dsj1984/domio", runGh), []);
254
+ });
255
+
256
+ test("fetchRunners propagates a non-404 error", () => {
257
+ const runGh = () => {
258
+ throw new Error("gh: Forbidden (HTTP 403)");
259
+ };
260
+ assert.throws(() => fetchRunners("dsj1984/domio", runGh));
261
+ });
262
+
263
+ test("fetchQueuedRuns merges queued and waiting runs", () => {
264
+ const runGh = (args) => {
265
+ const path = args[1];
266
+ if (path.includes("status=queued")) {
267
+ return JSON.stringify({ workflow_runs: [{ id: 1, status: "queued", created_at: "x" }] });
268
+ }
269
+ if (path.includes("status=waiting")) {
270
+ return JSON.stringify({ workflow_runs: [{ id: 2, status: "waiting", created_at: "y" }] });
271
+ }
272
+ throw new Error(`unexpected path ${path}`);
273
+ };
274
+ const runs = fetchQueuedRuns("dsj1984/domio", runGh);
275
+ assert.equal(runs.length, 2);
276
+ assert.deepEqual(runs.map((r) => r.id).sort(), [1, 2]);
277
+ });
278
+
279
+ test("fetchQueuedRuns returns [] on a 404", () => {
280
+ const runGh = () => {
281
+ throw new Error("gh: Not Found (HTTP 404)");
282
+ };
283
+ assert.deepEqual(fetchQueuedRuns("dsj1984/domio", runGh), []);
284
+ });
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // buildReport / hasUnhealthy
288
+ // ---------------------------------------------------------------------------
289
+
290
+ const CONFIG = {
291
+ defaultStaleQueuedMinutes: 20,
292
+ repos: [
293
+ { name: "domio", repo: "dsj1984/domio", expectedCount: 2, labels: ["self-hosted", "domio-runner"] },
294
+ ],
295
+ };
296
+
297
+ test("buildReport marks a repo healthy when runners are all online and matching", () => {
298
+ const runGh = (args) => {
299
+ const path = args[1];
300
+ if (path.includes("actions/runners")) {
301
+ return JSON.stringify({
302
+ runners: [
303
+ { id: 1, name: "r1", status: "online", labels: [{ name: "self-hosted" }, { name: "domio-runner" }] },
304
+ { id: 2, name: "r2", status: "online", labels: [{ name: "self-hosted" }, { name: "domio-runner" }] },
305
+ ],
306
+ });
307
+ }
308
+ if (path.includes("actions/runs")) {
309
+ return JSON.stringify({ workflow_runs: [] });
310
+ }
311
+ throw new Error(`unexpected ${path}`);
312
+ };
313
+ const report = buildReport(CONFIG, runGh, NOW);
314
+ assert.equal(report.results[0].healthy, true);
315
+ assert.equal(hasUnhealthy(report), false);
316
+ });
317
+
318
+ test("buildReport marks a repo unhealthy and records a fetch error as unhealthy", () => {
319
+ const runGh = (args) => {
320
+ const path = args[1];
321
+ if (path.includes("actions/runners")) throw new Error("gh: Service Unavailable (HTTP 503)");
322
+ return "{}";
323
+ };
324
+ const report = buildReport(CONFIG, runGh, NOW);
325
+ assert.equal(report.results[0].error !== undefined, true);
326
+ assert.equal(hasUnhealthy(report), true);
327
+ });
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // runCli (end-to-end against a temp config + injected runGh)
331
+ // ---------------------------------------------------------------------------
332
+
333
+ test("runCli exits 0 for a healthy fleet and 1 for a degraded one", () => {
334
+ const dir = mkdtempSync(join(tmpdir(), "runner-health-cli-"));
335
+ try {
336
+ const configPath = join(dir, "runner-fleet-consumers.json");
337
+ writeFileSync(
338
+ configPath,
339
+ JSON.stringify({
340
+ defaultStaleQueuedMinutes: 20,
341
+ repos: [
342
+ { name: "domio", repo: "dsj1984/domio", expectedCount: 2, labels: ["self-hosted", "domio-runner"] },
343
+ ],
344
+ }),
345
+ );
346
+ const stdout = { buf: "", write(s) { this.buf += s; } };
347
+ const stderr = { buf: "", write(s) { this.buf += s; } };
348
+ const healthyRunGh = (args) => {
349
+ const path = args[1];
350
+ if (path.includes("actions/runners")) {
351
+ return JSON.stringify({
352
+ runners: [
353
+ { id: 1, name: "r1", status: "online", labels: [{ name: "self-hosted" }, { name: "domio-runner" }] },
354
+ { id: 2, name: "r2", status: "online", labels: [{ name: "self-hosted" }, { name: "domio-runner" }] },
355
+ ],
356
+ });
357
+ }
358
+ if (path.includes("actions/runs")) return JSON.stringify({ workflow_runs: [] });
359
+ throw new Error(`unexpected ${path}`);
360
+ };
361
+ const code = runCli({
362
+ argv: ["--config", configPath],
363
+ cwd: process.cwd(),
364
+ stdout,
365
+ stderr,
366
+ runGh: healthyRunGh,
367
+ summaryPath: undefined,
368
+ nowMs: NOW,
369
+ });
370
+ assert.equal(code, 0);
371
+ assert.match(stdout.buf, /Fleet healthy|Runner-fleet health dashboard/);
372
+ } finally {
373
+ rmSync(dir, { recursive: true, force: true });
374
+ }
375
+ });
376
+
377
+ test("runCli returns 1 on a bad config path", () => {
378
+ const stdout = { buf: "", write(s) { this.buf += s; } };
379
+ const stderr = { buf: "", write(s) { this.buf += s; } };
380
+ const code = runCli({
381
+ argv: ["--config", "scripts/does-not-exist.json"],
382
+ cwd: process.cwd(),
383
+ stdout,
384
+ stderr,
385
+ runGh: () => "{}",
386
+ });
387
+ assert.equal(code, 1);
388
+ assert.match(stderr.buf, /failed to read config/);
389
+ });
@@ -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
  }