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.
- 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-workflow-gh-flags.mjs +172 -0
- package/scripts/check-workflow-gh-flags.test.mjs +86 -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/semgrep-requirements.txt +169 -0
- package/templates/workflows/deploy-staging-run.yml +44 -4
- package/templates/workflows/deploy-staging.yml +68 -9
|
@@ -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
|
}
|
|
@@ -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
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
48
|
-
assert.equal(extractSubdomain("
|
|
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,
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
+
});
|