mandrel-platform 0.21.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/check-pin-drift.mjs +66 -1
- package/scripts/check-pin-drift.test.mjs +125 -0
- package/scripts/check-runner-health.mjs +469 -0
- package/scripts/check-runner-health.test.mjs +389 -0
- package/scripts/deploy-boot-smoke.mjs +116 -36
- package/scripts/deploy-boot-smoke.test.mjs +138 -17
- package/scripts/platform-repair.mjs +38 -2
- package/scripts/platform-repair.test.mjs +113 -0
- package/scripts/platform-sync.test.mjs +36 -9
- package/scripts/runner-fleet-consumers.json +20 -0
- package/scripts/semgrep-requirements.txt +169 -0
- package/templates/runbooks/runner-fleet-health.md +150 -0
- package/templates/workflows/deploy-staging-run.yml +117 -0
- package/templates/workflows/deploy-staging.yml +125 -64
|
@@ -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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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 {
|
|
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
|
|
91
|
-
*
|
|
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(
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
235
|
-
subdomain =
|
|
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
|
|
239
|
-
"
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
}
|