@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,152 @@
1
+ /** A cutover's time from `--at`: a timestamp, an interval from now,
2
+ * `now`, or the default early-morning slot. */
3
+ import { ExpectedError } from "@astralarium/repo-telemetry";
4
+ /** Zone of the default cutover and of the echoed time. */
5
+ const CUTOVER_ZONE = "America/Los_Angeles";
6
+ /** Hour (in `CUTOVER_ZONE`) of the default cutover, on tomorrow's date. */
7
+ const DEFAULT_HOUR = 4;
8
+ /** Increment a distant cutover rounds to. */
9
+ const ROUND_MS = 30 * 60_000;
10
+ /** A cutover farther out than this rounds to `ROUND_MS`. */
11
+ const ROUND_AFTER_MS = 90 * 60_000;
12
+ /** Farthest a cutover schedules ahead; beyond it, the token and staged
13
+ * build are stale. */
14
+ const MAX_CUTOVER_LEAD_MS = 30 * 24 * 60 * 60_000;
15
+ /** Ms per interval unit. */
16
+ const UNIT_MS = { d: 24 * 60 * 60_000, h: 60 * 60_000, m: 60_000 };
17
+ /** `1d`, `2h`, `90m`, `1h30m`: units in descending order. */
18
+ const INTERVAL = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/;
19
+ /** An ISO 8601 date, optionally with a time (to minutes, seconds or ms)
20
+ * and its zone (`Z`, `±hh:mm`). */
21
+ const TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2})?)?$/i;
22
+ /** Parses `--at` at `now` (ms epoch): undefined = default; `now` and
23
+ * intervals relative; else ISO 8601, zone-less in `CUTOVER_ZONE`. Throws
24
+ * on malformed text. */
25
+ export function parseCutoverSpec(text, now) {
26
+ if (text === undefined)
27
+ return { kind: "fixed", at: defaultCutover(now) };
28
+ if (text === "now")
29
+ return { kind: "relative", offsetMs: 0 };
30
+ const interval = INTERVAL.exec(text);
31
+ if (interval && text !== "") {
32
+ const [, d = "0", h = "0", m = "0"] = interval;
33
+ return {
34
+ kind: "relative",
35
+ offsetMs: +d * UNIT_MS.d + +h * UNIT_MS.h + +m * UNIT_MS.m,
36
+ };
37
+ }
38
+ if (/^\d+$/.test(text))
39
+ throw new ExpectedError(`--at: "${text}" needs a unit (d, h, m)`);
40
+ const at = parseTimestamp(text);
41
+ if (at === undefined)
42
+ throw new ExpectedError(`--at: unreadable time "${text}"; expected now, an interval (1h30m) or an ISO timestamp (2026-10-09T04:00, Pacific unless zoned)`);
43
+ return { kind: "fixed", at };
44
+ }
45
+ /** The instant ISO timestamp `text` names, zone-less in `CUTOVER_ZONE`;
46
+ * undefined when malformed. */
47
+ function parseTimestamp(text) {
48
+ const match = TIMESTAMP.exec(text);
49
+ if (!match)
50
+ return undefined;
51
+ const [, y, mo, d, h = "0", mi = "0", sec = "0", frac = "0", zone] = match;
52
+ const wall = { year: +y, month: +mo, day: +d, hour: +h, minute: +mi };
53
+ const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
54
+ // Date.UTC carries overflow into the next field: a changed field was out
55
+ // of range.
56
+ const check = new Date(asUtc);
57
+ if (check.getUTCFullYear() !== wall.year ||
58
+ check.getUTCMonth() !== wall.month - 1 ||
59
+ check.getUTCDate() !== wall.day ||
60
+ check.getUTCHours() !== wall.hour ||
61
+ check.getUTCMinutes() !== wall.minute ||
62
+ +sec > 59)
63
+ return undefined;
64
+ const extraMs = +sec * 1_000 + +frac.padEnd(3, "0");
65
+ if (!zone)
66
+ return zonedInstant(wall, CUTOVER_ZONE) + extraMs;
67
+ const offset = /^([+-])(\d{2}):(\d{2})$/.exec(zone);
68
+ const offsetMs = offset
69
+ ? (offset[1] === "-" ? -1 : 1) * (+offset[2] * 60 + +offset[3]) * 60_000
70
+ : 0;
71
+ return asUtc + extraMs - offsetMs;
72
+ }
73
+ /** Cutover instant (ms epoch) of `spec` at `now`. Beyond `ROUND_AFTER_MS`
74
+ * rounds to `ROUND_MS` unless `exact`. Throws on a passed fixed instant
75
+ * or one beyond `MAX_CUTOVER_LEAD_MS`. */
76
+ export function resolveCutover(spec, now, exact = false) {
77
+ const at = spec.kind === "fixed" ? spec.at : now + spec.offsetMs;
78
+ if (spec.kind === "fixed" && at <= now)
79
+ throw new ExpectedError(`--at: ${new Date(at).toISOString()} has passed`);
80
+ const resolved = exact || at - now <= ROUND_AFTER_MS
81
+ ? at
82
+ : Math.round(at / ROUND_MS) * ROUND_MS;
83
+ if (resolved - now > MAX_CUTOVER_LEAD_MS)
84
+ throw new ExpectedError(`--at: ${new Date(resolved).toISOString()} is over ${MAX_CUTOVER_LEAD_MS / (24 * 60 * 60_000)} days out, the farthest a cutover schedules`);
85
+ return resolved;
86
+ }
87
+ /** `DEFAULT_HOUR`:00 in `CUTOVER_ZONE` on the zone's date after `now`'s. */
88
+ function defaultCutover(now) {
89
+ const today = zonedParts(now, CUTOVER_ZONE);
90
+ // Date.UTC normalizes day overflow across months and years.
91
+ const tomorrow = new Date(Date.UTC(today.year, today.month - 1, today.day + 1));
92
+ return zonedInstant({
93
+ year: tomorrow.getUTCFullYear(),
94
+ month: tomorrow.getUTCMonth() + 1,
95
+ day: tomorrow.getUTCDate(),
96
+ hour: DEFAULT_HOUR,
97
+ minute: 0,
98
+ }, CUTOVER_ZONE);
99
+ }
100
+ /** `at`'s wall clock in `zone`. */
101
+ function zonedParts(at, zone) {
102
+ const parts = new Intl.DateTimeFormat("en-US", {
103
+ timeZone: zone,
104
+ hourCycle: "h23",
105
+ year: "numeric",
106
+ month: "numeric",
107
+ day: "numeric",
108
+ hour: "numeric",
109
+ minute: "numeric",
110
+ }).formatToParts(at);
111
+ const field = (type) => Number(parts.find((p) => p.type === type)?.value);
112
+ return {
113
+ year: field("year"),
114
+ month: field("month"),
115
+ day: field("day"),
116
+ hour: field("hour"),
117
+ minute: field("minute"),
118
+ };
119
+ }
120
+ /** The instant `wall` reads in `zone`. */
121
+ function zonedInstant(wall, zone) {
122
+ const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
123
+ // Two passes: exact except inside a DST gap.
124
+ let at = asUtc;
125
+ for (let i = 0; i < 2; i++)
126
+ at = asUtc - offsetOf(at, zone);
127
+ return at;
128
+ }
129
+ /** `zone`'s UTC offset (ms) at `at`. */
130
+ function offsetOf(at, zone) {
131
+ const w = zonedParts(at, zone);
132
+ const wallAsUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
133
+ return wallAsUtc - Math.floor(at / 60_000) * 60_000;
134
+ }
135
+ /** `at` for an operator: the time in `CUTOVER_ZONE` and UTC, and how far
136
+ * from `now`. */
137
+ export function describeCutover(at, now) {
138
+ const zoned = new Intl.DateTimeFormat("en-US", {
139
+ timeZone: CUTOVER_ZONE,
140
+ weekday: "short",
141
+ month: "short",
142
+ day: "numeric",
143
+ hour: "numeric",
144
+ minute: "2-digit",
145
+ timeZoneName: "short",
146
+ }).format(at);
147
+ const minutes = Math.max(0, Math.round((at - now) / 60_000));
148
+ const away = minutes === 0
149
+ ? "now"
150
+ : `in ${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
151
+ return `${zoned} (${new Date(at).toISOString()}), ${away}`;
152
+ }
@@ -0,0 +1,62 @@
1
+ import { type InstanceStatus } from "./cloudflare.ts";
2
+ import type { SlotSession } from "./session.ts";
3
+ import { type DeployState } from "./state.ts";
4
+ import { type WebVersion } from "./web.ts";
5
+ /** A cutover run the API lists. */
6
+ interface CutoverRun {
7
+ id: string;
8
+ status: InstanceStatus;
9
+ }
10
+ /** Id of a new cutover run for `build`: `cutover-<build>-<epoch>`. */
11
+ export declare const cutoverRunId: (build: string) => string;
12
+ /** Build of cutover run `id`; undefined for another shape. */
13
+ export declare const buildOfRun: (id: string) => string | undefined;
14
+ /** What a command found after taking the lease. */
15
+ export interface Cutovers {
16
+ /** The 100% web version. */
17
+ live: WebVersion;
18
+ /** The deploy state; `noticed` once the update is due. */
19
+ state: DeployState;
20
+ /** Runs past their sleep, or owning a due update whatever their
21
+ * status (the API reports `waiting` during step retries). */
22
+ inFlight: CutoverRun[];
23
+ /** Waiting runs of the live build: released, retrying the tail (old
24
+ * slot's cron clear, a publish). Commands wait them out: the clear
25
+ * would strip crons a new `cutover` or `rollback` adds to that slot. */
26
+ finishing: CutoverRun[];
27
+ /** Waiting runs ahead of their release: owning no update, or one still
28
+ * ahead. */
29
+ terminable: string[];
30
+ /** The update once no listed run owns it: its run ended. */
31
+ orphan: {
32
+ run: string;
33
+ at: Date;
34
+ } | undefined;
35
+ }
36
+ /** Classifies `runs` against `state`. */
37
+ export declare function classify(live: WebVersion, state: DeployState, runs: readonly CutoverRun[]): Cutovers;
38
+ /** Reads the live web version, the deploy state and the cutover runs,
39
+ * classified. */
40
+ export declare function readCutovers(s: SlotSession): Promise<Cutovers>;
41
+ /** `requireIdle`'s options. */
42
+ interface IdleOptions {
43
+ /** What follows the run's end, for the operator. */
44
+ then?: string;
45
+ /** Ends finishing runs along with terminable ones: the command's own
46
+ * steps redo their tail (`settle`). */
47
+ endFinishing?: boolean;
48
+ }
49
+ /** Throws while a cutover is in flight, or finishing unless
50
+ * `endFinishing`. */
51
+ export declare function requireIdle(c: Cutovers, { then, endFinishing }?: IdleOptions): void;
52
+ /** Throws while the deploy state names a live build other than the live
53
+ * web version's: `settle` realigns them. */
54
+ export declare function requireLiveAligned(s: SlotSession, c: Cutovers): void;
55
+ /** Terminates terminable runs (finishing ones too with `endFinishing`),
56
+ * withdrawing each one's update: a dead run's update closes the app ahead
57
+ * of nothing. Re-reads first: an update may have turned due since the
58
+ * command's read. Resolves to the fresh classification. */
59
+ export declare function terminateCutovers(s: SlotSession, options?: IdleOptions): Promise<Cutovers>;
60
+ /** Withdraws the orphaned update, fenced. Resolves whether one was there. */
61
+ export declare function withdrawOrphan(s: SlotSession, c: Cutovers): Promise<boolean>;
62
+ export {};
@@ -0,0 +1,112 @@
1
+ /** Cutover runs and the scheduled update, classified against the deploy
2
+ * state and the live web version. */
3
+ import { ExpectedError, log } from "@astralarium/repo-telemetry";
4
+ import { epoch, RUNNING, WAITING } from "./cloudflare.js";
5
+ import { abortReason } from "./process.js";
6
+ import { readDeployState, withdrawUpdateIn, } from "./state.js";
7
+ import { readLive } from "./web.js";
8
+ /** Id of a new cutover run for `build`: `cutover-<build>-<epoch>`. */
9
+ export const cutoverRunId = (build) => `cutover-${build}-${epoch()}`;
10
+ /** Build of cutover run `id`; undefined for another shape. */
11
+ export const buildOfRun = (id) => /^cutover-(.+)-\d+$/.exec(id)?.[1];
12
+ /** Statuses a listing covers: every unfinished run. */
13
+ const LISTED = [...RUNNING, ...WAITING];
14
+ /** Every unfinished cutover run; throws naming `settle` when the listing
15
+ * fails. */
16
+ async function listRuns(s) {
17
+ const flow = s.target.flows.cutover;
18
+ try {
19
+ const listed = await Promise.all(LISTED.map(async (status) => (await s.cf.instances(flow, status)).map((id) => ({ id, status }))));
20
+ return listed.flat();
21
+ }
22
+ catch (e) {
23
+ if (s.signal.aborted)
24
+ throw abortReason(s.signal);
25
+ throw new ExpectedError(`The cutover runs are unreadable (${e instanceof Error ? e.message : String(e)}): retry; ${s.cli("settle")} realigns the deployment once they read.`, { cause: e });
26
+ }
27
+ }
28
+ /** Classifies `runs` against `state`. */
29
+ export function classify(live, state, runs) {
30
+ const inFlight = [];
31
+ const finishing = [];
32
+ const terminable = [];
33
+ for (const run of runs) {
34
+ const owner = run.id === state.update_run;
35
+ if (RUNNING.includes(run.status) || (owner && state.noticed))
36
+ inFlight.push(run);
37
+ else if (buildOfRun(run.id) === state.live_build)
38
+ finishing.push(run);
39
+ else
40
+ terminable.push(run.id);
41
+ }
42
+ const owned = runs.some((run) => run.id === state.update_run);
43
+ const orphan = state.update_run !== null && state.update_at !== null && !owned
44
+ ? { run: state.update_run, at: state.update_at }
45
+ : undefined;
46
+ return { live, state, inFlight, finishing, terminable, orphan };
47
+ }
48
+ /** Reads the live web version, the deploy state and the cutover runs,
49
+ * classified. */
50
+ export async function readCutovers(s) {
51
+ const live = await readLive(s);
52
+ const [state, runs] = await Promise.all([
53
+ readDeployState(s.sql, 0),
54
+ listRuns(s),
55
+ ]);
56
+ return classify(live, state, runs);
57
+ }
58
+ /** `runs` grouped by status: `running: a b; waiting: c`. */
59
+ function describeRuns(runs) {
60
+ const byStatus = new Map();
61
+ for (const { id, status } of runs) {
62
+ const ids = byStatus.get(status) ?? [];
63
+ ids.push(id);
64
+ byStatus.set(status, ids);
65
+ }
66
+ return [...byStatus]
67
+ .map(([status, ids]) => `${status}: ${ids.join(" ")}`)
68
+ .join("; ");
69
+ }
70
+ /** Throws while a cutover is in flight, or finishing unless
71
+ * `endFinishing`. */
72
+ export function requireIdle(c, { then = "retry once it ends.", endFinishing = false } = {}) {
73
+ if (c.inFlight.length)
74
+ throw new ExpectedError(`A cutover is in progress (${describeRuns(c.inFlight)}); ${then}`);
75
+ if (c.finishing.length && !endFinishing)
76
+ throw new ExpectedError(`A cutover is finishing (${describeRuns(c.finishing)}); ${then}`);
77
+ }
78
+ /** Throws while the deploy state names a live build other than the live
79
+ * web version's: `settle` realigns them. */
80
+ export function requireLiveAligned(s, c) {
81
+ const { live_build } = c.state;
82
+ if (live_build !== null && live_build !== c.live.build)
83
+ throw new ExpectedError(`The deploy state names build '${live_build}' live but the live web version is build '${c.live.build}'; run ${s.cli("settle")} first.`);
84
+ }
85
+ /** Terminates terminable runs (finishing ones too with `endFinishing`),
86
+ * withdrawing each one's update: a dead run's update closes the app ahead
87
+ * of nothing. Re-reads first: an update may have turned due since the
88
+ * command's read. Resolves to the fresh classification. */
89
+ export async function terminateCutovers(s, options = {}) {
90
+ const c = await readCutovers(s);
91
+ requireIdle(c, options);
92
+ const ending = options.endFinishing
93
+ ? [...c.terminable, ...c.finishing.map((run) => run.id)]
94
+ : c.terminable;
95
+ for (const id of ending) {
96
+ await s.cf.terminate(s.target.flows.cutover, id);
97
+ const outcome = await s.lease.fenced((tx) => withdrawUpdateIn(tx, id));
98
+ if (outcome === "withdrawn")
99
+ log.info("deploy_release.update_withdrawn", "Withdrew the update of terminated cutover run {run}", { run: id });
100
+ }
101
+ return c;
102
+ }
103
+ /** Withdraws the orphaned update, fenced. Resolves whether one was there. */
104
+ export async function withdrawOrphan(s, c) {
105
+ if (!c.orphan)
106
+ return false;
107
+ const { run } = c.orphan;
108
+ const outcome = await s.lease.fenced((tx) => withdrawUpdateIn(tx, run));
109
+ if (outcome === "withdrawn")
110
+ log.info("deploy_release.orphan_withdrawn", "Withdrew the update at {at} of ended cutover run {run}", { at: c.orphan.at.toISOString(), run: c.orphan.run });
111
+ return true;
112
+ }
@@ -0,0 +1,36 @@
1
+ import type { SlotTarget } from "./target.ts";
2
+ /** `target`'s deployer wrangler config, entry `main`, on the slotted
3
+ * worker's runtime; its `env.<env>` block holds the workflows,
4
+ * `DEPLOYMENT` and each slot's control. */
5
+ export declare function deployerConfig(target: SlotTarget, main: string): {
6
+ name: string;
7
+ main: string;
8
+ compatibility_date: string;
9
+ compatibility_flags: string[];
10
+ observability: {
11
+ enabled: boolean;
12
+ };
13
+ workers_dev: boolean;
14
+ preview_urls: boolean;
15
+ env: {
16
+ [x: string]: {
17
+ name: string;
18
+ workflows: {
19
+ name: string;
20
+ binding: "CUTOVER" | "SETTLE" | "DRAIN";
21
+ class_name: string;
22
+ }[];
23
+ vars: {
24
+ DEPLOYMENT: import("@astralarium/repo-deploy-info").Deployment;
25
+ };
26
+ services: {
27
+ binding: "CONTROL_BLUE" | "CONTROL_GREEN";
28
+ service: string;
29
+ entrypoint: string;
30
+ }[];
31
+ };
32
+ };
33
+ };
34
+ /** Writes `target`'s deployer into `dir`: `DEPLOYER_ENTRY` and
35
+ * `wrangler.json` (`deployerConfig`); returns the config's path. */
36
+ export declare function writeDeployer(dir: string, target: SlotTarget): Promise<string>;
@@ -0,0 +1,63 @@
1
+ /** The deployer worker of a blue/green env: `@astralarium/repo-deploy-workflows/deployer`
2
+ * under a wrangler config `deploy-release` generates from the target. */
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { CONTROL_ENTRYPOINT, controlBinding, DEPLOYER_CLASSES, DEPLOYER_FLOWS, DEPLOYMENT_VAR, SLOTS, } from "@astralarium/repo-deploy-info";
7
+ /** Entry file of the generated deployer, beside its config: re-exports
8
+ * `@astralarium/repo-deploy-workflows/deployer`, so the bundle lands as `index.js`. */
9
+ const DEPLOYER_ENTRY = "index.js";
10
+ /** Flag the deployer adds to the slotted worker's: the site check
11
+ * reaches the site through its public routing. */
12
+ const PUBLIC_FETCH_FLAG = "global_fetch_strictly_public";
13
+ /** `target`'s deployer wrangler config, entry `main`, on the slotted
14
+ * worker's runtime; its `env.<env>` block holds the workflows,
15
+ * `DEPLOYMENT` and each slot's control. */
16
+ export function deployerConfig(target, main) {
17
+ const { env, deployer, deployment, flows, runtime } = target;
18
+ return {
19
+ name: deployer,
20
+ main,
21
+ compatibility_date: runtime.compatibilityDate,
22
+ compatibility_flags: [
23
+ ...new Set([...runtime.compatibilityFlags, PUBLIC_FETCH_FLAG]),
24
+ ],
25
+ observability: { enabled: true },
26
+ // Reached only through the Workflows API.
27
+ workers_dev: false,
28
+ preview_urls: false,
29
+ env: {
30
+ [env]: {
31
+ name: deployer,
32
+ workflows: Object.keys(flows).flatMap((flow) => {
33
+ const name = flows[flow];
34
+ return name
35
+ ? [
36
+ {
37
+ name,
38
+ binding: DEPLOYER_FLOWS[flow],
39
+ class_name: DEPLOYER_CLASSES[flow],
40
+ },
41
+ ]
42
+ : [];
43
+ }),
44
+ vars: { [DEPLOYMENT_VAR]: deployment },
45
+ services: SLOTS.map((slot) => ({
46
+ binding: controlBinding(slot),
47
+ service: deployment.slotScripts[slot],
48
+ entrypoint: CONTROL_ENTRYPOINT,
49
+ })),
50
+ },
51
+ },
52
+ };
53
+ }
54
+ /** Writes `target`'s deployer into `dir`: `DEPLOYER_ENTRY` and
55
+ * `wrangler.json` (`deployerConfig`); returns the config's path. */
56
+ export async function writeDeployer(dir, target) {
57
+ const config = join(dir, "wrangler.json");
58
+ const module = JSON.stringify(fileURLToPath(import.meta.resolve("@astralarium/repo-deploy-workflows/deployer")));
59
+ await mkdir(dir, { recursive: true });
60
+ await writeFile(join(dir, DEPLOYER_ENTRY), `export * from ${module};\nexport { default } from ${module};\n`);
61
+ await writeFile(config, JSON.stringify(deployerConfig(target, DEPLOYER_ENTRY)));
62
+ return config;
63
+ }
@@ -0,0 +1,2 @@
1
+ /** `deploy-release`'s app declaration: `defineReleaseApp` and its types. */
2
+ export { type BlueGreenEnv, defineReleaseApp, type EnvRelease, type InPlaceEnv, type OngoingWork, type ReleaseApp, type RetireBuilds, type SlotPackages, type SlotSecrets, type WorkerBuild, type WorkerSpec, } from "./app.ts";
@@ -0,0 +1,2 @@
1
+ /** `deploy-release`'s app declaration: `defineReleaseApp` and its types. */
2
+ export { defineReleaseApp, } from "./app.js";
@@ -0,0 +1,36 @@
1
+ import { type Sql, type TransactionSql } from "@astralarium/repo-postgres/sql";
2
+ /** The commands that change the deployment, as their lease names them:
3
+ * the `deploy.operation` values. Keep in step with `sql/schema.sql`. */
4
+ export declare const DEPLOY_OPERATIONS: readonly ["init", "stage", "deploy", "cutover", "rollback", "settle", "cancel", "secrets"];
5
+ /** A command that changes the deployment (`DEPLOY_OPERATIONS`). */
6
+ export type DeployOperation = (typeof DEPLOY_OPERATIONS)[number];
7
+ /** The lease as it stands. */
8
+ export interface LeaseRow {
9
+ holder: string;
10
+ operation: DeployOperation;
11
+ expires_at: Date;
12
+ }
13
+ /** `row` for an operator: holder, operation, expiry. */
14
+ export declare const describeLease: (row: LeaseRow) => string;
15
+ /** A held lease. */
16
+ export declare class Lease {
17
+ #private;
18
+ readonly holder: string;
19
+ private constructor();
20
+ /** Takes the lease for `operation`, renewing it while held; `onLost`
21
+ * hears of it lapsing or passing to another holder. Throws while
22
+ * another holds it, naming `unlock`, the command line that breaks it. */
23
+ static take(sql: Sql, operation: DeployOperation, { onLost, unlock }: {
24
+ onLost: (error: Error) => void;
25
+ unlock: string;
26
+ }): Promise<Lease>;
27
+ /** Runs `fn` in a transaction locked behind the lease; throws once it is
28
+ * lost. */
29
+ fenced<T>(fn: (tx: TransactionSql) => Promise<T>): Promise<T>;
30
+ /** Stops renewing and drops the lease. */
31
+ release(): Promise<void>;
32
+ }
33
+ /** The lease; undefined while free. */
34
+ export declare function readLease(sql: Sql): Promise<LeaseRow | undefined>;
35
+ /** Drops `holder`'s lease; false when another holds it now. */
36
+ export declare function breakLease(sql: Sql, holder: string): Promise<boolean>;
@@ -0,0 +1,112 @@
1
+ /** The deploy lease (`deploy.state`): one mutating command at a time.
2
+ * A heartbeat renews it; a lapse loses it, failing fenced writes. */
3
+ import { hostname, userInfo } from "node:os";
4
+ import { isSqlstate, SQLSTATE, } from "@astralarium/repo-postgres/sql";
5
+ import { ExpectedError, log } from "@astralarium/repo-telemetry";
6
+ /** The commands that change the deployment, as their lease names them:
7
+ * the `deploy.operation` values. Keep in step with `sql/schema.sql`. */
8
+ export const DEPLOY_OPERATIONS = [
9
+ "init",
10
+ "stage",
11
+ "deploy",
12
+ "cutover",
13
+ "rollback",
14
+ "settle",
15
+ "cancel",
16
+ "secrets",
17
+ ];
18
+ /** Between heartbeat renewals. */
19
+ const RENEW_MS = 5 * 60_000;
20
+ /** How long a lease holds past its last renewal. */
21
+ const LEASE_TTL = "1 hour";
22
+ /** `row` for an operator: holder, operation, expiry. */
23
+ export const describeLease = (row) => `${row.holder} (${row.operation}, until ${row.expires_at.toISOString()})`;
24
+ /** This invocation as a lease holder: `user@host pid N`. */
25
+ const leaseHolder = () => `${userInfo().username}@${hostname().split(".")[0]} pid ${process.pid}`;
26
+ /** A held lease. */
27
+ export class Lease {
28
+ #sql;
29
+ #heartbeat;
30
+ /** The heartbeat's renewal in flight; `release` awaits it. */
31
+ #renewal = Promise.resolve();
32
+ holder;
33
+ constructor(sql, holder, onLost) {
34
+ this.#sql = sql;
35
+ this.holder = holder;
36
+ this.#heartbeat = setInterval(() => {
37
+ this.#renewal = renewLease(sql, holder).then(({ renewed, lease }) => {
38
+ if (renewed)
39
+ return;
40
+ clearInterval(this.#heartbeat);
41
+ onLost(new ExpectedError(lease
42
+ ? `Lost the deploy lease to ${describeLease(lease)}.`
43
+ : "Lost the deploy lease: it lapsed or was broken."));
44
+ }, (e) => log.warn("deploy_release.lease_renewal_failed", "deploy lease renewal failed; retrying", { exception: e }));
45
+ }, RENEW_MS).unref();
46
+ }
47
+ /** Takes the lease for `operation`, renewing it while held; `onLost`
48
+ * hears of it lapsing or passing to another holder. Throws while
49
+ * another holds it, naming `unlock`, the command line that breaks it. */
50
+ static async take(sql, operation, { onLost, unlock }) {
51
+ const holder = leaseHolder();
52
+ let row;
53
+ try {
54
+ row = await takeLease(sql, holder, operation);
55
+ }
56
+ catch (e) {
57
+ throw new ExpectedError(isSqlstate(e, SQLSTATE.insufficientPrivilege)
58
+ ? "The database role may not take the deploy lease: it needs EXECUTE on the deploy.*_lease functions and UPDATE on deploy.state, both granted to app_service."
59
+ : "Could not take the deploy lease.", { cause: e });
60
+ }
61
+ if (!row.taken)
62
+ throw new ExpectedError(`The deployment is leased: ${describeLease(row)}. Wait for it, or run ${unlock} if that command died.`);
63
+ return new Lease(sql, holder, onLost);
64
+ }
65
+ /** Runs `fn` in a transaction locked behind the lease; throws once it is
66
+ * lost. */
67
+ fenced(fn) {
68
+ return this.#sql.begin(async (tx) => {
69
+ await tx `SELECT deploy.fence_lease(${this.holder})`;
70
+ return fn(tx);
71
+ });
72
+ }
73
+ /** Stops renewing and drops the lease. */
74
+ async release() {
75
+ clearInterval(this.#heartbeat);
76
+ await this.#renewal;
77
+ await this.#sql `SELECT deploy.drop_lease(${this.holder})`;
78
+ }
79
+ }
80
+ /** Takes the lease or renews `holder`'s, keeping its operation; reports
81
+ * it as it stands. */
82
+ async function takeLease(sql, holder, operation) {
83
+ const [row] = await sql `
84
+ SELECT * FROM deploy.take_lease(
85
+ ${holder}, ${operation}::deploy.operation, ${LEASE_TTL}::interval)
86
+ `;
87
+ return row;
88
+ }
89
+ /** Extends `holder`'s unexpired lease; reports the lease as it stands,
90
+ * undefined while free. */
91
+ async function renewLease(sql, holder) {
92
+ const [{ renewed, ...row }] = await sql `
93
+ SELECT * FROM deploy.renew_lease(${holder}, ${LEASE_TTL}::interval)
94
+ `;
95
+ return { renewed, lease: row.holder ? row : undefined };
96
+ }
97
+ /** The lease; undefined while free. */
98
+ export async function readLease(sql) {
99
+ const [row] = await sql `
100
+ SELECT lease_holder AS holder, lease_operation AS operation,
101
+ lease_expires_at AS expires_at
102
+ FROM deploy.state
103
+ `;
104
+ return row.holder ? row : undefined;
105
+ }
106
+ /** Drops `holder`'s lease; false when another holds it now. */
107
+ export async function breakLease(sql, holder) {
108
+ const [{ dropped }] = await sql `
109
+ SELECT deploy.drop_lease(${holder}) AS dropped
110
+ `;
111
+ return dropped !== null;
112
+ }
@@ -0,0 +1,6 @@
1
+ import { type ReleaseApp } from "./app.ts";
2
+ /** Runs `argv` (after the program name, carrying `--app <module>`)
3
+ * against the app `load` resolves the module path to, setting
4
+ * `process.exitCode`; a failure to load the app or read its targets
5
+ * logs like a command's. */
6
+ export declare function main(argv: readonly string[], load: (app: string) => ReleaseApp | Promise<ReleaseApp>): Promise<void>;
@@ -0,0 +1,43 @@
1
+ /** Runs one `deploy-release` command line for an app. */
2
+ import { appArgs } from "@astralarium/repo-deploy-info/server";
3
+ import { buildApplication, ExitCode, run } from "@stricli/core";
4
+ import { PROGRAM } from "./app.js";
5
+ import { commands } from "./commands.js";
6
+ import { reportFailure } from "./process.js";
7
+ /** Exit status of a usage error. */
8
+ const USAGE_EXIT = 2;
9
+ /** What a command line lacking `--app` reports. */
10
+ const USAGE = `Usage: ${PROGRAM} --app <module> <command> [options]`;
11
+ /** Runs `argv` (after the program name, carrying `--app <module>`)
12
+ * against the app `load` resolves the module path to, setting
13
+ * `process.exitCode`; a failure to load the app or read its targets
14
+ * logs like a command's. */
15
+ export async function main(argv, load) {
16
+ let args;
17
+ let routes;
18
+ try {
19
+ args = appArgs(argv, USAGE);
20
+ }
21
+ catch (error) {
22
+ reportFailure(error);
23
+ process.exitCode = USAGE_EXIT;
24
+ return;
25
+ }
26
+ const invocation = `${PROGRAM} --app ${args.app}`;
27
+ try {
28
+ routes = commands(await load(args.app), invocation);
29
+ }
30
+ catch (error) {
31
+ reportFailure(error);
32
+ return;
33
+ }
34
+ const application = buildApplication(routes, {
35
+ name: invocation,
36
+ scanner: { caseStyle: "allow-kebab-for-camel" },
37
+ documentation: { caseStyle: "convert-camel-to-kebab" },
38
+ });
39
+ await run(application, args.argv, { process });
40
+ if (process.exitCode === ExitCode.InvalidArgument ||
41
+ process.exitCode === ExitCode.UnknownCommand)
42
+ process.exitCode = USAGE_EXIT;
43
+ }
@@ -0,0 +1,34 @@
1
+ import { ExpectedError } from "@astralarium/repo-telemetry";
2
+ /** Exit status after Ctrl-C. */
3
+ export declare const INTERRUPT_EXIT = 130;
4
+ /** Ctrl-C: exits `INTERRUPT_EXIT`. */
5
+ export declare class Interrupted extends ExpectedError {
6
+ constructor();
7
+ }
8
+ /** SIGKILLs every running child. */
9
+ export declare function killChildren(): void;
10
+ /** Logs `error` as the command's failure and sets the exit status:
11
+ * `INTERRUPT_EXIT` after Ctrl-C, else 1. */
12
+ export declare function reportFailure(error: unknown): void;
13
+ /** Why `signal` aborted, as an error. */
14
+ export declare const abortReason: (signal: AbortSignal) => Error;
15
+ /** Variables over a child's inherited environment; an undefined value
16
+ * removes one. */
17
+ export type EnvVars = Readonly<Record<string, string | undefined>>;
18
+ /** How `run` wires a child. */
19
+ export interface RunOptions {
20
+ cwd?: string;
21
+ env?: EnvVars;
22
+ /** Written to stdin; stdin is inherited otherwise. */
23
+ input?: string;
24
+ /** Resolve to stdout instead of passing it through. */
25
+ capture?: boolean;
26
+ /** Hold stderr, showing it only on failure. */
27
+ quiet?: boolean;
28
+ }
29
+ /** Runs `cmd` with `args` until it exits; resolves to its stdout when
30
+ * captured. Throws on a nonzero exit, and with `signal`'s reason once it
31
+ * aborts (killing the child). */
32
+ export declare function run(cmd: string, args: readonly string[], signal: AbortSignal, { cwd, env, input, capture, quiet }?: RunOptions): Promise<string>;
33
+ /** Resolves after `ms`; throws `signal`'s reason once it aborts. */
34
+ export declare const sleep: (ms: number, signal: AbortSignal) => Promise<undefined>;