mandrel-platform 0.20.0 → 0.21.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,381 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ parseCsv,
6
+ parseSmokePaths,
7
+ extractSubdomain,
8
+ parseVersionField,
9
+ buildProbePlan,
10
+ uniqueSorted,
11
+ probeUrl,
12
+ runSmoke,
13
+ resolveFailedFile,
14
+ } from "./deploy-boot-smoke.mjs";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // parseCsv / parseSmokePaths
18
+ // ---------------------------------------------------------------------------
19
+
20
+ test("parseCsv trims entries and drops empties", () => {
21
+ assert.deepEqual(parseCsv(" api , worker-cron ,, "), ["api", "worker-cron"]);
22
+ assert.deepEqual(parseCsv(""), []);
23
+ assert.deepEqual(parseCsv(undefined), []);
24
+ });
25
+
26
+ test("parseSmokePaths enforces a leading slash", () => {
27
+ assert.deepEqual(parseSmokePaths("/,/portal,api/health"), ["/", "/portal", "/api/health"]);
28
+ assert.deepEqual(parseSmokePaths("/health"), ["/health"]);
29
+ });
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // extractSubdomain
33
+ // ---------------------------------------------------------------------------
34
+
35
+ test("extractSubdomain finds the first workers.dev slug in whoami output", () => {
36
+ const whoami = [
37
+ "Getting User settings...",
38
+ "👋 You are logged in!",
39
+ "┌──────────────┬──────────────────────────┐",
40
+ "│ Account Name │ dsj1984's Account │",
41
+ "│ Subdomain │ dsj1984.workers.dev │",
42
+ "└──────────────┴──────────────────────────┘",
43
+ ].join("\n");
44
+ assert.equal(extractSubdomain(whoami), "dsj1984");
45
+ });
46
+
47
+ test("extractSubdomain returns null when no slug is present", () => {
48
+ assert.equal(extractSubdomain("no subdomain here"), null);
49
+ assert.equal(extractSubdomain(""), null);
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // parseVersionField — the jq/JSON.parse replacement for grep-for-"version"
54
+ // ---------------------------------------------------------------------------
55
+
56
+ test("parseVersionField reads the top-level version string", () => {
57
+ assert.equal(parseVersionField('{"status":"ok","version":"abc123"}'), "abc123");
58
+ });
59
+
60
+ test("parseVersionField rejects non-JSON, non-object, and missing/empty fields", () => {
61
+ assert.equal(parseVersionField("<html>hi</html>"), null);
62
+ assert.equal(parseVersionField('"version"'), null);
63
+ assert.equal(parseVersionField('["version"]'), null);
64
+ assert.equal(parseVersionField("null"), null);
65
+ assert.equal(parseVersionField('{"status":"ok"}'), null);
66
+ assert.equal(parseVersionField('{"version":""}'), null);
67
+ assert.equal(parseVersionField('{"version":42}'), null);
68
+ });
69
+
70
+ test("parseVersionField never matches a nested version key (grep-era false positive)", () => {
71
+ // The old grep/sed extraction would have matched deps.version here.
72
+ assert.equal(parseVersionField('{"status":"ok","deps":{"version":"9.9.9"}}'), null);
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // buildProbePlan — the smoke_base_url duplication fix
77
+ // ---------------------------------------------------------------------------
78
+
79
+ test("buildProbePlan with a shared base URL probes each path exactly once, attributing all workers", () => {
80
+ const plan = buildProbePlan({
81
+ workers: ["api", "worker-cron"],
82
+ paths: ["/", "/health"],
83
+ smokeBaseUrl: "https://godomio.com/",
84
+ subdomain: "",
85
+ });
86
+ assert.equal(plan.length, 2); // one per PATH, not workers × paths
87
+ assert.deepEqual(
88
+ plan.map((e) => e.url),
89
+ ["https://godomio.com/", "https://godomio.com/health"]
90
+ );
91
+ for (const entry of plan) {
92
+ assert.deepEqual(entry.attributedWorkers, ["api", "worker-cron"]);
93
+ }
94
+ });
95
+
96
+ test("buildProbePlan without a base URL probes workers × paths on workers.dev, attributing per worker", () => {
97
+ const plan = buildProbePlan({
98
+ workers: ["api", "worker-cron"],
99
+ paths: ["/health"],
100
+ smokeBaseUrl: "",
101
+ subdomain: "dsj1984",
102
+ });
103
+ assert.deepEqual(
104
+ plan.map((e) => e.url),
105
+ [
106
+ "https://api.dsj1984.workers.dev/health",
107
+ "https://worker-cron.dsj1984.workers.dev/health",
108
+ ]
109
+ );
110
+ assert.deepEqual(plan[0].attributedWorkers, ["api"]);
111
+ assert.deepEqual(plan[1].attributedWorkers, ["worker-cron"]);
112
+ });
113
+
114
+ test("uniqueSorted de-duplicates and sorts (mirrors sort -u)", () => {
115
+ assert.deepEqual(uniqueSorted(["b", "a", "b"]), ["a", "b"]);
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // probeUrl — retry semantics
120
+ // ---------------------------------------------------------------------------
121
+
122
+ test("probeUrl returns the first non-transient response without retrying", async () => {
123
+ let calls = 0;
124
+ const fetchImpl = async () => {
125
+ calls++;
126
+ return { status: 200, text: async () => "ok" };
127
+ };
128
+ const res = await probeUrl("https://x.example/health", { fetchImpl, sleep: async () => {} });
129
+ assert.equal(res.status, 200);
130
+ assert.equal(res.body, "ok");
131
+ assert.equal(calls, 1);
132
+ });
133
+
134
+ test("probeUrl never follows redirects (curl-without--L parity: a 302 is a failure)", async () => {
135
+ let seenOptions;
136
+ const fetchImpl = async (url, options) => {
137
+ seenOptions = options;
138
+ return { status: 302, text: async () => "" };
139
+ };
140
+ const res = await probeUrl("https://x.example/health", { fetchImpl, sleep: async () => {} });
141
+ assert.equal(seenOptions.redirect, "manual");
142
+ assert.equal(res.status, 302); // surfaces as non-200 → smoke failure
143
+ });
144
+
145
+ test("probeUrl does not retry a non-200 non-transient status (e.g. 404)", async () => {
146
+ let calls = 0;
147
+ const fetchImpl = async () => {
148
+ calls++;
149
+ return { status: 404, text: async () => "nope" };
150
+ };
151
+ const res = await probeUrl("https://x.example/health", { fetchImpl, sleep: async () => {} });
152
+ assert.equal(res.status, 404);
153
+ assert.equal(calls, 1);
154
+ });
155
+
156
+ test("probeUrl retries transient statuses and network errors up to the retry budget", async () => {
157
+ let calls = 0;
158
+ const fetchImpl = async () => {
159
+ calls++;
160
+ if (calls === 1) throw new Error("ECONNRESET");
161
+ if (calls === 2) return { status: 503, text: async () => "" };
162
+ return { status: 200, text: async () => "ok" };
163
+ };
164
+ const res = await probeUrl("https://x.example/health", { fetchImpl, sleep: async () => {} });
165
+ assert.equal(res.status, 200);
166
+ assert.equal(calls, 3);
167
+ });
168
+
169
+ test("probeUrl reports status 0 when every attempt fails at the network layer", async () => {
170
+ let calls = 0;
171
+ const fetchImpl = async () => {
172
+ calls++;
173
+ throw new Error("timeout");
174
+ };
175
+ const res = await probeUrl("https://x.example/health", { fetchImpl, retries: 2, sleep: async () => {} });
176
+ assert.equal(res.status, 0);
177
+ assert.equal(calls, 3); // initial + 2 retries
178
+ });
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // runSmoke — end-to-end orchestration with injected deps
182
+ // ---------------------------------------------------------------------------
183
+
184
+ function collectLogs() {
185
+ const lines = [];
186
+ return { lines, log: (line) => lines.push(line) };
187
+ }
188
+
189
+ test("runSmoke passes when every probe returns 200", async () => {
190
+ const { log } = collectLogs();
191
+ const result = await runSmoke(
192
+ { DEPLOYED_WORKERS: "api,worker-cron", SMOKE_PATHS: "/health", WORKERS_DEV_SUBDOMAIN: "dsj1984" },
193
+ { log, probe: async () => ({ status: 200, body: "{}" }) }
194
+ );
195
+ assert.equal(result.exitCode, 0);
196
+ assert.deepEqual(result.failedWorkers, []);
197
+ });
198
+
199
+ test("runSmoke attributes a workers.dev probe failure to that worker only", async () => {
200
+ const { log } = collectLogs();
201
+ const result = await runSmoke(
202
+ { DEPLOYED_WORKERS: "api,worker-cron", SMOKE_PATHS: "/health", WORKERS_DEV_SUBDOMAIN: "dsj1984" },
203
+ {
204
+ log,
205
+ probe: async (url) =>
206
+ url.startsWith("https://api.") ? { status: 500, body: "" } : { status: 200, body: "{}" },
207
+ }
208
+ );
209
+ assert.equal(result.exitCode, 1);
210
+ assert.deepEqual(result.failedWorkers, ["api"]);
211
+ });
212
+
213
+ test("runSmoke with a shared base URL probes each path once and rolls back ALL deployed workers on failure", async () => {
214
+ const { log } = collectLogs();
215
+ const probed = [];
216
+ const result = await runSmoke(
217
+ {
218
+ DEPLOYED_WORKERS: "api,worker-cron",
219
+ SMOKE_PATHS: "/,/health",
220
+ SMOKE_BASE_URL: "https://godomio.com",
221
+ },
222
+ {
223
+ log,
224
+ probe: async (url) => {
225
+ probed.push(url);
226
+ return url.endsWith("/health") ? { status: 502, body: "" } : { status: 200, body: "{}" };
227
+ },
228
+ }
229
+ );
230
+ // Each path requested exactly once — not once per worker.
231
+ assert.deepEqual(probed, ["https://godomio.com/", "https://godomio.com/health"]);
232
+ assert.equal(result.exitCode, 1);
233
+ // Shared-host failure → explicit rollback of everything deployed.
234
+ assert.deepEqual(result.failedWorkers, ["api", "worker-cron"]);
235
+ });
236
+
237
+ test("runSmoke verify-commit-sha passes on a matching top-level version", async () => {
238
+ const { log } = collectLogs();
239
+ const result = await runSmoke(
240
+ {
241
+ DEPLOYED_WORKERS: "api",
242
+ SMOKE_PATHS: "/health",
243
+ WORKERS_DEV_SUBDOMAIN: "dsj1984",
244
+ VERIFY_COMMIT_SHA: "true",
245
+ EXPECTED_SHA: "deadbeef",
246
+ },
247
+ { log, probe: async () => ({ status: 200, body: '{"status":"ok","version":"deadbeef"}' }) }
248
+ );
249
+ assert.equal(result.exitCode, 0);
250
+ });
251
+
252
+ test("runSmoke verify-commit-sha fails on mismatch and on unparsable version", async () => {
253
+ const { log } = collectLogs();
254
+ const mismatch = await runSmoke(
255
+ {
256
+ DEPLOYED_WORKERS: "api",
257
+ SMOKE_PATHS: "/health",
258
+ WORKERS_DEV_SUBDOMAIN: "dsj1984",
259
+ VERIFY_COMMIT_SHA: "true",
260
+ EXPECTED_SHA: "deadbeef",
261
+ },
262
+ { log, probe: async () => ({ status: 200, body: '{"version":"cafef00d"}' }) }
263
+ );
264
+ assert.equal(mismatch.exitCode, 1);
265
+ assert.deepEqual(mismatch.failedWorkers, ["api"]);
266
+
267
+ const unparsable = await runSmoke(
268
+ {
269
+ DEPLOYED_WORKERS: "api",
270
+ SMOKE_PATHS: "/health",
271
+ WORKERS_DEV_SUBDOMAIN: "dsj1984",
272
+ VERIFY_COMMIT_SHA: "true",
273
+ EXPECTED_SHA: "deadbeef",
274
+ },
275
+ { log, probe: async () => ({ status: 200, body: "<html>ok</html>" }) }
276
+ );
277
+ assert.equal(unparsable.exitCode, 1);
278
+ assert.deepEqual(unparsable.failedWorkers, ["api"]);
279
+ });
280
+
281
+ test("runSmoke runs a consumer smoke-command with WORKERS + SMOKE_BASE_URL exported", async () => {
282
+ const { log } = collectLogs();
283
+ let seen;
284
+ const result = await runSmoke(
285
+ {
286
+ DEPLOYED_WORKERS: "api,worker-cron",
287
+ SMOKE_COMMAND: "./my-smoke.sh",
288
+ SMOKE_BASE_URL: "https://godomio.com",
289
+ },
290
+ {
291
+ log,
292
+ runShell: (cmd, env) => {
293
+ seen = { cmd, env };
294
+ return 0;
295
+ },
296
+ }
297
+ );
298
+ assert.equal(result.exitCode, 0);
299
+ assert.equal(seen.cmd, "./my-smoke.sh");
300
+ assert.equal(seen.env.WORKERS, "api,worker-cron");
301
+ assert.equal(seen.env.SMOKE_BASE_URL, "https://godomio.com");
302
+ });
303
+
304
+ test("runSmoke marks every deployed worker for rollback when the consumer smoke-command fails", async () => {
305
+ const { log } = collectLogs();
306
+ const result = await runSmoke(
307
+ { DEPLOYED_WORKERS: "b-worker,a-worker", SMOKE_COMMAND: "exit 1" },
308
+ { log, runShell: () => 1 }
309
+ );
310
+ assert.equal(result.exitCode, 1);
311
+ assert.deepEqual(result.failedWorkers, ["a-worker", "b-worker"]);
312
+ });
313
+
314
+ test("runSmoke fails without a rollback list when no subdomain is derivable", async () => {
315
+ const { log, lines } = collectLogs();
316
+ const result = await runSmoke(
317
+ { DEPLOYED_WORKERS: "api", SMOKE_PATHS: "/health" },
318
+ { log, whoami: () => "not logged in", probe: async () => ({ status: 200, body: "{}" }) }
319
+ );
320
+ assert.equal(result.exitCode, 1);
321
+ assert.deepEqual(result.failedWorkers, []);
322
+ assert.ok(lines.some((l) => l.includes("Could not derive workers.dev subdomain")));
323
+ });
324
+
325
+ test("runSmoke derives the subdomain from whoami output when not provided", async () => {
326
+ const { log } = collectLogs();
327
+ const probed = [];
328
+ const result = await runSmoke(
329
+ { DEPLOYED_WORKERS: "api", SMOKE_PATHS: "/health" },
330
+ {
331
+ log,
332
+ whoami: () => "│ Subdomain │ dsj1984.workers.dev │",
333
+ probe: async (url) => {
334
+ probed.push(url);
335
+ return { status: 200, body: "{}" };
336
+ },
337
+ }
338
+ );
339
+ assert.equal(result.exitCode, 0);
340
+ assert.deepEqual(probed, ["https://api.dsj1984.workers.dev/health"]);
341
+ });
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // resolveFailedFile — rollback-list path resolution (CWE-377 hardening)
345
+ // ---------------------------------------------------------------------------
346
+
347
+ test("resolveFailedFile honours an explicit SMOKE_FAILED_FILE verbatim (no mkdtemp)", () => {
348
+ let mkdtempCalls = 0;
349
+ const path = resolveFailedFile({ SMOKE_FAILED_FILE: "/tmp/smoke-failed-workers.txt" }, () => {
350
+ mkdtempCalls++;
351
+ return "/should/not/be/used";
352
+ });
353
+ assert.equal(path, "/tmp/smoke-failed-workers.txt");
354
+ assert.equal(mkdtempCalls, 0);
355
+ });
356
+
357
+ test("resolveFailedFile trims a whitespace-only SMOKE_FAILED_FILE and falls back to mkdtemp", () => {
358
+ const seen = [];
359
+ const path = resolveFailedFile({ SMOKE_FAILED_FILE: " " }, (prefix) => {
360
+ seen.push(prefix);
361
+ return "/var/folders/xyz/deploy-boot-smoke-Abc123";
362
+ });
363
+ // Fell through to the private-temp-dir branch, not the predictable path.
364
+ assert.equal(seen.length, 1);
365
+ assert.equal(path, "/var/folders/xyz/deploy-boot-smoke-Abc123/smoke-failed-workers.txt");
366
+ });
367
+
368
+ test("resolveFailedFile creates a private temp dir when SMOKE_FAILED_FILE is unset", () => {
369
+ const seen = [];
370
+ const path = resolveFailedFile({}, (prefix) => {
371
+ seen.push(prefix);
372
+ return "/var/folders/xyz/deploy-boot-smoke-Zzz999";
373
+ });
374
+ // The mkdtemp prefix is scoped under the OS temp dir and carries our label,
375
+ // and the returned path lives INSIDE the freshly-created dir — never the
376
+ // fixed, world-writable /tmp/smoke-failed-workers.txt.
377
+ assert.equal(seen.length, 1);
378
+ assert.ok(seen[0].includes("deploy-boot-smoke-"), "mkdtemp prefix carries the script label");
379
+ assert.equal(path, "/var/folders/xyz/deploy-boot-smoke-Zzz999/smoke-failed-workers.txt");
380
+ assert.notEqual(path, "/tmp/smoke-failed-workers.txt");
381
+ });
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deploy-worker-secrets.mjs
4
+ *
5
+ * In-pipeline worker-secrets provisioning for the shared
6
+ * `deploy-cloudflare.yml` workflow (Story #170; extracted from inline bash in
7
+ * Story #231). The workflow sparse-checks this script out of
8
+ * dsj1984/mandrel-platform at `github.job_workflow_sha` — the exact commit
9
+ * the caller's `deploy-cloudflare.yml@<ref>` pin resolved to — so the script
10
+ * version always travels in lockstep with the workflow pin (same model as
11
+ * `uptime-apply.yml` → `apply-uptime-monitors.mjs`).
12
+ *
13
+ * What it does (behaviour-identical to the inline predecessor): for each
14
+ * secret NAME the consumer enumerates in the `worker-secrets` input, resolve
15
+ * the value from the INHERITED secrets context (SECRETS_CONTEXT, the
16
+ * `toJSON(secrets)` payload) and write it onto every deployed worker via the
17
+ * VERSIONS secret API — `pnpm exec wrangler versions secret put`, which is
18
+ * immune to Cloudflare error 10215 even when a prior rollback left the
19
+ * Worker at active ≠ latest-uploaded — then promote the resulting version to
20
+ * 100% traffic (`wrangler versions deploy … -y`) so the just-written secrets
21
+ * are the ACTIVE version boot-smoke probes and any active/latest split
22
+ * self-heals.
23
+ *
24
+ * Secret hygiene: values are passed to wrangler over STDIN and are never
25
+ * echoed, interpolated into argv, or logged — only secret NAMES appear in
26
+ * output (GitHub additionally redacts inherited secret values in logs).
27
+ *
28
+ * Environment contract (all read from process.env):
29
+ * DEPLOY_ENV Cloudflare --env label (required)
30
+ * DEPLOYED_WORKERS csv of deployed worker names (required)
31
+ * WORKER_SECRETS newline-separated secret NAMES; blank lines and '#'
32
+ * comment lines ignored (required — the workflow step is
33
+ * skipped entirely when the input is empty)
34
+ * SECRETS_CONTEXT JSON object of the inherited secrets context (required)
35
+ *
36
+ * Exit codes:
37
+ * 0 — every named secret provisioned onto every deployed worker (or the
38
+ * name list resolved to zero entries — notice printed, nothing to do).
39
+ * 1 — a named secret is absent/empty in the inherited context, a wrangler
40
+ * invocation failed, or the environment contract is violated.
41
+ */
42
+
43
+ import { execFileSync } from "node:child_process";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Pure helpers (unit-tested)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** Split a csv into trimmed, non-empty entries. */
50
+ export function parseCsv(csv) {
51
+ return String(csv ?? "")
52
+ .split(",")
53
+ .map((s) => s.trim())
54
+ .filter(Boolean);
55
+ }
56
+
57
+ /**
58
+ * Parse the newline-separated worker-secrets NAME list: trim each line, skip
59
+ * blanks and leading-'#' comment lines (so a consumer can annotate the list).
60
+ */
61
+ export function parseSecretNames(raw) {
62
+ return String(raw ?? "")
63
+ .split("\n")
64
+ .map((line) => line.trim())
65
+ .filter((line) => line !== "" && !line.startsWith("#"));
66
+ }
67
+
68
+ /**
69
+ * Resolve one secret value from the inherited secrets context JSON. Returns
70
+ * the non-empty string value, or null when the name is absent or empty.
71
+ */
72
+ export function resolveSecretValue(secretsContext, name) {
73
+ const value = secretsContext?.[name];
74
+ return typeof value === "string" && value.length > 0 ? value : null;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Provisioning loop (deps injectable for the test suite)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Run the full provisioning pass. Returns an exit code and performs no
83
+ * process.exit of its own so the test suite can drive it directly.
84
+ */
85
+ export function provisionWorkerSecrets(env, deps = {}) {
86
+ const {
87
+ log = (line) => process.stdout.write(`${line}\n`),
88
+ runWrangler = defaultRunWrangler,
89
+ } = deps;
90
+
91
+ const deployEnv = env.DEPLOY_ENV ?? "";
92
+ const workers = parseCsv(env.DEPLOYED_WORKERS);
93
+ const names = parseSecretNames(env.WORKER_SECRETS);
94
+
95
+ if (names.length === 0) {
96
+ log("::notice::worker-secrets was set but resolved to zero names — nothing to provision.");
97
+ return 0;
98
+ }
99
+
100
+ let secretsContext;
101
+ try {
102
+ secretsContext = JSON.parse(env.SECRETS_CONTEXT ?? "");
103
+ } catch {
104
+ log("::error::worker-secrets: SECRETS_CONTEXT is not valid JSON — cannot resolve secret values.");
105
+ return 1;
106
+ }
107
+
108
+ for (const worker of workers) {
109
+ for (const name of names) {
110
+ // A missing / empty inherited secret is a hard error — a
111
+ // deploy-critical secret the consumer explicitly listed must exist.
112
+ const value = resolveSecretValue(secretsContext, name);
113
+ if (value === null) {
114
+ log(
115
+ `::error::worker-secrets: '${name}' is not present (or empty) in the inherited secrets — forward it via 'secrets: inherit' before listing it in worker-secrets.`
116
+ );
117
+ return 1;
118
+ }
119
+
120
+ log(`::group::Provisioning ${name} onto ${worker} (versions secret API)`);
121
+ // `wrangler versions secret put` reads the value from stdin (same
122
+ // non-interactive path as `wrangler secret put`) and creates a NEW
123
+ // version WITHOUT deploying it. This write is immune to error 10215
124
+ // even when active ≠ latest-uploaded.
125
+ const putCode = runWrangler(
126
+ ["versions", "secret", "put", name, "--name", worker, "--env", deployEnv],
127
+ value
128
+ );
129
+ if (putCode !== 0) {
130
+ log(`::error::worker-secrets: 'wrangler versions secret put ${name}' failed for worker '${worker}'.`);
131
+ log("::endgroup::");
132
+ return 1;
133
+ }
134
+ log("::endgroup::");
135
+ }
136
+
137
+ log(`::group::Promoting latest version of ${worker} to 100% (self-heal active=latest)`);
138
+ // Deploy the latest-uploaded version (the one carrying the secrets just
139
+ // written) at 100% traffic, non-interactively. This both makes the
140
+ // current secret values the ACTIVE version boot-smoke probes and
141
+ // realigns active = latest so no residual 10215-inducing split remains.
142
+ // `-y` selects the non-interactive default (latest @ 100%).
143
+ const deployCode = runWrangler([
144
+ "versions",
145
+ "deploy",
146
+ "--name",
147
+ worker,
148
+ "--env",
149
+ deployEnv,
150
+ "--message",
151
+ "In-pipeline worker-secrets provisioning (Story #170)",
152
+ "-y",
153
+ ]);
154
+ if (deployCode !== 0) {
155
+ log(`::error::worker-secrets: 'wrangler versions deploy' failed for worker '${worker}'.`);
156
+ log("::endgroup::");
157
+ return 1;
158
+ }
159
+ log("::endgroup::");
160
+ }
161
+
162
+ return 0;
163
+ }
164
+
165
+ /**
166
+ * Spawn the consumer's lockfile-pinned wrangler (`pnpm exec wrangler …`,
167
+ * installed and preflighted by setup-toolchain's require-wrangler). When
168
+ * `stdinValue` is provided it is piped over stdin and never appears in argv.
169
+ */
170
+ function defaultRunWrangler(args, stdinValue) {
171
+ try {
172
+ execFileSync("pnpm", ["exec", "wrangler", ...args], {
173
+ stdio: [stdinValue === undefined ? "ignore" : "pipe", "inherit", "inherit"],
174
+ input: stdinValue,
175
+ });
176
+ return 0;
177
+ } catch {
178
+ return 1;
179
+ }
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // CLI entry
184
+ // ---------------------------------------------------------------------------
185
+
186
+ const invokedDirectly =
187
+ process.argv[1] && process.argv[1].endsWith("deploy-worker-secrets.mjs");
188
+ if (invokedDirectly) {
189
+ process.exit(provisionWorkerSecrets(process.env));
190
+ }