@astralarium/repo-deploy-release 0.0.0-stage → 0.2.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/dist/bin/deploy-release.d.ts +2 -0
  4. package/dist/bin/deploy-release.js +7 -0
  5. package/dist/src/app.d.ts +143 -0
  6. package/dist/src/app.js +102 -0
  7. package/dist/src/bundle.d.ts +18 -0
  8. package/dist/src/bundle.js +41 -0
  9. package/dist/src/check.d.ts +44 -0
  10. package/dist/src/check.js +128 -0
  11. package/dist/src/cloudflare.d.ts +70 -0
  12. package/dist/src/cloudflare.js +170 -0
  13. package/dist/src/commands.d.ts +55 -0
  14. package/dist/src/commands.js +651 -0
  15. package/dist/src/cutover-at.d.ts +23 -0
  16. package/dist/src/cutover-at.js +152 -0
  17. package/dist/src/cutovers.d.ts +62 -0
  18. package/dist/src/cutovers.js +112 -0
  19. package/dist/src/deployer.d.ts +36 -0
  20. package/dist/src/deployer.js +63 -0
  21. package/dist/src/index.d.ts +2 -0
  22. package/dist/src/index.js +2 -0
  23. package/dist/src/lease.d.ts +36 -0
  24. package/dist/src/lease.js +112 -0
  25. package/dist/src/main.d.ts +6 -0
  26. package/dist/src/main.js +43 -0
  27. package/dist/src/process.d.ts +34 -0
  28. package/dist/src/process.js +68 -0
  29. package/dist/src/prompt.d.ts +5 -0
  30. package/dist/src/prompt.js +69 -0
  31. package/dist/src/provision.d.ts +36 -0
  32. package/dist/src/provision.js +230 -0
  33. package/dist/src/schema.d.ts +2 -0
  34. package/dist/src/schema.js +12 -0
  35. package/dist/src/session.d.ts +81 -0
  36. package/dist/src/session.js +275 -0
  37. package/dist/src/state.d.ts +55 -0
  38. package/dist/src/state.js +115 -0
  39. package/dist/src/status.d.ts +65 -0
  40. package/dist/src/status.js +224 -0
  41. package/dist/src/steps.d.ts +80 -0
  42. package/dist/src/steps.js +389 -0
  43. package/dist/src/target.d.ts +94 -0
  44. package/dist/src/target.js +246 -0
  45. package/dist/src/web.d.ts +28 -0
  46. package/dist/src/web.js +70 -0
  47. package/dist/src/workspace.d.ts +11 -0
  48. package/dist/src/workspace.js +40 -0
  49. package/dist/testkit/index.d.ts +4 -0
  50. package/dist/testkit/index.js +36 -0
  51. package/dist/testkit/release-bundles.d.ts +16 -0
  52. package/dist/testkit/release-bundles.js +56 -0
  53. package/dist/testkit/release-checks.d.ts +19 -0
  54. package/dist/testkit/release-checks.js +71 -0
  55. package/dist/testkit/release.d.ts +44 -0
  56. package/dist/testkit/release.js +61 -0
  57. package/package.json +64 -3
  58. package/sql/before-reset.sql +22 -0
  59. package/sql/schema.sql +164 -0
  60. package/sql/scrub.sql +5 -0
@@ -0,0 +1,651 @@
1
+ /** `deploy-release`'s commands and their help. */
2
+ import { readFileSync } from "node:fs";
3
+ import { parseEnv } from "node:util";
4
+ import { buildOf, otherSlot, parseDeployEnv, } from "@astralarium/repo-deploy-info";
5
+ import { ExpectedError, log } from "@astralarium/repo-telemetry";
6
+ import { buildCommand, buildRouteMap } from "@stricli/core";
7
+ import { dbUrlVar, envNames, secretsFile, } from "./app.js";
8
+ import { BUILDS, DEPLOYER, DEPLOYER_AND_WORKER, READS, runCheck, STATE, UPLOADS, } from "./check.js";
9
+ import { epoch } from "./cloudflare.js";
10
+ import { readCutovers, requireIdle, requireLiveAligned, terminateCutovers, withdrawOrphan, } from "./cutovers.js";
11
+ import { breakLease, describeLease, readLease, } from "./lease.js";
12
+ import { reportFailure } from "./process.js";
13
+ import { shown } from "./provision.js";
14
+ import { inSession, inSlots, slotSession, } from "./session.js";
15
+ import { checkFacts, formatCheck, readFacts, summarize } from "./status.js";
16
+ import { deployDeployer, deployNext, deployWorkers, installToken, pinCutover, preflightSecrets, promptToken, publishState, recordLiveBuild, retireBuilds, runFlow, scheduleCutover, smokeTest, TOKEN_MARGIN, tokenExpiry, triggerCutover, } from "./steps.js";
17
+ import { secretsByScript } from "./target.js";
18
+ import { earlierLiveVersions, readStaged, slotBuild, slotOf, } from "./web.js";
19
+ /** Between ongoing-work checks while `stage` waits. */
20
+ const WORK_POLL_MS = 30_000;
21
+ /** A boolean flag; default false. */
22
+ const toggle = (brief) => ({ kind: "boolean", brief, default: false, withNegated: false });
23
+ /** `--force` of `stage`, `deploy`. */
24
+ const FORCE = toggle("End the idle slot's ongoing work now, through the deployer's drain workflow");
25
+ /** `--at` of `cutover`, `deploy`. */
26
+ const AT = {
27
+ kind: "parsed",
28
+ parse: String,
29
+ optional: true,
30
+ placeholder: "T",
31
+ brief: "When the next build takes over, at most 30 days out: an ISO timestamp (2026-10-09T04:00, Pacific unless zoned), an interval (1h, 1h30m, 1d) or now; default 4 AM Pacific tomorrow",
32
+ };
33
+ /** `--exact-time` of `cutover`, `deploy`. */
34
+ const EXACT_TIME = toggle("Keep T as given; T over 90 minutes out rounds to the half hour otherwise");
35
+ /** `--build-env` of `init`, `stage`, `deploy`: one of `app`'s `buildEnv`
36
+ * variables, repeatable. */
37
+ const buildEnvFlag = (app) => {
38
+ const names = Object.keys(app.buildEnv ?? {});
39
+ return {
40
+ kind: "parsed",
41
+ parse: (value) => {
42
+ const at = value.indexOf("=");
43
+ const name = value.slice(0, at);
44
+ if (at >= 1 && names.includes(name))
45
+ return [name, value.slice(at + 1)];
46
+ throw new ExpectedError(`--build-env takes NAME=VALUE, NAME one of: ${names.join(", ") || "none declared"}`);
47
+ },
48
+ variadic: true,
49
+ optional: true,
50
+ placeholder: "NAME=VALUE",
51
+ brief: "Set a build environment variable (Build environment, below); builds see these alone",
52
+ };
53
+ };
54
+ /** Help paragraphs, then examples as summary and command line. */
55
+ const describe = (paragraphs, examples = []) => [
56
+ ...paragraphs,
57
+ ...(examples.length
58
+ ? [
59
+ `Examples:\n${examples.map(([summary, line]) => ` # ${summary}\n ${line}`).join("\n\n")}`,
60
+ ]
61
+ : []),
62
+ ].join("\n\n");
63
+ /** `init`: deploys the next build to blue, its web version live alone,
64
+ * and names it the live build. Refuses once a slot is live. */
65
+ export async function runInit(s, { buildEnv } = {}) {
66
+ const { deployment } = s.target;
67
+ s.ready();
68
+ await s.takeLease("init");
69
+ await preflightSecrets(s);
70
+ const live = await s.cf.versionAt(deployment.webScript, 100);
71
+ const slot = live && slotOf(await s.cf.version(deployment.webScript, live));
72
+ if (slot)
73
+ throw new ExpectedError(`Slot ${slot} is live: already initialized.`);
74
+ // Blue keeps its crons and goes live at 100%; slots read the live
75
+ // build from the deploy state.
76
+ const next = await deployNext(s, "blue", buildEnv);
77
+ await s.cf.deploy(deployment.webScript, [{ version_id: next.version, percentage: 100 }], `init: ${next.build} on blue`);
78
+ await recordLiveBuild(s, next.build);
79
+ await retireBuilds(s, [next.build]);
80
+ await smokeTest(s, next.build, s.target.buildPaths, {
81
+ script: deployment.webScript,
82
+ id: next.version,
83
+ });
84
+ log.info("deploy_release.live", "Live: build {build} on {script} as web version {version}", {
85
+ build: next.build,
86
+ script: deployment.slotScripts.blue,
87
+ version: next.version,
88
+ });
89
+ }
90
+ /** `stage`'s checks before any change; resolves to the cutovers read.
91
+ * Live alignment keeps staging off the slot the deploy state names live. */
92
+ async function stagePreflight(s, operation) {
93
+ s.ready();
94
+ await s.takeLease(operation);
95
+ await preflightSecrets(s);
96
+ const c = await readCutovers(s);
97
+ requireIdle(c);
98
+ // A new next build would fail a scheduled cutover's verify step.
99
+ if (c.terminable.length)
100
+ throw new ExpectedError(`A cutover is scheduled (${c.terminable.join(" ")}); run ${s.cli("cancel")} first.`);
101
+ if (c.orphan)
102
+ throw new ExpectedError(`An update at ${c.orphan.at.toISOString()} belongs to ended cutover run ${c.orphan.run}; run ${s.cli("cancel")} first.`);
103
+ requireLiveAligned(s, c);
104
+ return c;
105
+ }
106
+ /** Deploys the next build to the idle slot at 0% beside `live`. */
107
+ async function stageBuild(s, live, { force, buildEnv }) {
108
+ const idle = otherSlot(live.slot);
109
+ const idleScript = s.target.deployment.slotScripts[idle];
110
+ // The idle slot's ongoing work ends first: --force ends it through the
111
+ // deployer's drain workflow.
112
+ const idleBuild = await slotBuild(s, idle);
113
+ const { ongoingWork } = s.app;
114
+ const { drain } = s.target.flows;
115
+ if (idleBuild && force && drain) {
116
+ await deployDeployer(s);
117
+ await runFlow(s, drain, `drain-${idle}-${epoch()}`, { slot: idle });
118
+ }
119
+ else if (idleBuild && ongoingWork)
120
+ for (;;) {
121
+ const count = await ongoingWork(s.sql, idleBuild);
122
+ if (!count)
123
+ break;
124
+ log.info("deploy_release.work_wait", "{script} runs {count, plural, one {# ongoing work item} other {# ongoing work items}}; waiting{drain, select, true { (--force ends them)} other {}}", { count, script: idleScript, drain: drain !== undefined });
125
+ await s.sleep(WORK_POLL_MS);
126
+ }
127
+ const next = await deployNext(s, idle, buildEnv);
128
+ // Crons run only on the live slot; the cutover moves them.
129
+ await s.cf.clearCrons(idleScript);
130
+ // Keyed on the slots' builds, so a rerun clears leftovers.
131
+ await retireBuilds(s, [live.build, next.build]);
132
+ await s.cf.deploy(s.target.deployment.webScript, [
133
+ { version_id: live.id, percentage: 100 },
134
+ { version_id: next.version, percentage: 0 },
135
+ ], `stage build ${next.build} on ${idle}`);
136
+ await smokeTest(s, next.build, s.target.buildPaths, {
137
+ script: s.target.deployment.webScript,
138
+ id: next.version,
139
+ });
140
+ log.info("deploy_release.staged", "Next build {build} on {script} as web version {version}", { build: next.build, script: idleScript, version: next.version });
141
+ }
142
+ /** Throws on `--force` while `s`'s target has no drain workflow. */
143
+ function requireDrainForForce(s, force) {
144
+ if (force && !s.target.flows.drain)
145
+ throw new ExpectedError(`--force needs a drain workflow: declare ReleaseApp.envs.${s.target.env}.drain.`);
146
+ }
147
+ /** `stage`: deploys the next build to the idle slot at 0%. */
148
+ export async function runStage(s, flags) {
149
+ requireDrainForForce(s, flags.force);
150
+ const { live } = await stagePreflight(s, "stage");
151
+ await stageBuild(s, live, flags);
152
+ // Both slots now hold code, so the deployer binds both.
153
+ await deployDeployer(s);
154
+ log.info("deploy_release.next_step", "Next: {command} [--at <T>]", {
155
+ command: s.cli("cutover"),
156
+ });
157
+ }
158
+ /** `cutover`: schedules the switch to the staged build at `--at`. */
159
+ export async function runCutover(s, flags) {
160
+ const at = pinCutover(flags.at, flags.exactTime);
161
+ s.requireClean();
162
+ await s.takeLease("cutover");
163
+ const c = await readCutovers(s);
164
+ requireIdle(c);
165
+ requireLiveAligned(s, c);
166
+ const staged = await readStaged(s, c.live);
167
+ const token = await promptToken(s, tokenExpiry(at));
168
+ await scheduleCutover(s, staged, at, token);
169
+ }
170
+ /** `deploy` of an in-place target: builds and deploys its workers;
171
+ * an env with a database names the build live and retires older builds'
172
+ * state under the deploy lease; then checks every worker serves the
173
+ * build. */
174
+ async function runDeployWorkers(s, { buildEnv }) {
175
+ s.ready();
176
+ const { workers, dbUrlVar: db } = s.target;
177
+ if (db)
178
+ await s.takeLease("deploy");
179
+ await preflightSecrets(s);
180
+ const build = await deployWorkers(s, buildEnv);
181
+ if (db) {
182
+ await recordLiveBuild(s, build);
183
+ await retireBuilds(s, [build]);
184
+ }
185
+ await smokeTest(s, build, [...new Set(workers.map((w) => w.buildPath))]);
186
+ log.info("deploy_release.live", "Live: build {build} on {scripts}", { build, scripts: workers.map((w) => w.script) });
187
+ }
188
+ /** `deploy`: `stage`, then `cutover`, unattended after the token prompt.
189
+ * An in-place target deploys at once (`runDeployWorkers`); its
190
+ * cutover flags refuse. */
191
+ export async function runDeploy(session, flags) {
192
+ if (session.target.kind === "in-place") {
193
+ if (flags.at !== undefined || flags.exactTime || flags.force)
194
+ throw new ExpectedError(`${session.target.env} deploys at once: --at, --exact-time and --force apply to blue/green targets.`);
195
+ return runDeployWorkers(session, flags);
196
+ }
197
+ const s = slotSession(session);
198
+ requireDrainForForce(s, flags.force);
199
+ const at = pinCutover(flags.at, flags.exactTime);
200
+ const c = await stagePreflight(s, "deploy");
201
+ if (at - Date.now() < 60 * 60_000)
202
+ log.warn("deploy_release.cutover_soon", "the cutover is under an hour away; if staging runs past it, cuts over immediately");
203
+ // The token first: staging then runs unattended.
204
+ const token = await promptToken(s, tokenExpiry(at));
205
+ await stageBuild(s, c.live, flags);
206
+ const staged = await readStaged(s, c.live);
207
+ await scheduleCutover(s, staged, at, token);
208
+ }
209
+ /** `rollback`: switches back to the previous build now, through the
210
+ * cutover workflow. Previous = the newest earlier live web version on
211
+ * the other slot, while that slot still holds its build. */
212
+ export async function runRollback(s) {
213
+ const { webScript, slotScripts } = s.target.deployment;
214
+ s.requireClean();
215
+ await s.takeLease("rollback");
216
+ const c = await readCutovers(s);
217
+ requireIdle(c);
218
+ requireLiveAligned(s, c);
219
+ const { live } = c;
220
+ const previousSlot = otherSlot(live.slot);
221
+ let previous;
222
+ for (const id of earlierLiveVersions(await s.cf.deployments(webScript), live.id)) {
223
+ const version = await s.cf.version(webScript, id);
224
+ const build = buildOf(version);
225
+ if (build && slotOf(version) === previousSlot) {
226
+ previous = { id, build };
227
+ break;
228
+ }
229
+ }
230
+ if (!previous)
231
+ throw new ExpectedError(`No previous version on ${previousSlot}.`);
232
+ if ((await slotBuild(s, previousSlot)) !== previous.build)
233
+ throw new ExpectedError(`${slotScripts[previousSlot]} no longer holds build '${previous.build}'.`);
234
+ // Token first: an abandoned prompt changes nothing. The previous version
235
+ // goes to 0% after the trigger's fresh read.
236
+ await installToken(s, await promptToken(s, tokenExpiry(Date.now())));
237
+ const released = await triggerCutover(s, {
238
+ liveVersion: live.id,
239
+ nextVersion: previous.id,
240
+ liveSlot: live.slot,
241
+ idleSlot: previousSlot,
242
+ build: previous.build,
243
+ cutoverAt: new Date().toISOString(),
244
+ }, () => s.cf.deploy(webScript, [
245
+ { version_id: live.id, percentage: 100 },
246
+ { version_id: previous.id, percentage: 0 },
247
+ ], `roll back to ${previous.build} on ${previousSlot}`));
248
+ if (released)
249
+ log.info("deploy_release.rolled_back", "Rolled back: build {build} is live", { build: previous.build });
250
+ else
251
+ log.info("deploy_release.rollback", "Rolling back: build {build} takes over now", {
252
+ build: previous.build,
253
+ });
254
+ }
255
+ /** Throws unless the live slot runs `live`'s build. */
256
+ async function requireLiveSlotHolds(s, live) {
257
+ if ((await slotBuild(s, live.slot)) !== live.build)
258
+ throw new ExpectedError(`${s.target.deployment.slotScripts[live.slot]} no longer holds the live build '${live.build}'.`);
259
+ }
260
+ /** `cancel`: terminates waiting cutover runs, withdrawing their updates
261
+ * and one left by an ended run, and publishes the deploy state. Resolves
262
+ * whether an update or a waiting run was there to cancel. */
263
+ export async function cancelUpdate(s) {
264
+ await s.takeLease("cancel");
265
+ const c = await readCutovers(s);
266
+ const then = `once it ends, ${s.cli("rollback")} switches back.`;
267
+ requireIdle(c, { then });
268
+ const { live } = c;
269
+ await requireLiveSlotHolds(s, live);
270
+ if (!c.terminable.length && !c.orphan) {
271
+ log.info("deploy_release.nothing_scheduled", "No update scheduled");
272
+ return false;
273
+ }
274
+ // Fresh read at act time: a run whose update turned due is in flight.
275
+ const fresh = await terminateCutovers(s, { then });
276
+ await withdrawOrphan(s, fresh);
277
+ // Publishes through settle so clients drop the notice; a failed publish
278
+ // warns and names settle.
279
+ await publishState(s, live);
280
+ log.info("deploy_release.cancelled", "Cancelled: no update scheduled; build {build} stays live", { build: live.build });
281
+ return true;
282
+ }
283
+ /** `settle`: aligns the deployment to the live web version. Ends finishing
284
+ * cutover runs too; the steps below redo their tail. */
285
+ export async function runSettle(s) {
286
+ const { slotScripts } = s.target.deployment;
287
+ s.requireClean();
288
+ await s.takeLease("settle");
289
+ const c = await readCutovers(s);
290
+ requireIdle(c, { endFinishing: true });
291
+ const { live } = c;
292
+ await requireLiveSlotHolds(s, live);
293
+ await terminateCutovers(s, { endFinishing: true });
294
+ // Live slot's crons first, then the other's clear; the live-build gate
295
+ // keeps one slot's crons active meanwhile.
296
+ await s.cf.wrangler(s.target.packages.worker, [
297
+ "triggers",
298
+ "deploy",
299
+ "--env",
300
+ s.target.env,
301
+ "--name",
302
+ slotScripts[live.slot],
303
+ ]);
304
+ await s.cf.clearCrons(slotScripts[otherSlot(live.slot)]);
305
+ await deployDeployer(s);
306
+ await runFlow(s, s.target.flows.settle, `settle-${epoch()}`, {
307
+ slot: live.slot,
308
+ build: live.build,
309
+ });
310
+ log.info("deploy_release.settled", "Settled: build {build} is live on {script}", { build: live.build, script: slotScripts[live.slot] });
311
+ }
312
+ /** `status`: prints the deployment's facts and checks; resolves to the
313
+ * exit status, 1 on any failed check. */
314
+ export async function runStatus(s) {
315
+ const facts = await readFacts(s);
316
+ const checks = checkFacts(facts, (c) => s.cli(c), s.target.dbUrlVar);
317
+ process.stdout.write([
318
+ ...summarize(facts, s.target.deployment.webScript),
319
+ "Checks:",
320
+ ...checks.map(formatCheck),
321
+ "",
322
+ ].join("\n"));
323
+ return checks.some((c) => c.level === "fail") ? 1 : 0;
324
+ }
325
+ /** `unlock`: breaks the deploy lease once the operator confirms (`yes`
326
+ * skips the question). */
327
+ export async function runUnlock(s, flags) {
328
+ const lease = await readLease(s.sql);
329
+ if (!lease)
330
+ return log.info("deploy_release.lease_free", "No lease held");
331
+ const held = describeLease(lease);
332
+ if (!flags.yes &&
333
+ !(await s.confirm(`Break the lease of ${held}? Its command may still run. [y/N] `)))
334
+ return log.info("deploy_release.lease_kept", "Left in place");
335
+ if (!(await breakLease(s.sql, lease.holder)))
336
+ throw new ExpectedError("The lease changed hands meanwhile; left in place.");
337
+ log.info("deploy_release.lease_broken", "Broke the lease of {holder} ({operation}, until {expires})", {
338
+ holder: lease.holder,
339
+ operation: lease.operation,
340
+ expires: lease.expires_at,
341
+ });
342
+ }
343
+ /** The target's secrets file by script (`secretsByScript`); the
344
+ * `secrets` gate (`UPLOADS`) has checked it holds every value. */
345
+ function readSecrets(s) {
346
+ const values = parseEnv(readFileSync(s.target.secretsFile, "utf8"));
347
+ return new Map(secretsByScript(s.target).map(([script, keys]) => [
348
+ script,
349
+ Object.fromEntries(keys.map((key) => [key, values[key]])),
350
+ ]));
351
+ }
352
+ /** `secrets` of a blue/green target: uploads to the web worker and both
353
+ * slots (`SlotTarget.secrets`), web first. */
354
+ async function uploadSlotSecrets(s, secrets) {
355
+ const { env, deployment, packages } = s.target;
356
+ await s.takeLease("secrets");
357
+ // A secret change deploys the latest web version at 100%, so it must
358
+ // be the live one.
359
+ const latest = (await s.cf.versions(deployment.webScript)).reduce((a, v) => (!a || v.number > a.number ? v : a), undefined);
360
+ const live = await s.cf.versionAt(deployment.webScript, 100);
361
+ if (latest && latest.id !== live)
362
+ throw new ExpectedError(`The latest web version (${latest.id}) is not live: stage and cut over a new build (${s.cli("deploy")}).`);
363
+ // Web first: only it can refuse (undeployed latest version), and a
364
+ // refusal leaves the slots untouched.
365
+ await s.cf.wrangler(packages.web, ["secret", "bulk", "--env", env], {
366
+ input: JSON.stringify(secrets.get(deployment.webScript)),
367
+ });
368
+ for (const script of Object.values(deployment.slotScripts))
369
+ await s.cf.wrangler(packages.worker, ["secret", "bulk", "--env", env, "--name", script], { input: JSON.stringify(secrets.get(script)) });
370
+ }
371
+ /** `secrets`: uploads the target's secrets file to each of its scripts. */
372
+ export async function runSecrets(s) {
373
+ const secrets = readSecrets(s);
374
+ if (s.target.kind === "in-place") {
375
+ if (s.target.dbUrlVar)
376
+ await s.takeLease("secrets");
377
+ for (const { package: pkg, script } of s.target.workers)
378
+ await s.cf.wrangler(pkg, ["secret", "bulk", "--env", s.target.env], {
379
+ input: JSON.stringify(secrets.get(script)),
380
+ });
381
+ }
382
+ else
383
+ await uploadSlotSecrets(slotSession(s), secrets);
384
+ log.info("deploy_release.secrets_uploaded", "Secrets uploaded to {scripts}", { scripts: [...secrets.keys()] });
385
+ }
386
+ /** `--env` of every command: one of the app's `envs`. */
387
+ const envFlag = (envs) => ({
388
+ kind: "parsed",
389
+ parse: (value) => parseDeployEnv(value, envs),
390
+ placeholder: "env",
391
+ brief: `Target environment: ${envs.join(", ")}`,
392
+ });
393
+ /** Help examples: each summary with its command line for `env`; none
394
+ * while the app has no env of the kind. */
395
+ const examplesFor = (env, examples) => env === undefined
396
+ ? []
397
+ : examples.map(([summary, line]) => [summary, line(env)]);
398
+ /** Help lines of the variables the commands read: each env's database
399
+ * URL, then the app's build variables. */
400
+ function environmentHelp(app) {
401
+ const databases = Object.entries(app.envs).map(([env, release]) => {
402
+ const variable = dbUrlVar(env, release);
403
+ if (!variable)
404
+ return ` ${env}: no database`;
405
+ const needs = release.deploy === "blue-green"
406
+ ? "every command but status and check needs it"
407
+ : "deploy, secrets and unlock need it";
408
+ return ` ${variable} ${env} database URL; ${needs}`;
409
+ });
410
+ const builds = Object.entries(app.buildEnv ?? {}).map(([name, brief]) => ` ${name} ${brief}`);
411
+ return [
412
+ `Environment:\n${databases.join("\n")}`,
413
+ ...(builds.length
414
+ ? [`Build environment, set by --build-env:\n${builds.join("\n")}`]
415
+ : []),
416
+ ];
417
+ }
418
+ /** `deploy-release`'s commands against `app`'s targets; `invocation`
419
+ * starts each printed command line. */
420
+ export function commands(app, invocation) {
421
+ const envs = envNames(app);
422
+ const ENV = envFlag(envs);
423
+ const BUILD_ENV = buildEnvFlag(app);
424
+ const deploying = (deploy) => envs.find((env) => app.envs[env].deploy === deploy);
425
+ const slotEnv = deploying("blue-green");
426
+ const workerEnv = deploying("in-place");
427
+ const drainEnv = envs.find((env) => {
428
+ const release = app.envs[env];
429
+ return release.deploy === "blue-green" && release.drain;
430
+ });
431
+ const bootstrapping = envs.filter((env) => app.envs[env].bootstrapping).join(", ") || "none";
432
+ const secretsPath = shown(secretsFile(app, "<env>"));
433
+ const init = buildCommand({
434
+ docs: {
435
+ brief: "Deploy the first build to blue and make it live",
436
+ fullDescription: describe([
437
+ "First deploy, while no slotted version is live: deploys the next build to blue, makes it live, and records it as the live build.",
438
+ ]),
439
+ },
440
+ parameters: { flags: { env: ENV, buildEnv: BUILD_ENV } },
441
+ func: inSlots(app, invocation, BUILDS, (flags, s) => runInit(s, flags)),
442
+ });
443
+ const stage = buildCommand({
444
+ docs: {
445
+ brief: "Deploy the next build to the idle slot at 0%",
446
+ fullDescription: describe([
447
+ "Deploys the next build to the idle slot and uploads its web version at 0%. Clients see no change.",
448
+ "Waits out the idle slot's ongoing work first (the app's ongoingWork hook). Refuses while a cutover is scheduled.",
449
+ ]),
450
+ },
451
+ parameters: { flags: { env: ENV, force: FORCE, buildEnv: BUILD_ENV } },
452
+ func: inSlots(app, invocation, BUILDS, (flags, s) => runStage(s, flags)),
453
+ });
454
+ const cutover = buildCommand({
455
+ docs: {
456
+ brief: "Schedule the switch to the staged build",
457
+ fullDescription: describe([
458
+ "Schedules the switch to the staged build at T; the deployer's cutover workflow runs it. Rerun to reschedule.",
459
+ "The app shows clients the update ahead of T and closes to new work by its own lead times; with `drain`, the live slot's ongoing work ends at T.",
460
+ `Resolves T when the command starts (intervals and now count from then) and prompts for an API token with Workers Scripts: Edit, valid until ${TOKEN_MARGIN} past T. If T passes during the prompt, cuts over immediately; a prompt so late that the token would expire before the cutover's budget refuses. Times print in Pacific and UTC.`,
461
+ "Waits until the cutover run schedules the update; exits 1 when the run ends without a change (another cutover's update is due, or a later run took over).",
462
+ ], examplesFor(slotEnv, [
463
+ [
464
+ "Cut over at 4 AM Pacific tomorrow",
465
+ (env) => `${invocation} cutover --env ${env}`,
466
+ ],
467
+ [
468
+ "Cut over 90 minutes from now",
469
+ (env) => `${invocation} cutover --env ${env} --at 1h30m`,
470
+ ],
471
+ [
472
+ "Cut over at 6:05 PM Pacific on Oct 9, unrounded",
473
+ (env) => `${invocation} cutover --env ${env} --at 2026-10-09T18:05 --exact-time`,
474
+ ],
475
+ ])),
476
+ },
477
+ parameters: { flags: { env: ENV, at: AT, exactTime: EXACT_TIME } },
478
+ func: inSlots(app, invocation, DEPLOYER, (flags, s) => runCutover(s, flags)),
479
+ });
480
+ const deploy = buildCommand({
481
+ docs: {
482
+ brief: "Stage the next build, then schedule its cutover",
483
+ fullDescription: describe([
484
+ "A blue/green env: runs stage, then cutover. Resolves T when the command starts (intervals and now count from then), prompts for the token after its checks, then stages and schedules unattended.",
485
+ `The token is valid until ${TOKEN_MARGIN} past T, like cutover's. Warns when T is under an hour away; if staging runs past T, cuts over immediately. Staging run so late that the token would expire before the cutover's budget leaves the build staged; cutover then schedules it with a fresh token.`,
486
+ "An in-place env: builds every worker, deploys them in order, names the build live and retires older builds' state (an env with a database), then checks each worker serves the build; a first deploy's custom domain may take minutes to serve. A deploy failing midway leaves the earlier workers on the new build and the rest on the old one; the failure names both, and a rerun redeploys every worker.",
487
+ ], [
488
+ ...examplesFor(slotEnv, [
489
+ [
490
+ "Ship at 4 AM Pacific tomorrow",
491
+ (env) => `${invocation} deploy --env ${env}`,
492
+ ],
493
+ ]),
494
+ ...examplesFor(drainEnv, [
495
+ [
496
+ "Ship a hotfix now, ending the idle slot's ongoing work",
497
+ (env) => `${invocation} deploy --env ${env} --at now --force`,
498
+ ],
499
+ ]),
500
+ ...examplesFor(workerEnv, [
501
+ ["Deploy at once", (env) => `${invocation} deploy --env ${env}`],
502
+ ]),
503
+ ]),
504
+ },
505
+ parameters: {
506
+ flags: {
507
+ env: ENV,
508
+ at: AT,
509
+ exactTime: EXACT_TIME,
510
+ force: FORCE,
511
+ buildEnv: BUILD_ENV,
512
+ },
513
+ },
514
+ func: inSession(app, invocation, BUILDS, (flags, s) => runDeploy(s, flags)),
515
+ });
516
+ const rollback = buildCommand({
517
+ docs: {
518
+ brief: "Switch back to the previous build now",
519
+ fullDescription: describe([
520
+ "Switches back to the previous build now, through the cutover workflow. Its slot must still hold it. With `drain`, the live slot's ongoing work ends now.",
521
+ "Prompts for an API token like cutover.",
522
+ ]),
523
+ },
524
+ parameters: { flags: { env: ENV } },
525
+ func: inSlots(app, invocation, DEPLOYER, (_flags, s) => runRollback(s)),
526
+ });
527
+ const cancel = buildCommand({
528
+ docs: {
529
+ brief: "Cancel the scheduled update",
530
+ fullDescription: describe([
531
+ "Cancels the scheduled update before its cutover runs: ends the waiting cutover run, withdraws the update and clears the notice; when that publish fails, settle republishes. The staged build stays staged; cutover reschedules it.",
532
+ "Refuses while a cutover is in progress; rollback is the path once it ends. Exits 0 when nothing is scheduled.",
533
+ ]),
534
+ },
535
+ parameters: { flags: { env: ENV } },
536
+ func: inSlots(app, invocation, STATE, async (_flags, s) => {
537
+ await cancelUpdate(s);
538
+ }),
539
+ });
540
+ const settle = buildCommand({
541
+ docs: {
542
+ brief: "Sync the deployment to the live web version",
543
+ fullDescription: describe([
544
+ "Syncs the deployment to the live (100%) web version: cancels a waiting cutover (one finishing its release included), withdraws the update notice, records the live build, and puts crons on its slot only.",
545
+ ]),
546
+ },
547
+ parameters: { flags: { env: ENV } },
548
+ func: inSlots(app, invocation, DEPLOYER_AND_WORKER, (_flags, s) => runSettle(s)),
549
+ });
550
+ const status = buildCommand({
551
+ docs: {
552
+ brief: "Check the deployment's invariants",
553
+ fullDescription: describe([
554
+ "Prints web versions and each slot's build and crons, then checks: the live slot runs the live build; the public site serves it; only the live slot holds crons (both while a cutover hands them over); the deploy state names the live build; a scheduled update has a cutover run.",
555
+ "Prints a fix per failure; exits 1 on any. Skips the deploy state without the database URL.",
556
+ ]),
557
+ },
558
+ parameters: { flags: { env: ENV } },
559
+ func: inSlots(app, invocation, READS, (_flags, s) => runStatus(s)),
560
+ });
561
+ const unlock = buildCommand({
562
+ docs: {
563
+ brief: "Break a deploy lease left by a dead command",
564
+ fullDescription: describe([
565
+ "Breaks the deploy lease after asking. Its command may still run. Any env with a database.",
566
+ "A lease expires an hour after its last renewal; the next command takes an expired lease over.",
567
+ ], examplesFor(slotEnv ?? workerEnv, [
568
+ [
569
+ "Break a dead command's lease",
570
+ (env) => `${invocation} unlock --env ${env}`,
571
+ ],
572
+ [
573
+ "Break it from a script",
574
+ (env) => `${invocation} unlock --env ${env} --yes`,
575
+ ],
576
+ ])),
577
+ },
578
+ parameters: { flags: { env: ENV, yes: toggle("Skip the confirmation") } },
579
+ func: inSession(app, invocation, STATE, (flags, s) => runUnlock(s, flags)),
580
+ });
581
+ const secrets = buildCommand({
582
+ docs: {
583
+ brief: "Upload the secrets file to the env's scripts",
584
+ fullDescription: describe([
585
+ `Uploads ${secretsPath} (template: ${secretsPath}.tmpl). Each script gets the keys its wrangler config's env block requires (secrets.required); a blue/green env's slots share the worker's. Creates missing scripts.`,
586
+ "A blue/green env requires the latest web version to be live, since a secret change deploys it: fails while a build is staged or after a rollback.",
587
+ "Rotating a shared session secret signs everyone out. Workers disagree on it until every upload lands; rerun after a failure.",
588
+ ]),
589
+ },
590
+ parameters: { flags: { env: ENV } },
591
+ func: inSession(app, invocation, UPLOADS, (_flags, s) => runSecrets(s)),
592
+ });
593
+ const check = buildCommand({
594
+ docs: {
595
+ brief: "List what an env still needs before deploying",
596
+ fullDescription: describe([
597
+ "Lists every provisioning gap of the env (default: every env) with its next step: env blocks, a target's fields, binding resources left malformed or at a placeholder id, bindings the top level declares and the env block lacks, secrets the app declares and the env block lacks, the secrets file and its template, the database URL variable. Reads files and the process environment only.",
598
+ "A placeholder id is its kind's shape with every digit zero (Hyperdrive and KV 00000000000000000000000000000000, D1 00000000-0000-0000-0000-000000000000, rate limits 0); a placeholder secret is xxxx. Other commands warn of the env's gaps and refuse on those they act on: commands deploying from config on a config gap of a package they deploy (init, stage and deploy every package; cutover and rollback the deployer; settle the deployer and the slotted worker), secrets on a secrets file gap, commands on the deploy state on an unset database URL; every command on an undeclared target.",
599
+ `Exits 1 on a gap of a required env; a bootstrapping env's gaps report alone (bootstrapping: ${bootstrapping}).`,
600
+ ], [
601
+ ["Check every env", `${invocation} check`],
602
+ ...examplesFor(workerEnv ?? slotEnv, [
603
+ ["Check one env", (env) => `${invocation} check --env ${env}`],
604
+ ]),
605
+ ]),
606
+ },
607
+ parameters: {
608
+ flags: {
609
+ env: {
610
+ ...ENV,
611
+ optional: true,
612
+ brief: `${ENV.brief}; default every env`,
613
+ },
614
+ },
615
+ },
616
+ func: (flags) => {
617
+ try {
618
+ process.exitCode = runCheck(app, flags.env ? [flags.env] : envs);
619
+ }
620
+ catch (error) {
621
+ reportFailure(error);
622
+ }
623
+ },
624
+ });
625
+ return buildRouteMap({
626
+ routes: {
627
+ check,
628
+ deploy,
629
+ stage,
630
+ cutover,
631
+ cancel,
632
+ rollback,
633
+ status,
634
+ settle,
635
+ unlock,
636
+ secrets,
637
+ init,
638
+ },
639
+ docs: {
640
+ brief: "Release each deploy env: blue/green envs stage each build on the idle worker slot, then cut over on schedule; in-place envs deploy at once.",
641
+ fullDescription: [
642
+ "Release each deploy env, as ReleaseApp.envs declares it. A blue/green env deploys each build to its idle worker slot (blue or green), then takes over at a scheduled cutover; an in-place env deploys its workers at once, through deploy, secrets, unlock and check alone.",
643
+ "With a blue/green env's `drain`, `--force` ends the idle slot's ongoing work through the deployer's drain workflow; cutover and rollback end the live slot's at the switch.",
644
+ `check lists what each env still needs (resources, secrets, database URL) with the next step; other commands warn of their env's gaps and refuse on those they act on. Bootstrapping envs, whose gaps check reports without failing: ${bootstrapping}.`,
645
+ ...environmentHelp(app),
646
+ "Changing commands of an env with a database hold the deploy lease and exit while another holds it. While a cutover runs, every blue/green command but init, status, unlock and secrets exits; while one finishes its release (clearing the old slot's crons), settle alone proceeds, ending it.",
647
+ "Builds need clean checkouts of every bundled repo; cutover, rollback and settle, a clean app checkout (the deployer bundles it).",
648
+ ].join("\n\n"),
649
+ },
650
+ });
651
+ }
@@ -0,0 +1,23 @@
1
+ /** A parsed `--at`. */
2
+ export type AtSpec =
3
+ /** An instant (ms epoch): a timestamp or the default. */
4
+ {
5
+ kind: "fixed";
6
+ at: number;
7
+ }
8
+ /** An offset (ms) from the moment the cutover is scheduled. */
9
+ | {
10
+ kind: "relative";
11
+ offsetMs: number;
12
+ };
13
+ /** Parses `--at` at `now` (ms epoch): undefined = default; `now` and
14
+ * intervals relative; else ISO 8601, zone-less in `CUTOVER_ZONE`. Throws
15
+ * on malformed text. */
16
+ export declare function parseCutoverSpec(text: string | undefined, now: number): AtSpec;
17
+ /** Cutover instant (ms epoch) of `spec` at `now`. Beyond `ROUND_AFTER_MS`
18
+ * rounds to `ROUND_MS` unless `exact`. Throws on a passed fixed instant
19
+ * or one beyond `MAX_CUTOVER_LEAD_MS`. */
20
+ export declare function resolveCutover(spec: AtSpec, now: number, exact?: boolean): number;
21
+ /** `at` for an operator: the time in `CUTOVER_ZONE` and UTC, and how far
22
+ * from `now`. */
23
+ export declare function describeCutover(at: number, now: number): string;