@astralarium/repo-deploy-workflows 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Astralarium
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,4 @@
1
- # Temporary Holding Version
1
+ # `@astralarium/repo-deploy-workflows`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The blue/green deploy worker for `@astralarium/repo-deploy-release`:
4
+ cutover, settle and drain workflows.
@@ -0,0 +1,81 @@
1
+ /** Cutover, settle and drain workflow timelines, over the slots' ops
2
+ * controls and the Cloudflare API. */
3
+ import { type CfApi, type CutoverOutcome, type CutoverParams, type Deployment, type DEPLOYMENT_VAR, type DeploymentVersion, type DrainParams, type OpsControlRpc, type SettleParams, type Slot } from "@astralarium/repo-deploy-info";
4
+ import type { WorkflowStep, WorkflowStepConfig } from "cloudflare:workers";
5
+ /** A precondition failed; the cutover makes no change. */
6
+ export declare class PreconditionError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** Narrows a workflow payload to `CutoverParams`; throws
10
+ * `PreconditionError` on a malformed one. */
11
+ export declare function parseParams(payload: unknown): CutoverParams;
12
+ /** Narrows a workflow payload to `SettleParams`; throws
13
+ * `PreconditionError` on a malformed one. */
14
+ export declare function parseSettleParams(payload: unknown): SettleParams;
15
+ /** Narrows a workflow payload to `DrainParams`; throws
16
+ * `PreconditionError` on a malformed one. */
17
+ export declare function parseDrainParams(payload: unknown): DrainParams;
18
+ /** The deployer env's vars and secrets the timelines read. */
19
+ export interface SlotDeployerEnv {
20
+ /** Scripts and site of the deploy. */
21
+ [DEPLOYMENT_VAR]: Deployment;
22
+ /** Short-lived "Workers Scripts: Edit" token `deploy-release` puts
23
+ * before each cutover. */
24
+ CLOUDFLARE_API_TOKEN?: string;
25
+ }
26
+ /** `env`'s Cloudflare API over `params.accountId`; throws
27
+ * `PreconditionError` while the token is unset. */
28
+ export declare function deployerApi(env: SlotDeployerEnv, params: CutoverParams): CfApi;
29
+ /** The web deployment before the switch: live@100, next@0. */
30
+ export declare const preSwitch: (params: CutoverParams) => DeploymentVersion[];
31
+ /** Resolves to the live slot's crons once the web deployment is
32
+ * live@100 + next@0, next names `params.build`, and the live slot holds
33
+ * crons; throws `PreconditionError` otherwise. */
34
+ export declare function verifyCutover(api: CfApi, deployment: Deployment, params: CutoverParams): Promise<string[]>;
35
+ /** Throws unless the public site answers `build` on every `paths` path. */
36
+ export declare function checkSite(fetchFn: typeof fetch, siteOrigin: string, paths: readonly string[], build: string): Promise<void>;
37
+ /** Slot control RPCs: seconds to minutes. */
38
+ export declare const RPC_STEP: WorkflowStepConfig;
39
+ /** Release: the next build already serves the site, so it retries ~30 min. */
40
+ export declare const CONVERGE_STEP: WorkflowStepConfig;
41
+ /** Draining the live slot: short budget ahead of the token-bound steps. */
42
+ export declare const DRAIN_STEP: WorkflowStepConfig;
43
+ /** The site check: retries while the switch propagates. */
44
+ export declare const SITE_STEP: WorkflowStepConfig;
45
+ /** Cloudflare API calls. */
46
+ export declare const API_STEP: WorkflowStepConfig;
47
+ /** Database-clock step: retries up to an hour while that clock trails the
48
+ * deployer's. */
49
+ export declare const CLOCK_STEP: WorkflowStepConfig;
50
+ /** What a timeline acts through. */
51
+ export interface TimelineDeps {
52
+ deployment: Deployment;
53
+ /** Each slot's ops controls. */
54
+ controls: Record<Slot, OpsControlRpc>;
55
+ api: CfApi;
56
+ /** Reaches the public site. */
57
+ fetch: typeof fetch;
58
+ }
59
+ /**
60
+ * Hands the site to the next build at `cutoverAt`. Closes the live build's
61
+ * queues; publishes the notice `UPDATE_NOTICE_LEAD_MS` ahead; at the
62
+ * cutover (database clock) verifies (fence included), drains the live
63
+ * slot (`params.drain`), adds crons to the idle slot, deploys the next web
64
+ * version, checks the site (`params.buildPaths`),
65
+ * releases (names the build live + withdraws the update in one
66
+ * transaction), and clears the live slot's crons. Both slots hold crons
67
+ * across the release; the live-build gate runs one. Steps are idempotent.
68
+ *
69
+ * `run` (workflow instance) owns the update until a later run's close takes
70
+ * it over. A refused close or superseded update ends the run unchanged. A
71
+ * failure before the release restores the web, clears idle crons, withdraws
72
+ * the update, and rethrows. Past the release, cron clear and publish are
73
+ * best effort.
74
+ */
75
+ export declare function runCutover(run: string, params: CutoverParams, step: WorkflowStep, deps: TimelineDeps): Promise<CutoverOutcome>;
76
+ /** Names the live build and withdraws any update in one write, reopening
77
+ * every queue. `deploy-release` moves the crons. */
78
+ export declare function runSettle(params: SettleParams, step: WorkflowStep, deps: Pick<TimelineDeps, "controls">): Promise<void>;
79
+ /** Ends the ongoing work of `params.slot`'s build in one step
80
+ * (`drainSlot`); a failed precondition fails it without retries. */
81
+ export declare function runDrain(params: DrainParams, step: WorkflowStep, deps: Pick<TimelineDeps, "controls">): Promise<void>;
@@ -0,0 +1,316 @@
1
+ /** Cutover, settle and drain workflow timelines, over the slots' ops
2
+ * controls and the Cloudflare API. */
3
+ import { buildOf, cfApi, deployScript, isForeignBuild, isSlot, putScriptCrons, scriptCrons, scriptDeployments, scriptVersion, SLOTS, UPDATE_NOTICE_LEAD_MS, } from "@astralarium/repo-deploy-info";
4
+ import { log } from "@astralarium/repo-telemetry";
5
+ import { NonRetryableError } from "cloudflare:workflows";
6
+ /** A precondition failed; the cutover makes no change. */
7
+ export class PreconditionError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "PreconditionError";
11
+ }
12
+ }
13
+ /** Narrows a workflow payload to `CutoverParams`; throws
14
+ * `PreconditionError` on a malformed one. */
15
+ export function parseParams(payload) {
16
+ const p = (payload ?? {});
17
+ const strings = [
18
+ p.accountId,
19
+ p.liveVersion,
20
+ p.nextVersion,
21
+ p.build,
22
+ p.cutoverAt,
23
+ ];
24
+ if (!strings.every((s) => typeof s === "string" && s !== ""))
25
+ throw new PreconditionError("cutover params: missing field");
26
+ if (!isSlot(p.liveSlot) || !isSlot(p.idleSlot))
27
+ throw new PreconditionError("cutover params: unknown slot");
28
+ if (p.liveSlot === p.idleSlot)
29
+ throw new PreconditionError("cutover params: idle slot is the live slot");
30
+ if (Number.isNaN(Date.parse(p.cutoverAt ?? "")))
31
+ throw new PreconditionError("cutover params: malformed cutoverAt");
32
+ if (!Array.isArray(p.buildPaths) ||
33
+ p.buildPaths.length === 0 ||
34
+ !p.buildPaths.every((path) => typeof path === "string" && path.startsWith("/")))
35
+ throw new PreconditionError("cutover params: malformed buildPaths");
36
+ if (typeof p.drain !== "boolean")
37
+ throw new PreconditionError("cutover params: missing drain");
38
+ return p;
39
+ }
40
+ /** Narrows a workflow payload to `SettleParams`; throws
41
+ * `PreconditionError` on a malformed one. */
42
+ export function parseSettleParams(payload) {
43
+ const { slot, build } = (payload ?? {});
44
+ if (!isSlot(slot))
45
+ throw new PreconditionError("settle params: unknown slot");
46
+ if (typeof build !== "string" || build === "")
47
+ throw new PreconditionError("settle params: missing build");
48
+ return { slot, build };
49
+ }
50
+ /** Narrows a workflow payload to `DrainParams`; throws
51
+ * `PreconditionError` on a malformed one. */
52
+ export function parseDrainParams(payload) {
53
+ const { slot } = (payload ?? {});
54
+ if (!isSlot(slot))
55
+ throw new PreconditionError("drain params: unknown slot");
56
+ return { slot };
57
+ }
58
+ /** `env`'s Cloudflare API over `params.accountId`; throws
59
+ * `PreconditionError` while the token is unset. */
60
+ export function deployerApi(env, params) {
61
+ const token = env.CLOUDFLARE_API_TOKEN;
62
+ if (!token)
63
+ throw new PreconditionError("CLOUDFLARE_API_TOKEN is unset");
64
+ return cfApi(token, params.accountId);
65
+ }
66
+ /** `versions` as a comparable `id@percent` list. */
67
+ const splitOf = (versions) => versions
68
+ .map((v) => `${v.version_id}@${v.percentage}`)
69
+ .sort()
70
+ .join(" ");
71
+ /** The web script's live deployment as `splitOf` reads it. */
72
+ async function liveSplit(api, webScript) {
73
+ const [latest] = await scriptDeployments(api, webScript);
74
+ return splitOf(latest?.versions ?? []);
75
+ }
76
+ /** The web deployment before the switch: live@100, next@0. */
77
+ export const preSwitch = (params) => [
78
+ { version_id: params.liveVersion, percentage: 100 },
79
+ { version_id: params.nextVersion, percentage: 0 },
80
+ ];
81
+ /** Resolves to the live slot's crons once the web deployment is
82
+ * live@100 + next@0, next names `params.build`, and the live slot holds
83
+ * crons; throws `PreconditionError` otherwise. */
84
+ export async function verifyCutover(api, deployment, params) {
85
+ const web = deployment.webScript;
86
+ const split = await liveSplit(api, web);
87
+ const expected = splitOf(preSwitch(params));
88
+ if (split !== expected)
89
+ throw new PreconditionError(`${web} deploys ${split || "nothing"}, expected ${expected}`);
90
+ const build = buildOf(await scriptVersion(api, web, params.nextVersion));
91
+ if (build !== params.build)
92
+ throw new PreconditionError(`next version ${params.nextVersion} is build "${build}", expected "${params.build}"`);
93
+ const liveScript = deployment.slotScripts[params.liveSlot];
94
+ const crons = await scriptCrons(api, liveScript);
95
+ if (crons.length === 0)
96
+ throw new PreconditionError(`${liveScript} holds no crons`);
97
+ return crons;
98
+ }
99
+ /** Restores `webScript`'s pre-switch deployment; no-op if already there. */
100
+ async function restoreWeb(api, webScript, params) {
101
+ const before = preSwitch(params);
102
+ if ((await liveSplit(api, webScript)) === splitOf(before))
103
+ return;
104
+ await deployScript(api, webScript, before, `cutover to ${params.build} failed; back to the live version`);
105
+ }
106
+ /** Throws unless the public site answers `build` on every `paths` path. */
107
+ export async function checkSite(fetchFn, siteOrigin, paths, build) {
108
+ for (const path of paths) {
109
+ const url = new URL(path, siteOrigin);
110
+ url.searchParams.set("cutover", build);
111
+ const res = await fetchFn(url, {
112
+ headers: { "Cache-Control": "no-cache" },
113
+ });
114
+ const body = (await res.json().catch(() => null));
115
+ if (!res.ok || body?.build !== build)
116
+ throw new Error(`${path} answers ${res.status} build "${String(body?.build)}", expected "${build}"`);
117
+ }
118
+ }
119
+ /** Slot control RPCs: seconds to minutes. */
120
+ export const RPC_STEP = {
121
+ retries: { limit: 5, delay: "15 seconds", backoff: "exponential" },
122
+ timeout: "10 minutes",
123
+ };
124
+ /** Release: the next build already serves the site, so it retries ~30 min. */
125
+ export const CONVERGE_STEP = {
126
+ retries: { limit: 15, delay: "1 minute", backoff: "constant" },
127
+ timeout: "1 minute",
128
+ };
129
+ /** Draining the live slot: short budget ahead of the token-bound steps. */
130
+ export const DRAIN_STEP = {
131
+ retries: { limit: 3, delay: "10 seconds", backoff: "exponential" },
132
+ timeout: "2 minutes",
133
+ };
134
+ /** The site check: retries while the switch propagates. */
135
+ export const SITE_STEP = {
136
+ retries: { limit: 6, delay: "10 seconds", backoff: "exponential" },
137
+ timeout: "1 minute",
138
+ };
139
+ /** Cloudflare API calls. */
140
+ export const API_STEP = {
141
+ retries: { limit: 5, delay: "10 seconds", backoff: "exponential" },
142
+ timeout: "1 minute",
143
+ };
144
+ /** Database-clock step: retries up to an hour while that clock trails the
145
+ * deployer's. */
146
+ export const CLOCK_STEP = {
147
+ retries: { limit: 60, delay: "1 minute", backoff: "constant" },
148
+ timeout: "10 minutes",
149
+ };
150
+ /** Ends the ongoing work of `slot`'s build through its control's `drain`;
151
+ * resolves to the count ended. Throws `PreconditionError` on an
152
+ * in-process control lacking `drain`; an RPC stub answers every method,
153
+ * so `BlueGreenEnv.drain` gates RPC controls. */
154
+ function drainSlot(controls, slot) {
155
+ const control = controls[slot];
156
+ if (!control.drain)
157
+ throw new PreconditionError(`slot ${slot} has no drain`);
158
+ return control.drain();
159
+ }
160
+ /** Throws `PreconditionError` unless `slot`'s worker reports `build`
161
+ * (trusted over version annotations). */
162
+ async function expectBuild(controls, slot, build) {
163
+ const actual = await controls[slot].build();
164
+ if (actual !== build)
165
+ throw new PreconditionError(`slot ${slot} runs build "${actual}", expected "${build}"`);
166
+ }
167
+ /** Runs `fn`, failing its step without retries on a `PreconditionError`
168
+ * or a slot's `ForeignBuildError`. */
169
+ async function precondition(fn) {
170
+ try {
171
+ return await fn();
172
+ }
173
+ catch (e) {
174
+ if (e instanceof PreconditionError || isForeignBuild(e))
175
+ throw new NonRetryableError(e.message);
176
+ throw e;
177
+ }
178
+ }
179
+ /** Publishes the deploy state on every slot. */
180
+ async function publishSlots(controls) {
181
+ await Promise.all(SLOTS.map((s) => controls[s].publish()));
182
+ }
183
+ /** Publishes the deploy state on both slots in one step. */
184
+ async function publishBoth(step, name, { controls }) {
185
+ await step.do(name, RPC_STEP, () => publishSlots(controls));
186
+ }
187
+ /** Database-clock step: false once another run took over `run`'s update;
188
+ * retries until `reached`; then runs `then` and returns true. */
189
+ async function awaitClock(step, name, live, run, reached, then = () => Promise.resolve()) {
190
+ return step.do(name, CLOCK_STEP, async () => {
191
+ // The taking-over run publishes its own update.
192
+ if (!(await live.scheduledFor(run)))
193
+ return false;
194
+ if (!(await reached(run)))
195
+ throw new Error(`the ${name} is not due`);
196
+ await then();
197
+ return true;
198
+ });
199
+ }
200
+ /** Sleeps until `at`; skips a passed `at` (replays included). */
201
+ async function sleepUntilAhead(step, name, at) {
202
+ if (at > Date.now())
203
+ await step.sleepUntil(name, at);
204
+ }
205
+ /**
206
+ * Hands the site to the next build at `cutoverAt`. Closes the live build's
207
+ * queues; publishes the notice `UPDATE_NOTICE_LEAD_MS` ahead; at the
208
+ * cutover (database clock) verifies (fence included), drains the live
209
+ * slot (`params.drain`), adds crons to the idle slot, deploys the next web
210
+ * version, checks the site (`params.buildPaths`),
211
+ * releases (names the build live + withdraws the update in one
212
+ * transaction), and clears the live slot's crons. Both slots hold crons
213
+ * across the release; the live-build gate runs one. Steps are idempotent.
214
+ *
215
+ * `run` (workflow instance) owns the update until a later run's close takes
216
+ * it over. A refused close or superseded update ends the run unchanged. A
217
+ * failure before the release restores the web, clears idle crons, withdraws
218
+ * the update, and rethrows. Past the release, cron clear and publish are
219
+ * best effort.
220
+ */
221
+ export async function runCutover(run, params, step, deps) {
222
+ const cutoverAt = Date.parse(params.cutoverAt);
223
+ const live = deps.controls[params.liveSlot];
224
+ const idle = deps.controls[params.idleSlot];
225
+ const { api, deployment } = deps;
226
+ const { buildPaths } = params;
227
+ const { webScript, slotScripts, siteOrigin } = deployment;
228
+ const liveScript = slotScripts[params.liveSlot];
229
+ const idleScript = slotScripts[params.idleSlot];
230
+ // Set before their steps, so a replay rebuilds them.
231
+ // Compensation clears the idle slot's crons.
232
+ let added = false;
233
+ // Compensation switches the web back.
234
+ let switched = false;
235
+ try {
236
+ // Refused while another run's update is due.
237
+ if (!(await step.do("close", RPC_STEP, () => live.close(run, cutoverAt))))
238
+ return "refused";
239
+ await publishBoth(step, "publish close", deps);
240
+ await sleepUntilAhead(step, "until notice", cutoverAt - UPDATE_NOTICE_LEAD_MS);
241
+ const noticed = await awaitClock(step, "notice", live, run, (r) => live.noticed(r), () => publishSlots(deps.controls));
242
+ if (!noticed)
243
+ return "superseded";
244
+ await sleepUntilAhead(step, "until cutover", cutoverAt);
245
+ // Once due, no later run's close takes the update over.
246
+ if (!(await awaitClock(step, "cutover", live, run, (r) => live.due(r))))
247
+ return "superseded";
248
+ const crons = await step.do("verify", API_STEP, () => precondition(async () => {
249
+ if (!(await live.scheduledFor(run)))
250
+ return null;
251
+ await expectBuild(deps.controls, params.idleSlot, params.build);
252
+ const crons = await verifyCutover(api, deployment, params);
253
+ // Site answers the live build now, so a later failed check blames
254
+ // the switch.
255
+ await checkSite(deps.fetch, siteOrigin, buildPaths, await live.build());
256
+ return crons;
257
+ }));
258
+ if (!crons)
259
+ return "superseded";
260
+ if (params.drain)
261
+ await step.do("drain", DRAIN_STEP, () => precondition(() => drainSlot(deps.controls, params.liveSlot)));
262
+ added = true;
263
+ await step.do("add crons", API_STEP, () => putScriptCrons(api, idleScript, crons));
264
+ // The slot may have changed since the verify; the switch rechecks.
265
+ switched = true;
266
+ await step.do("deploy next web", API_STEP, () => precondition(async () => {
267
+ await expectBuild(deps.controls, params.idleSlot, params.build);
268
+ await deployScript(api, webScript, [{ version_id: params.nextVersion, percentage: 100 }], `cutover to ${params.build}`);
269
+ }));
270
+ await step.do("check site", SITE_STEP, () => checkSite(deps.fetch, siteOrigin, buildPaths, params.build));
271
+ }
272
+ catch (e) {
273
+ // Best effort, token-bound steps first (`CUTOVER_TOKEN_MARGIN_MS`);
274
+ // `settle` realigns leftovers.
275
+ const attempt = (p) => p.catch(() => undefined);
276
+ if (switched)
277
+ await attempt(step.do("restore web", API_STEP, () => restoreWeb(api, webScript, params)));
278
+ if (added)
279
+ await attempt(step.do("clear idle crons", API_STEP, () => putScriptCrons(api, idleScript, [])));
280
+ await attempt(step.do("reopen after failure", RPC_STEP, () => live.reopen(run)));
281
+ await attempt(publishBoth(step, "publish reopen", deps));
282
+ throw e;
283
+ }
284
+ await step.do("release", CONVERGE_STEP, () => precondition(async () => {
285
+ await expectBuild(deps.controls, params.idleSlot, params.build);
286
+ // The build lands either way; another run's update stays.
287
+ if (!(await idle.release(params.build, run)))
288
+ log.warn("cutover.release_left_update", "release left another run's update", {
289
+ run,
290
+ build: params.build,
291
+ });
292
+ }));
293
+ // Next build is live; the rest is best effort, `settle` finishing it.
294
+ await step
295
+ .do("clear old crons", API_STEP, () => putScriptCrons(api, liveScript, []))
296
+ .catch((e) => log.warn("cutover.clear_old_crons_failed", "the old slot keeps its crons, gated off", { run, exception: e }));
297
+ await publishBoth(step, "publish release", deps).catch((e) => log.warn("cutover.publish_release_failed", "release publish failed", {
298
+ run,
299
+ exception: e,
300
+ }));
301
+ return "released";
302
+ }
303
+ /** Names the live build and withdraws any update in one write, reopening
304
+ * every queue. `deploy-release` moves the crons. */
305
+ export async function runSettle(params, step, deps) {
306
+ await step.do("release", RPC_STEP, () => precondition(async () => {
307
+ await expectBuild(deps.controls, params.slot, params.build);
308
+ await deps.controls[params.slot].release(params.build, null);
309
+ }));
310
+ await publishBoth(step, "publish release", deps);
311
+ }
312
+ /** Ends the ongoing work of `params.slot`'s build in one step
313
+ * (`drainSlot`); a failed precondition fails it without retries. */
314
+ export async function runDrain(params, step, deps) {
315
+ await step.do("drain", RPC_STEP, () => precondition(() => drainSlot(deps.controls, params.slot)));
316
+ }
@@ -0,0 +1,29 @@
1
+ /** A blue/green deployer worker, complete: the cutover, settle and drain
2
+ * workflows over the slot controls its env binds, and the 404 fetch
3
+ * handler. `deploy-release` deploys it per blue/green env under a
4
+ * generated wrangler config (`deployerConfig`). */
5
+ import { type ControlBinding, type CutoverParams, type DrainParams, type OpsControlRpc, type SettleParams } from "@astralarium/repo-deploy-info";
6
+ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
7
+ import { type SlotDeployerEnv } from "./cutover.ts";
8
+ /** The deployer worker's env: `SlotDeployerEnv` plus each slot's control
9
+ * (`controlBinding`: a service binding to the slotted worker's
10
+ * `CONTROL_ENTRYPOINT`). */
11
+ type DeployerEnv = SlotDeployerEnv & Record<ControlBinding, OpsControlRpc>;
12
+ /** `DEPLOYER_FLOWS.cutover`: `runCutover` over `env`; a malformed payload
13
+ * or absent token fails before any step. */
14
+ export declare class CutoverWorkflow extends WorkflowEntrypoint<DeployerEnv, CutoverParams> {
15
+ run(event: WorkflowEvent<CutoverParams>, step: WorkflowStep): Promise<import("@astralarium/repo-deploy-info").CutoverOutcome>;
16
+ }
17
+ /** `DEPLOYER_FLOWS.settle`: `runSettle` over `env`. */
18
+ export declare class SettleWorkflow extends WorkflowEntrypoint<DeployerEnv, SettleParams> {
19
+ run(event: WorkflowEvent<SettleParams>, step: WorkflowStep): Promise<void>;
20
+ }
21
+ /** `DEPLOYER_FLOWS.drain`: `runDrain` over `env`. */
22
+ export declare class DrainWorkflow extends WorkflowEntrypoint<DeployerEnv, DrainParams> {
23
+ run(event: WorkflowEvent<DrainParams>, step: WorkflowStep): Promise<void>;
24
+ }
25
+ /** Reached only through the Workflows API. */
26
+ declare const _default: {
27
+ fetch: () => Response;
28
+ };
29
+ export default _default;
@@ -0,0 +1,44 @@
1
+ /** A blue/green deployer worker, complete: the cutover, settle and drain
2
+ * workflows over the slot controls its env binds, and the 404 fetch
3
+ * handler. `deploy-release` deploys it per blue/green env under a
4
+ * generated wrangler config (`deployerConfig`). */
5
+ import { controlBinding, DEPLOYMENT_VAR, SLOTS, } from "@astralarium/repo-deploy-info";
6
+ import { WorkflowEntrypoint, } from "cloudflare:workers";
7
+ import { deployerApi, parseDrainParams, parseParams, parseSettleParams, runCutover, runDrain, runSettle, } from "./cutover.js";
8
+ /** Each slot's control, as `env` binds it. */
9
+ const controlsOf = (env) => Object.fromEntries(SLOTS.map((slot) => [slot, env[controlBinding(slot)]]));
10
+ /** `DEPLOYER_FLOWS.cutover`: `runCutover` over `env`; a malformed payload
11
+ * or absent token fails before any step. */
12
+ export class CutoverWorkflow extends WorkflowEntrypoint {
13
+ async run(event, step) {
14
+ const params = parseParams(event.payload);
15
+ return runCutover(event.instanceId, params, step, {
16
+ deployment: this.env[DEPLOYMENT_VAR],
17
+ controls: controlsOf(this.env),
18
+ api: deployerApi(this.env, params),
19
+ fetch: (input, init) => fetch(input, init),
20
+ });
21
+ }
22
+ }
23
+ /** `DEPLOYER_FLOWS.settle`: `runSettle` over `env`. */
24
+ export class SettleWorkflow extends WorkflowEntrypoint {
25
+ async run(event, step) {
26
+ const params = parseSettleParams(event.payload);
27
+ await runSettle(params, step, {
28
+ controls: controlsOf(this.env),
29
+ });
30
+ }
31
+ }
32
+ /** `DEPLOYER_FLOWS.drain`: `runDrain` over `env`. */
33
+ export class DrainWorkflow extends WorkflowEntrypoint {
34
+ async run(event, step) {
35
+ const params = parseDrainParams(event.payload);
36
+ await runDrain(params, step, {
37
+ controls: controlsOf(this.env),
38
+ });
39
+ }
40
+ }
41
+ /** Reached only through the Workflows API. */
42
+ export default {
43
+ fetch: () => new Response("Not found", { status: 404 }),
44
+ };
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@astralarium/repo-deploy-workflows",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "homepage": "https://github.com/astralarium/repo/tree/main/packages/repo-deploy-workflows#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/astralarium/repo.git",
9
+ "directory": "packages/repo-deploy-workflows"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ "./deployer": "./dist/src/deployer.js"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {
23
+ "@astralarium/repo-telemetry": "^0.2.0",
24
+ "@astralarium/repo-deploy-info": "^0.1.1"
25
+ },
26
+ "devDependencies": {
27
+ "@cloudflare/vitest-plugin": "^1.4.9",
28
+ "@types/node": "^24.2.0",
29
+ "eslint": "^10.2.1",
30
+ "typescript": "^6.0.3",
31
+ "vitest": "^5.0.3",
32
+ "wrangler": "^4.132.0",
33
+ "@astralarium/repo-config-typescript": "^0.1.0",
34
+ "@astralarium/repo-config-eslint": "^0.3.0",
35
+ "@astralarium/repo-package-files": "^0.1.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=24.2"
39
+ },
40
+ "scripts": {
41
+ "cf-typegen": "wrangler types -c test/wrangler.jsonc",
42
+ "check-types": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit",
43
+ "lint": "eslint",
44
+ "test": "vitest"
45
+ }
6
46
  }