mandrel-platform 0.20.1 → 0.24.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 +1 -1
- package/scripts/check-ci-required-aggregator.test.mjs +227 -0
- package/scripts/check-coverage-threshold.test.mjs +56 -51
- package/scripts/check-destructive-migration.mjs +68 -6
- package/scripts/check-destructive-migration.test.mjs +146 -0
- package/scripts/check-runner-health.mjs +469 -0
- package/scripts/check-runner-health.test.mjs +389 -0
- package/scripts/deploy-boot-smoke.mjs +364 -0
- package/scripts/deploy-boot-smoke.test.mjs +381 -0
- package/scripts/deploy-worker-secrets.mjs +190 -0
- package/scripts/deploy-worker-secrets.test.mjs +136 -0
- package/scripts/platform-sync.test.mjs +36 -9
- package/scripts/runner-fleet-consumers.json +20 -0
- package/templates/runbooks/README.md +13 -0
- package/templates/runbooks/runner-fleet-health.md +150 -0
- package/templates/runbooks/runner-provisioning.md +187 -0
- package/templates/runner/.env.example +45 -0
- package/templates/runner/job-cleanup.sh +97 -0
- package/templates/workflows/deploy-staging-run.yml +77 -0
- package/templates/workflows/deploy-staging.yml +67 -62
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deploy-boot-smoke.mjs
|
|
4
|
+
*
|
|
5
|
+
* Boot-smoke probe for the shared `deploy-cloudflare.yml` workflow
|
|
6
|
+
* (Story #231). Extracted from the workflow's former ~140-line inline bash so
|
|
7
|
+
* the probe is unit-testable and reviewed as code, not as a YAML diff. The
|
|
8
|
+
* workflow sparse-checks this script out of dsj1984/mandrel-platform at
|
|
9
|
+
* `github.job_workflow_sha` — the exact commit the caller's
|
|
10
|
+
* `deploy-cloudflare.yml@<ref>` pin resolved to — so the script version
|
|
11
|
+
* always travels in lockstep with the workflow pin (same model as
|
|
12
|
+
* `uptime-apply.yml` → `apply-uptime-monitors.mjs`).
|
|
13
|
+
*
|
|
14
|
+
* What it does (mirrors the inline predecessor, with one deliberate fix):
|
|
15
|
+
*
|
|
16
|
+
* • Consumer-supplied `smoke-command` (SMOKE_COMMAND set): runs it once via
|
|
17
|
+
* `bash -c` with WORKERS (deployed csv) + SMOKE_BASE_URL exported. A
|
|
18
|
+
* non-zero exit fails the run and marks every deployed worker for
|
|
19
|
+
* rollback.
|
|
20
|
+
* • Built-in probe: requests each smoke path against each target with a
|
|
21
|
+
* 15s timeout and up to 3 retries (5s apart) on transient failures,
|
|
22
|
+
* failing on any non-200 final status.
|
|
23
|
+
* • Opt-in `verify-commit-sha` (VERIFY_COMMIT_SHA=true): parses the health
|
|
24
|
+
* response body as JSON and asserts its TOP-LEVEL `version` field equals
|
|
25
|
+
* the deployed commit SHA (EXPECTED_SHA). This replaces the former
|
|
26
|
+
* grep/sed extraction, which could match a `"version"` key nested
|
|
27
|
+
* anywhere in the body.
|
|
28
|
+
*
|
|
29
|
+
* The smoke_base_url duplication fix (Story #231): the inline predecessor
|
|
30
|
+
* probed `${SMOKE_BASE_URL}${path}` once **per worker**, so with a shared
|
|
31
|
+
* base URL and N workers every path was requested N times and a failure was
|
|
32
|
+
* misattributed to whichever worker's loop iteration hit it. With
|
|
33
|
+
* SMOKE_BASE_URL set the probe now requests each path exactly ONCE — and
|
|
34
|
+
* because a shared-host failure cannot be attributed to an individual
|
|
35
|
+
* worker, it explicitly marks ALL deployed workers for rollback.
|
|
36
|
+
*
|
|
37
|
+
* Rollback contract (unchanged): failed worker names are written (sorted,
|
|
38
|
+
* de-duplicated, one per line) to SMOKE_FAILED_FILE — overwriting any stale
|
|
39
|
+
* file from a previous run on a reused runner — and `smoke_failed=true` is
|
|
40
|
+
* appended to $GITHUB_ENV so the workflow's `Rollback failed workers` step
|
|
41
|
+
* fires. Exit code 1 on any smoke failure.
|
|
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.
|
|
47
|
+
*
|
|
48
|
+
* Environment contract (all read from process.env):
|
|
49
|
+
* DEPLOYED_WORKERS csv of deployed worker names (required)
|
|
50
|
+
* SMOKE_COMMAND consumer-supplied probe command (optional)
|
|
51
|
+
* SMOKE_BASE_URL shared base URL override (optional, no trailing /)
|
|
52
|
+
* SMOKE_PATHS csv of probe paths (default "/health")
|
|
53
|
+
* WORKERS_DEV_SUBDOMAIN explicit workers.dev slug (optional)
|
|
54
|
+
* VERIFY_COMMIT_SHA 'true' to assert the health JSON version field
|
|
55
|
+
* EXPECTED_SHA the SHA verify-commit-sha asserts (github.sha)
|
|
56
|
+
* SMOKE_FAILED_FILE rollback-list path (default: a freshly-created
|
|
57
|
+
* private temp dir via mkdtemp — never a predictable
|
|
58
|
+
* world-writable path; CI sets this explicitly)
|
|
59
|
+
* GITHUB_ENV GitHub Actions env file (smoke_failed=true flag)
|
|
60
|
+
*
|
|
61
|
+
* Exit codes:
|
|
62
|
+
* 0 — every probe passed.
|
|
63
|
+
* 1 — a probe failed (rollback list written), or the probe target could
|
|
64
|
+
* not be resolved (no subdomain derivable — no rollback list).
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import { writeFileSync, appendFileSync, mkdtempSync } from "node:fs";
|
|
68
|
+
import { tmpdir } from "node:os";
|
|
69
|
+
import { join } from "node:path";
|
|
70
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Pure helpers (unit-tested)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Split a csv into trimmed, non-empty entries. */
|
|
77
|
+
export function parseCsv(csv) {
|
|
78
|
+
return String(csv ?? "")
|
|
79
|
+
.split(",")
|
|
80
|
+
.map((s) => s.trim())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parse SMOKE_PATHS into normalized paths (leading slash enforced). */
|
|
85
|
+
export function parseSmokePaths(csv) {
|
|
86
|
+
return parseCsv(csv).map((p) => (p.startsWith("/") ? p : `/${p}`));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Extract the workers.dev account subdomain slug from `wrangler whoami`
|
|
91
|
+
* output (the first `<slug>.workers.dev` token). Returns null when absent.
|
|
92
|
+
*/
|
|
93
|
+
export function extractSubdomain(whoamiOutput) {
|
|
94
|
+
const m = String(whoamiOutput ?? "").match(/([A-Za-z0-9-]+)\.workers\.dev/);
|
|
95
|
+
return m ? m[1] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse a health response body and return its TOP-LEVEL `version` field as a
|
|
100
|
+
* string, or null when the body is not JSON, not an object, or the field is
|
|
101
|
+
* missing / not a non-empty string. Deliberately never matches a `version`
|
|
102
|
+
* key nested inside a sub-object (the grep-era false positive).
|
|
103
|
+
*/
|
|
104
|
+
export function parseVersionField(body) {
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(body);
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
112
|
+
const version = parsed.version;
|
|
113
|
+
return typeof version === "string" && version.length > 0 ? version : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the probe plan: one entry per URL to request, each carrying the
|
|
118
|
+
* worker names a failure of that URL attributes to the rollback list.
|
|
119
|
+
*
|
|
120
|
+
* • SMOKE_BASE_URL set → each path probed ONCE against the shared base;
|
|
121
|
+
* a failure attributes to ALL deployed workers
|
|
122
|
+
* (shared host — per-worker attribution is
|
|
123
|
+
* impossible, so roll back everything deployed).
|
|
124
|
+
* • workers.dev (default) → each path probed per worker against
|
|
125
|
+
* https://<worker>.<subdomain>.workers.dev; a
|
|
126
|
+
* failure attributes to that worker only.
|
|
127
|
+
*/
|
|
128
|
+
export function buildProbePlan({ workers, paths, smokeBaseUrl, subdomain }) {
|
|
129
|
+
if (smokeBaseUrl) {
|
|
130
|
+
const base = smokeBaseUrl.replace(/\/+$/, "");
|
|
131
|
+
return paths.map((p) => ({
|
|
132
|
+
url: `${base}${p}`,
|
|
133
|
+
label: `shared base (${workers.join(", ")})`,
|
|
134
|
+
attributedWorkers: [...workers],
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
const plan = [];
|
|
138
|
+
for (const worker of workers) {
|
|
139
|
+
for (const p of paths) {
|
|
140
|
+
plan.push({
|
|
141
|
+
url: `https://${worker}.${subdomain}.workers.dev${p}`,
|
|
142
|
+
label: worker,
|
|
143
|
+
attributedWorkers: [worker],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return plan;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** De-duplicate + sort a worker list for the rollback file (mirrors `sort -u`). */
|
|
151
|
+
export function uniqueSorted(names) {
|
|
152
|
+
return [...new Set(names)].sort();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// HTTP status codes curl's `--retry` treats as transient; the inline
|
|
156
|
+
// predecessor used `curl --retry 3 --retry-delay 5`.
|
|
157
|
+
const TRANSIENT_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Probe one URL: GET with a 15s timeout, retrying transient failures
|
|
161
|
+
* (network error or a TRANSIENT_STATUS response) up to `retries` times with
|
|
162
|
+
* `retryDelayMs` between attempts. Returns { status, body } for the final
|
|
163
|
+
* attempt; a network failure on the final attempt returns
|
|
164
|
+
* { status: 0, body: "" } (the inline predecessor's `curl || echo 000`).
|
|
165
|
+
*/
|
|
166
|
+
export async function probeUrl(url, { fetchImpl = fetch, retries = 3, retryDelayMs = 5000, timeoutMs = 15000, sleep = defaultSleep } = {}) {
|
|
167
|
+
let last = { status: 0, body: "" };
|
|
168
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
169
|
+
if (attempt > 0) await sleep(retryDelayMs);
|
|
170
|
+
try {
|
|
171
|
+
// redirect: "manual" — parity with the inline predecessor's plain curl
|
|
172
|
+
// (no -L): a 301/302 from a health endpoint is a non-200 smoke FAILURE,
|
|
173
|
+
// never silently followed to whatever the redirect target returns.
|
|
174
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), redirect: "manual" });
|
|
175
|
+
last = { status: res.status, body: await res.text() };
|
|
176
|
+
} catch {
|
|
177
|
+
last = { status: 0, body: "" };
|
|
178
|
+
continue; // network failure → transient, retry
|
|
179
|
+
}
|
|
180
|
+
if (!TRANSIENT_STATUS.has(last.status)) return last;
|
|
181
|
+
}
|
|
182
|
+
return last;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function defaultSleep(ms) {
|
|
186
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Probe execution (deps injectable for the test suite)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run the full smoke pass. Returns { exitCode, failedWorkers } and performs
|
|
195
|
+
* no process.exit of its own so the test suite can drive it directly.
|
|
196
|
+
*/
|
|
197
|
+
export async function runSmoke(env, deps = {}) {
|
|
198
|
+
const {
|
|
199
|
+
log = (line) => process.stdout.write(`${line}\n`),
|
|
200
|
+
probe = probeUrl,
|
|
201
|
+
runShell = defaultRunShell,
|
|
202
|
+
whoami = defaultWhoami,
|
|
203
|
+
} = deps;
|
|
204
|
+
|
|
205
|
+
const workers = parseCsv(env.DEPLOYED_WORKERS);
|
|
206
|
+
const smokeCommand = env.SMOKE_COMMAND ?? "";
|
|
207
|
+
const smokeBaseUrl = (env.SMOKE_BASE_URL ?? "").trim();
|
|
208
|
+
const verifySha = env.VERIFY_COMMIT_SHA === "true";
|
|
209
|
+
const expectedSha = env.EXPECTED_SHA ?? "";
|
|
210
|
+
|
|
211
|
+
// ----- Consumer-supplied smoke replaces the built-in probe -----
|
|
212
|
+
if (smokeCommand) {
|
|
213
|
+
log("::group::Running consumer smoke-command");
|
|
214
|
+
const code = runShell(smokeCommand, {
|
|
215
|
+
WORKERS: env.DEPLOYED_WORKERS ?? "",
|
|
216
|
+
SMOKE_BASE_URL: smokeBaseUrl,
|
|
217
|
+
});
|
|
218
|
+
if (code !== 0) {
|
|
219
|
+
log("::error::Consumer smoke-command FAILED. Triggering rollback.");
|
|
220
|
+
log("::endgroup::");
|
|
221
|
+
return { exitCode: 1, failedWorkers: uniqueSorted(workers) };
|
|
222
|
+
}
|
|
223
|
+
log("✅ consumer smoke-command passed");
|
|
224
|
+
log("::endgroup::");
|
|
225
|
+
return { exitCode: 0, failedWorkers: [] };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ----- Built-in probe -----
|
|
229
|
+
// Resolve the workers.dev subdomain slug. NEVER the account ID (a UUID) —
|
|
230
|
+
// workers.dev subdomains are keyed by the account name/slug. Prefer the
|
|
231
|
+
// explicit input, else derive from `wrangler whoami`.
|
|
232
|
+
let subdomain = (env.WORKERS_DEV_SUBDOMAIN ?? "").trim();
|
|
233
|
+
if (!subdomain && !smokeBaseUrl) {
|
|
234
|
+
log("::group::Deriving workers.dev subdomain from wrangler whoami");
|
|
235
|
+
subdomain = extractSubdomain(whoami());
|
|
236
|
+
if (!subdomain) {
|
|
237
|
+
log(
|
|
238
|
+
"::error::Could not derive workers.dev subdomain from 'wrangler whoami'. " +
|
|
239
|
+
"Pass workers_dev_subdomain or smoke_base_url explicitly."
|
|
240
|
+
);
|
|
241
|
+
log("::endgroup::");
|
|
242
|
+
// No rollback list: the target could not be resolved, so nothing was
|
|
243
|
+
// probed (mirrors the inline predecessor, which exited before any
|
|
244
|
+
// failed-worker was recorded).
|
|
245
|
+
return { exitCode: 1, failedWorkers: [] };
|
|
246
|
+
}
|
|
247
|
+
log(`::notice::Derived workers.dev subdomain: ${subdomain}`);
|
|
248
|
+
log("::endgroup::");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const paths = parseSmokePaths(env.SMOKE_PATHS ?? "/health");
|
|
252
|
+
const plan = buildProbePlan({ workers, paths, smokeBaseUrl, subdomain });
|
|
253
|
+
const failed = [];
|
|
254
|
+
|
|
255
|
+
for (const entry of plan) {
|
|
256
|
+
log(`::group::Smoke-testing ${entry.label} → ${entry.url}`);
|
|
257
|
+
const { status, body } = await probe(entry.url);
|
|
258
|
+
log(`HTTP status: ${status}`);
|
|
259
|
+
|
|
260
|
+
if (status !== 200) {
|
|
261
|
+
log(
|
|
262
|
+
`::error::Smoke check FAILED for ${entry.label} at ${entry.url} (HTTP ${status}). Triggering rollback.`
|
|
263
|
+
);
|
|
264
|
+
failed.push(...entry.attributedWorkers);
|
|
265
|
+
log("::endgroup::");
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
log(`✅ ${entry.label} smoke check passed at ${entry.url} (HTTP 200)`);
|
|
269
|
+
|
|
270
|
+
// ----- verify-commit-sha (Story #176, opt-in) -----
|
|
271
|
+
// Assert the deployed worker's health response reports the SHA this run
|
|
272
|
+
// deployed. Contract: docs/runbooks/post-deploy-smoke.md
|
|
273
|
+
// #3-health-endpoint-contract — a JSON body whose top-level "version"
|
|
274
|
+
// field is env.GIT_COMMIT_SHA. A mismatch or unparsable field fails the
|
|
275
|
+
// smoke check and triggers the same auto-rollback as an HTTP failure.
|
|
276
|
+
if (verifySha) {
|
|
277
|
+
const deployedSha = parseVersionField(body);
|
|
278
|
+
if (deployedSha === null) {
|
|
279
|
+
log(
|
|
280
|
+
`::error::verify-commit-sha FAILED for ${entry.label} at ${entry.url} — response has no parsable top-level "version" field. Triggering rollback.`
|
|
281
|
+
);
|
|
282
|
+
failed.push(...entry.attributedWorkers);
|
|
283
|
+
} else if (deployedSha !== expectedSha) {
|
|
284
|
+
log(
|
|
285
|
+
`::error::verify-commit-sha FAILED for ${entry.label} — deployed SHA '${deployedSha}' does not match expected SHA '${expectedSha}'. Triggering rollback.`
|
|
286
|
+
);
|
|
287
|
+
failed.push(...entry.attributedWorkers);
|
|
288
|
+
} else {
|
|
289
|
+
log(`✅ ${entry.label} reports expected commit SHA: ${deployedSha}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
log("::endgroup::");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (failed.length > 0) {
|
|
296
|
+
return { exitCode: 1, failedWorkers: uniqueSorted(failed) };
|
|
297
|
+
}
|
|
298
|
+
return { exitCode: 0, failedWorkers: [] };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function defaultRunShell(command, extraEnv) {
|
|
302
|
+
const res = spawnSync("bash", ["-c", command], {
|
|
303
|
+
stdio: "inherit",
|
|
304
|
+
env: { ...process.env, ...extraEnv },
|
|
305
|
+
});
|
|
306
|
+
return res.status ?? 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
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.
|
|
313
|
+
try {
|
|
314
|
+
return execFileSync("pnpm", ["exec", "wrangler", "whoami"], {
|
|
315
|
+
encoding: "utf8",
|
|
316
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
317
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
318
|
+
});
|
|
319
|
+
} catch {
|
|
320
|
+
return "";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// CLI entry
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolve the rollback-list path. When SMOKE_FAILED_FILE is set (CI always
|
|
330
|
+
* sets it), it wins verbatim. Otherwise fall back to a file inside a
|
|
331
|
+
* freshly-created private temp directory (`mkdtemp` — mode 0700, unique
|
|
332
|
+
* per-invocation name) rather than the fixed, predictable, world-writable
|
|
333
|
+
* `/tmp/smoke-failed-workers.txt`. A predictable path in a shared temp dir is
|
|
334
|
+
* pre-creatable / symlink-swappable by a co-tenant on a reused runner
|
|
335
|
+
* (CWE-377); mkdtemp closes that seam by owning a private directory no other
|
|
336
|
+
* process can pre-stage.
|
|
337
|
+
*/
|
|
338
|
+
export function resolveFailedFile(env, mkdtempImpl = mkdtempSync) {
|
|
339
|
+
const explicit = (env.SMOKE_FAILED_FILE ?? "").trim();
|
|
340
|
+
if (explicit) return explicit;
|
|
341
|
+
return join(mkdtempImpl(join(tmpdir(), "deploy-boot-smoke-")), "smoke-failed-workers.txt");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function main() {
|
|
345
|
+
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
|
+
}
|
|
356
|
+
}
|
|
357
|
+
process.exit(exitCode);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const invokedDirectly =
|
|
361
|
+
process.argv[1] && process.argv[1].endsWith("deploy-boot-smoke.mjs");
|
|
362
|
+
if (invokedDirectly) {
|
|
363
|
+
await main();
|
|
364
|
+
}
|