@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.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/bin/deploy-release.d.ts +2 -0
- package/dist/bin/deploy-release.js +7 -0
- package/dist/src/app.d.ts +143 -0
- package/dist/src/app.js +102 -0
- package/dist/src/bundle.d.ts +18 -0
- package/dist/src/bundle.js +41 -0
- package/dist/src/check.d.ts +44 -0
- package/dist/src/check.js +128 -0
- package/dist/src/cloudflare.d.ts +70 -0
- package/dist/src/cloudflare.js +170 -0
- package/dist/src/commands.d.ts +55 -0
- package/dist/src/commands.js +651 -0
- package/dist/src/cutover-at.d.ts +23 -0
- package/dist/src/cutover-at.js +152 -0
- package/dist/src/cutovers.d.ts +62 -0
- package/dist/src/cutovers.js +112 -0
- package/dist/src/deployer.d.ts +36 -0
- package/dist/src/deployer.js +63 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/lease.d.ts +36 -0
- package/dist/src/lease.js +112 -0
- package/dist/src/main.d.ts +6 -0
- package/dist/src/main.js +43 -0
- package/dist/src/process.d.ts +34 -0
- package/dist/src/process.js +68 -0
- package/dist/src/prompt.d.ts +5 -0
- package/dist/src/prompt.js +69 -0
- package/dist/src/provision.d.ts +36 -0
- package/dist/src/provision.js +230 -0
- package/dist/src/schema.d.ts +2 -0
- package/dist/src/schema.js +12 -0
- package/dist/src/session.d.ts +81 -0
- package/dist/src/session.js +275 -0
- package/dist/src/state.d.ts +55 -0
- package/dist/src/state.js +115 -0
- package/dist/src/status.d.ts +65 -0
- package/dist/src/status.js +224 -0
- package/dist/src/steps.d.ts +80 -0
- package/dist/src/steps.js +389 -0
- package/dist/src/target.d.ts +94 -0
- package/dist/src/target.js +246 -0
- package/dist/src/web.d.ts +28 -0
- package/dist/src/web.js +70 -0
- package/dist/src/workspace.d.ts +11 -0
- package/dist/src/workspace.js +40 -0
- package/dist/testkit/index.d.ts +4 -0
- package/dist/testkit/index.js +36 -0
- package/dist/testkit/release-bundles.d.ts +16 -0
- package/dist/testkit/release-bundles.js +56 -0
- package/dist/testkit/release-checks.d.ts +19 -0
- package/dist/testkit/release-checks.js +71 -0
- package/dist/testkit/release.d.ts +44 -0
- package/dist/testkit/release.js +61 -0
- package/package.json +64 -3
- package/sql/before-reset.sql +22 -0
- package/sql/schema.sql +164 -0
- package/sql/scrub.sql +5 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/** Steps the commands share: preflights, builds, workflow runs and the
|
|
2
|
+
* cutover schedule. */
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { BUILD_VAR, CUTOVER_TOKEN_BUDGET_MS, CUTOVER_TOKEN_MARGIN_MS, SLOT_VAR, } from "@astralarium/repo-deploy-info";
|
|
6
|
+
import { ExpectedError, log } from "@astralarium/repo-telemetry";
|
|
7
|
+
import { buildEnvOf } from "./app.js";
|
|
8
|
+
import { requireReleaseBundle, workerBundleArgs } from "./bundle.js";
|
|
9
|
+
import { epoch, } from "./cloudflare.js";
|
|
10
|
+
import { describeCutover, parseCutoverSpec, resolveCutover, } from "./cutover-at.js";
|
|
11
|
+
import { cutoverRunId, terminateCutovers, withdrawOrphan } from "./cutovers.js";
|
|
12
|
+
import { writeDeployer } from "./deployer.js";
|
|
13
|
+
import { abortReason } from "./process.js";
|
|
14
|
+
import { setLiveBuild, updateOwnedBy } from "./state.js";
|
|
15
|
+
import { secretsByScript } from "./target.js";
|
|
16
|
+
import { uploadedVersionId } from "./web.js";
|
|
17
|
+
import { ownWorkspaceRoot } from "./workspace.js";
|
|
18
|
+
/** Between workflow status polls. */
|
|
19
|
+
const FLOW_POLL_MS = 5_000;
|
|
20
|
+
/** Max wait for a workflow run. */
|
|
21
|
+
const FLOW_TIMEOUT_MS = 30 * 60_000;
|
|
22
|
+
/** Consecutive unreadable statuses before giving up on a run. */
|
|
23
|
+
const FLOW_UNREADABLE_LIMIT = 3;
|
|
24
|
+
/** Runs wrangler on the target's generated deployer (`writeDeployer`),
|
|
25
|
+
* from the slotted worker's package. */
|
|
26
|
+
async function deployerWrangler(s, args, options = {}) {
|
|
27
|
+
const config = await writeDeployer(join(await s.tmpDir(), "deployer"), s.target);
|
|
28
|
+
await s.cf.wrangler(s.target.packages.worker, await args(config), {
|
|
29
|
+
...options,
|
|
30
|
+
config,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** Deploys the deployer as a release-checked bundle (`wranglerBundle`),
|
|
34
|
+
* keeping its secrets; once per session. Requires a clean app checkout
|
|
35
|
+
* (`Session.requireClean`). */
|
|
36
|
+
export async function deployDeployer(s) {
|
|
37
|
+
if (s.deployerDeployed)
|
|
38
|
+
return;
|
|
39
|
+
await deployerWrangler(s, async (config) => [
|
|
40
|
+
"deploy",
|
|
41
|
+
...(await wranglerBundle(s, s.target.packages.worker, {}, { config })),
|
|
42
|
+
]);
|
|
43
|
+
s.deployerDeployed = true;
|
|
44
|
+
}
|
|
45
|
+
/** Throws unless every script of the target holds its secrets. */
|
|
46
|
+
export async function preflightSecrets(s) {
|
|
47
|
+
for (const [script, keys] of secretsByScript(s.target)) {
|
|
48
|
+
const held = new Set(await s.cf.secretNames(script));
|
|
49
|
+
const missing = keys.filter((key) => !held.has(key));
|
|
50
|
+
if (missing.length)
|
|
51
|
+
throw new ExpectedError(`${script} lacks ${missing.join(", ")}. Run ${s.cli("secrets")} before deploying.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Builds workspace package `pkg` for the target's env as `build` says,
|
|
55
|
+
* with `vars` in its env; resolves to the args naming the build to
|
|
56
|
+
* `wrangler deploy` / `versions upload`, after the subcommand. */
|
|
57
|
+
async function buildWorker(s, pkg, build, vars) {
|
|
58
|
+
if (build === "wrangler")
|
|
59
|
+
return wranglerBundle(s, pkg, vars);
|
|
60
|
+
await s.run("pnpm", ["--filter", pkg, "build"], {
|
|
61
|
+
cwd: ownWorkspaceRoot(),
|
|
62
|
+
env: {
|
|
63
|
+
...vars,
|
|
64
|
+
// `vite build` compiles `import.meta.env.DEV` code out under production.
|
|
65
|
+
NODE_ENV: "production",
|
|
66
|
+
CLOUDFLARE_ENV: s.target.env,
|
|
67
|
+
CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV: "false",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
return ["--minify"];
|
|
71
|
+
}
|
|
72
|
+
/** Builds `pkg`'s release-checked worker bundle for the target's env
|
|
73
|
+
* (`wrangler deploy --dry-run`, `requireReleaseBundle`) with `vars` in
|
|
74
|
+
* its env, baking `vars.DEPLOY_INFO` when set; resolves to the args
|
|
75
|
+
* uploading that bundle after the `deploy` subcommand. */
|
|
76
|
+
async function wranglerBundle(s, pkg, vars = {}, { config } = {}) {
|
|
77
|
+
const { env } = s.target;
|
|
78
|
+
// One build: the checked bundle is the uploaded one.
|
|
79
|
+
const bundle = join(await s.tmpDir(), `${config ? "deployer" : pkg}-bundle`);
|
|
80
|
+
await s.cf.wrangler(pkg, workerBundleArgs(bundle, { env, deployInfo: vars.DEPLOY_INFO }), { env: vars, config });
|
|
81
|
+
await requireReleaseBundle(bundle);
|
|
82
|
+
return [join(bundle, "index.js"), "--no-bundle", "--env", env];
|
|
83
|
+
}
|
|
84
|
+
/** Builds the next build with `buildEnv` (`buildEnvOf`), deploys its
|
|
85
|
+
* release-checked worker bundle to `slot` (DO migrations included), then
|
|
86
|
+
* uploads its web version. */
|
|
87
|
+
export async function deployNext(s, slot, buildEnv) {
|
|
88
|
+
const { web, worker } = s.target.packages;
|
|
89
|
+
// Minted once: both bundles carry the same DeployInfo.
|
|
90
|
+
const info = s.mint();
|
|
91
|
+
const vars = {
|
|
92
|
+
...buildEnvOf(s.app, buildEnv),
|
|
93
|
+
DEPLOY_INFO: JSON.stringify(info),
|
|
94
|
+
};
|
|
95
|
+
const webBuild = await buildWorker(s, web, "vite", vars);
|
|
96
|
+
const workerBuild = await buildWorker(s, worker, "wrangler", vars);
|
|
97
|
+
await s.cf.wrangler(worker, [
|
|
98
|
+
"deploy",
|
|
99
|
+
...workerBuild,
|
|
100
|
+
"--name",
|
|
101
|
+
s.target.deployment.slotScripts[slot],
|
|
102
|
+
"--tag",
|
|
103
|
+
info.build,
|
|
104
|
+
"--var",
|
|
105
|
+
`${BUILD_VAR}:${info.build}`,
|
|
106
|
+
]);
|
|
107
|
+
const out = join(await s.tmpDir(), "versions-upload.jsonl");
|
|
108
|
+
await s.cf.wrangler(web, [
|
|
109
|
+
"versions",
|
|
110
|
+
"upload",
|
|
111
|
+
...webBuild,
|
|
112
|
+
"--tag",
|
|
113
|
+
info.build,
|
|
114
|
+
"--var",
|
|
115
|
+
`${SLOT_VAR}:${slot}`,
|
|
116
|
+
"--var",
|
|
117
|
+
`${BUILD_VAR}:${info.build}`,
|
|
118
|
+
],
|
|
119
|
+
// The build's redirected config targets the env.
|
|
120
|
+
{ env: { ...vars, WRANGLER_OUTPUT_FILE_PATH: out }, redirected: true });
|
|
121
|
+
return {
|
|
122
|
+
build: info.build,
|
|
123
|
+
version: uploadedVersionId(await readFile(out, "utf8")),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Builds the next build with `buildEnv` (`buildEnvOf`) for every worker
|
|
127
|
+
* of the target, then deploys them in order; resolves to its build. A
|
|
128
|
+
* deploy failing after another worker took the build throws naming the
|
|
129
|
+
* split and the recovery. */
|
|
130
|
+
export async function deployWorkers(s, buildEnv) {
|
|
131
|
+
const info = s.mint();
|
|
132
|
+
const vars = {
|
|
133
|
+
...buildEnvOf(s.app, buildEnv),
|
|
134
|
+
DEPLOY_INFO: JSON.stringify(info),
|
|
135
|
+
};
|
|
136
|
+
// Every build first: a failing build leaves the site untouched.
|
|
137
|
+
const builds = [];
|
|
138
|
+
for (const { package: pkg, build } of s.target.workers)
|
|
139
|
+
builds.push(await buildWorker(s, pkg, build, vars));
|
|
140
|
+
const deployed = [];
|
|
141
|
+
for (const [i, worker] of s.target.workers.entries()) {
|
|
142
|
+
try {
|
|
143
|
+
await s.cf.wrangler(worker.package, [
|
|
144
|
+
"deploy",
|
|
145
|
+
...builds[i],
|
|
146
|
+
"--tag",
|
|
147
|
+
info.build,
|
|
148
|
+
"--var",
|
|
149
|
+
`${BUILD_VAR}:${info.build}`,
|
|
150
|
+
...Object.entries(worker.vars ?? {}).flatMap(([name, value]) => [
|
|
151
|
+
"--var",
|
|
152
|
+
`${name}:${value}`,
|
|
153
|
+
]),
|
|
154
|
+
], { env: vars, redirected: worker.build === "vite" });
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
if (s.signal.aborted || !deployed.length)
|
|
158
|
+
throw e;
|
|
159
|
+
const pending = s.target.workers.slice(i).map((w) => w.script);
|
|
160
|
+
throw new ExpectedError(`${worker.script} failed to deploy after ${deployed.join(", ")} took build ${info.build}; ${pending.join(", ")} still ${pending.length === 1 ? "runs" : "run"} the previous build. Fix the cause, then rerun ${s.cli("deploy")}: it redeploys every worker.`, { cause: e });
|
|
161
|
+
}
|
|
162
|
+
deployed.push(worker.script);
|
|
163
|
+
}
|
|
164
|
+
return info.build;
|
|
165
|
+
}
|
|
166
|
+
/** Removes the app's state of builds outside `kept`
|
|
167
|
+
* (`ReleaseApp.retireBuilds`), fenced on the held lease; an app without
|
|
168
|
+
* the hook keeps every build's state. */
|
|
169
|
+
export async function retireBuilds(s, kept) {
|
|
170
|
+
const { retireBuilds } = s.app;
|
|
171
|
+
if (!retireBuilds)
|
|
172
|
+
return;
|
|
173
|
+
await s.lease.fenced((tx) => retireBuilds(tx, kept));
|
|
174
|
+
}
|
|
175
|
+
/** Names `build` the live build in the deploy state, fenced on the held
|
|
176
|
+
* lease: clients compare their build against it. */
|
|
177
|
+
export async function recordLiveBuild(s, build) {
|
|
178
|
+
await s.lease.fenced((tx) => setLiveBuild(tx, build));
|
|
179
|
+
}
|
|
180
|
+
/** A web version override answers as soon as the upload lands. */
|
|
181
|
+
const VERSION_SMOKE = { attempts: 5, retryMs: 3_000 };
|
|
182
|
+
/** A deployed script: a first deploy's custom domain takes minutes to
|
|
183
|
+
* serve (DNS, certificate). */
|
|
184
|
+
const DEPLOYED_SMOKE = { attempts: 30, retryMs: 10_000 };
|
|
185
|
+
/** Throws unless the site answers `build` at every path of `paths`,
|
|
186
|
+
* past caches: through a version override of `version` (`VERSION_SMOKE`),
|
|
187
|
+
* else as deployed (`DEPLOYED_SMOKE`). */
|
|
188
|
+
export async function smokeTest(s, build, paths, version) {
|
|
189
|
+
const { siteOrigin } = s.target;
|
|
190
|
+
const headers = {
|
|
191
|
+
"Cache-Control": "no-cache",
|
|
192
|
+
...(version && {
|
|
193
|
+
"Cloudflare-Workers-Version-Overrides": `${version.script}="${version.id}"`,
|
|
194
|
+
}),
|
|
195
|
+
};
|
|
196
|
+
/** The build `path` answers; undefined on a failed request. */
|
|
197
|
+
const servedAt = async (path) => {
|
|
198
|
+
const url = new URL(path, siteOrigin);
|
|
199
|
+
url.searchParams.set("smoke", String(Date.now()));
|
|
200
|
+
try {
|
|
201
|
+
const res = await s.fetch(url, { headers });
|
|
202
|
+
const { build: served } = (await res.json());
|
|
203
|
+
return res.ok ? served : undefined;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
if (s.signal.aborted)
|
|
207
|
+
throw abortReason(s.signal);
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const where = version ? `Web version ${version.id}` : siteOrigin;
|
|
212
|
+
const { attempts, retryMs } = version ? VERSION_SMOKE : DEPLOYED_SMOKE;
|
|
213
|
+
for (const path of paths) {
|
|
214
|
+
let served;
|
|
215
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
216
|
+
served = await servedAt(path);
|
|
217
|
+
if (served === build || attempt === attempts)
|
|
218
|
+
break;
|
|
219
|
+
log.info("deploy_release.smoke_retry", "{where} answered build {served} on {path}; retrying ({attempt}/{attempts})", { where, served: String(served), path, attempt, attempts });
|
|
220
|
+
await s.sleep(retryMs);
|
|
221
|
+
}
|
|
222
|
+
if (served !== build)
|
|
223
|
+
throw new ExpectedError(`${where} answered build '${String(served)}' on ${path}, expected '${build}' (${attempts} attempts over ${((attempts - 1) * retryMs) / 1000}s).`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** `wrangler` line describing instance `id` of `flow`. */
|
|
227
|
+
const describeLine = (s, flow, id) => `see: pnpm --filter ${s.target.packages.worker} exec wrangler workflows instances describe ${flow} ${id}`;
|
|
228
|
+
/** Polls instance `id` of `flow`: resolves to its output on completion,
|
|
229
|
+
* undefined once `settled` holds mid-run. Throws on failure, a lasting
|
|
230
|
+
* unreadable status, or timeout. */
|
|
231
|
+
async function awaitFlow(s, flow, id, settled = () => Promise.resolve(false)) {
|
|
232
|
+
const see = describeLine(s, flow, id);
|
|
233
|
+
const deadline = Date.now() + FLOW_TIMEOUT_MS;
|
|
234
|
+
let unreadable = 0;
|
|
235
|
+
let status;
|
|
236
|
+
for (;;) {
|
|
237
|
+
let reason = "";
|
|
238
|
+
let output;
|
|
239
|
+
try {
|
|
240
|
+
({ status, output } = await s.cf.instance(flow, id));
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
if (s.signal.aborted)
|
|
244
|
+
throw abortReason(s.signal);
|
|
245
|
+
status = undefined;
|
|
246
|
+
reason = e instanceof Error ? e.message : String(e);
|
|
247
|
+
}
|
|
248
|
+
switch (status) {
|
|
249
|
+
case "complete":
|
|
250
|
+
return { output };
|
|
251
|
+
case "errored":
|
|
252
|
+
case "terminated":
|
|
253
|
+
throw new ExpectedError(`${flow} run ${id} ${status}; ${see}`);
|
|
254
|
+
case "queued":
|
|
255
|
+
case "running":
|
|
256
|
+
case "waiting":
|
|
257
|
+
case "paused":
|
|
258
|
+
case "waitingForPause":
|
|
259
|
+
unreadable = 0;
|
|
260
|
+
if (await settled())
|
|
261
|
+
return undefined;
|
|
262
|
+
break;
|
|
263
|
+
default:
|
|
264
|
+
if (++unreadable >= FLOW_UNREADABLE_LIMIT)
|
|
265
|
+
throw new ExpectedError(`${flow} run ${id}: unreadable status: ${reason || String(status)}; ${see}`);
|
|
266
|
+
}
|
|
267
|
+
if (Date.now() >= deadline)
|
|
268
|
+
throw new ExpectedError(`${flow} run ${id} still ${status ?? "unreadable"} after ${FLOW_TIMEOUT_MS / 60_000} minutes; ${see}`);
|
|
269
|
+
await s.sleep(FLOW_POLL_MS);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Starts workflow `flow` as instance `id` with `params` and waits for
|
|
273
|
+
* it; throws when it fails. */
|
|
274
|
+
export async function runFlow(s, flow, id, params) {
|
|
275
|
+
await s.cf.trigger(flow, id, params);
|
|
276
|
+
await awaitFlow(s, flow, id);
|
|
277
|
+
}
|
|
278
|
+
/** `CUTOVER_TOKEN_MARGIN_MS` for an operator. */
|
|
279
|
+
export const TOKEN_MARGIN = `${CUTOVER_TOKEN_MARGIN_MS / (60 * 60_000)} hours`;
|
|
280
|
+
/** When a token for a cutover at `at` expires: `TOKEN_MARGIN` past it. */
|
|
281
|
+
export const tokenExpiry = (at) => at + CUTOVER_TOKEN_MARGIN_MS;
|
|
282
|
+
/** Prompts for an API token valid until `expiresAt`. */
|
|
283
|
+
export async function promptToken(s, expiresAt) {
|
|
284
|
+
const value = await s.secret(`API token (Workers Scripts: Edit; valid until ${new Date(expiresAt).toISOString()}): `);
|
|
285
|
+
if (!value)
|
|
286
|
+
throw new ExpectedError("No token given.");
|
|
287
|
+
return { value, expiresAt };
|
|
288
|
+
}
|
|
289
|
+
/** Deploys the deployer, then puts `token` on it: the deploy creates a
|
|
290
|
+
* missing deployer, which a secret upload alone would ask about. */
|
|
291
|
+
export async function installToken(s, token) {
|
|
292
|
+
await deployDeployer(s);
|
|
293
|
+
await deployerWrangler(s, () => ["secret", "bulk", "--env", s.target.env], {
|
|
294
|
+
input: JSON.stringify({ CLOUDFLARE_API_TOKEN: token.value }),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/** Throws unless `token` outlives a cutover at `cutoverAt` by the
|
|
298
|
+
* token-bound steps' worst case (`CUTOVER_TOKEN_BUDGET_MS`). */
|
|
299
|
+
export function requireTokenCovers(s, token, cutoverAt) {
|
|
300
|
+
const needed = cutoverAt + CUTOVER_TOKEN_BUDGET_MS;
|
|
301
|
+
if (needed <= token.expiresAt)
|
|
302
|
+
return;
|
|
303
|
+
throw new ExpectedError(`The token (valid until ${new Date(token.expiresAt).toISOString()}) expires before the cutover's budget (${new Date(needed).toISOString()}); the staged build stays staged: run ${s.cli("cutover")} with a fresh token.`);
|
|
304
|
+
}
|
|
305
|
+
/** Why a cutover run ended unchanged, per outcome. */
|
|
306
|
+
const UNCHANGED = {
|
|
307
|
+
refused: "another cutover's update is due",
|
|
308
|
+
superseded: "a later cutover run took over its update",
|
|
309
|
+
};
|
|
310
|
+
/** Publishes the deploy state to clients through the settle workflow,
|
|
311
|
+
* which names `live`'s build live again; a failure logs. */
|
|
312
|
+
export async function publishState(s, live) {
|
|
313
|
+
await runFlow(s, s.target.flows.settle, `publish-${epoch()}`, {
|
|
314
|
+
slot: live.slot,
|
|
315
|
+
build: live.build,
|
|
316
|
+
}).catch((e) => log.warn("deploy_release.publish_failed", "the withdrawn update may still show on clients; {settle} publishes it", { settle: s.cli("settle"), exception: e }));
|
|
317
|
+
}
|
|
318
|
+
/** Runs the cutover workflow with `params` until its close lands or the
|
|
319
|
+
* run ends. First, from a fresh read: ends terminable runs, withdraws an
|
|
320
|
+
* orphaned update, runs `prepare`. Requires the deployer's token
|
|
321
|
+
* (`installToken`). Resolves true when the run already released; throws
|
|
322
|
+
* on an in-flight cutover or a run ending unchanged or failed. */
|
|
323
|
+
export async function triggerCutover(s, params, prepare = () => Promise.resolve()) {
|
|
324
|
+
const c = await terminateCutovers(s);
|
|
325
|
+
await withdrawOrphan(s, c);
|
|
326
|
+
// An in-flight owner refuses above, so any update read was an ended
|
|
327
|
+
// run's and is now withdrawn; clients drop it at the next publish.
|
|
328
|
+
const withdrew = c.state.update_run !== null;
|
|
329
|
+
const flow = s.target.flows.cutover;
|
|
330
|
+
const id = cutoverRunId(params.build);
|
|
331
|
+
try {
|
|
332
|
+
await prepare();
|
|
333
|
+
const full = {
|
|
334
|
+
accountId: await s.cf.accountId(),
|
|
335
|
+
buildPaths: s.target.buildPaths,
|
|
336
|
+
drain: s.target.flows.drain !== undefined,
|
|
337
|
+
...params,
|
|
338
|
+
};
|
|
339
|
+
await s.cf.trigger(flow, id, full);
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
if (withdrew)
|
|
343
|
+
await publishState(s, c.live);
|
|
344
|
+
throw e;
|
|
345
|
+
}
|
|
346
|
+
// The close landed once the deploy state names the run; an unreadable
|
|
347
|
+
// state waits for the run's status.
|
|
348
|
+
const ended = await awaitFlow(s, flow, id, () => updateOwnedBy(s.sql, id).catch(() => false));
|
|
349
|
+
if (!ended)
|
|
350
|
+
return false;
|
|
351
|
+
const { output } = ended;
|
|
352
|
+
if (output === "released")
|
|
353
|
+
return true;
|
|
354
|
+
const why = output === "refused" || output === "superseded"
|
|
355
|
+
? `${output}: ${UNCHANGED[output]}`
|
|
356
|
+
: `unknown outcome ${JSON.stringify(output)}`;
|
|
357
|
+
throw new ExpectedError(`Cutover run ${id} ended without a change (${why}); ${s.cli("status")} shows the deployment. ${describeLine(s, flow, id)}`);
|
|
358
|
+
}
|
|
359
|
+
/** Resolves `--at` and `--exact-time` at `now` (ms epoch) to the cutover
|
|
360
|
+
* instant, printing it: intervals and `now` count from `now`. Throws on
|
|
361
|
+
* a malformed, passed or too distant time. */
|
|
362
|
+
export function pinCutover(at, exact, now = Date.now()) {
|
|
363
|
+
const resolved = resolveCutover(parseCutoverSpec(at, now), now, exact);
|
|
364
|
+
log.info("deploy_release.cutover_time", "Cutover: {when}", { when: describeCutover(resolved, now) });
|
|
365
|
+
return resolved;
|
|
366
|
+
}
|
|
367
|
+
/** Schedules the cutover from `live` to `next` at `at` (ms epoch; now
|
|
368
|
+
* once passed) under `token`: throws unless the token covers it
|
|
369
|
+
* (`requireTokenCovers`), then installs it and triggers the run. */
|
|
370
|
+
export async function scheduleCutover(s, { live, next }, at, token) {
|
|
371
|
+
const cutoverAt = Math.max(at, Date.now());
|
|
372
|
+
requireTokenCovers(s, token, cutoverAt);
|
|
373
|
+
await installToken(s, token);
|
|
374
|
+
const released = await triggerCutover(s, {
|
|
375
|
+
liveVersion: live.id,
|
|
376
|
+
nextVersion: next.id,
|
|
377
|
+
liveSlot: live.slot,
|
|
378
|
+
idleSlot: next.slot,
|
|
379
|
+
build: next.build,
|
|
380
|
+
cutoverAt: new Date(cutoverAt).toISOString(),
|
|
381
|
+
});
|
|
382
|
+
if (released)
|
|
383
|
+
log.info("deploy_release.released", "Released: build {build} is live", { build: next.build });
|
|
384
|
+
else
|
|
385
|
+
log.info("deploy_release.scheduled", "Scheduled: build {build} takes over {when}", {
|
|
386
|
+
build: next.build,
|
|
387
|
+
when: describeCutover(cutoverAt, Date.now()),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type Deployment } from "@astralarium/repo-deploy-info";
|
|
2
|
+
import { type ReleaseApp, type SlotPackages, type SlotSecrets, type WorkerSpec } from "./app.ts";
|
|
3
|
+
import { type Gap } from "./provision.ts";
|
|
4
|
+
/** What every target shares. */
|
|
5
|
+
interface TargetBase {
|
|
6
|
+
/** Minted into builds; wrangler's `--env` and `CLOUDFLARE_ENV`. */
|
|
7
|
+
env: string;
|
|
8
|
+
/** The public site; checked after each deploy. */
|
|
9
|
+
siteOrigin: string;
|
|
10
|
+
/** Secrets `secrets` uploads; copied from the `.tmpl` beside it. */
|
|
11
|
+
secretsFile: string;
|
|
12
|
+
/** Variable holding the database URL; absent for an env without a
|
|
13
|
+
* database. */
|
|
14
|
+
dbUrlVar?: string;
|
|
15
|
+
}
|
|
16
|
+
/** The runtime a worker deploys on, from its wrangler config. */
|
|
17
|
+
export interface WorkerRuntime {
|
|
18
|
+
compatibilityDate: string;
|
|
19
|
+
compatibilityFlags: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
/** A blue/green env. */
|
|
22
|
+
export interface SlotTarget extends TargetBase {
|
|
23
|
+
kind: "blue-green";
|
|
24
|
+
packages: SlotPackages;
|
|
25
|
+
/** Scripts and site: the deployer's `DEPLOYMENT` var. */
|
|
26
|
+
deployment: Deployment;
|
|
27
|
+
/** The generated deployer's script (`deployerConfig`). */
|
|
28
|
+
deployer: string;
|
|
29
|
+
/** The slotted worker's runtime; the deployer runs on it too. */
|
|
30
|
+
runtime: WorkerRuntime;
|
|
31
|
+
/** The deployer's workflows by name (`DEPLOYER_FLOWS`); `drain` with
|
|
32
|
+
* `BlueGreenEnv.drain`. */
|
|
33
|
+
flows: {
|
|
34
|
+
cutover: string;
|
|
35
|
+
settle: string;
|
|
36
|
+
drain?: string;
|
|
37
|
+
};
|
|
38
|
+
/** Secrets the web and worker env blocks require. */
|
|
39
|
+
secrets: SlotSecrets;
|
|
40
|
+
/** Site paths answering `{ build }` from the web worker and the live
|
|
41
|
+
* slot. */
|
|
42
|
+
buildPaths: readonly string[];
|
|
43
|
+
dbUrlVar: string;
|
|
44
|
+
}
|
|
45
|
+
/** A worker of an in-place env, as its wrangler config deploys it. */
|
|
46
|
+
export interface ReleasedWorker extends WorkerSpec {
|
|
47
|
+
/** The deployed script. */
|
|
48
|
+
script: string;
|
|
49
|
+
/** Secrets its wrangler config requires. */
|
|
50
|
+
secrets: readonly string[];
|
|
51
|
+
/** Site path answering `{ build }` from this worker. */
|
|
52
|
+
buildPath: string;
|
|
53
|
+
}
|
|
54
|
+
/** An in-place env: its workers deploy at once, in order; its commands
|
|
55
|
+
* are `deploy` and `secrets`. */
|
|
56
|
+
export interface InPlaceTarget extends TargetBase {
|
|
57
|
+
kind: "in-place";
|
|
58
|
+
workers: readonly ReleasedWorker[];
|
|
59
|
+
}
|
|
60
|
+
/** An env's release target, by kind. */
|
|
61
|
+
export type Target = SlotTarget | InPlaceTarget;
|
|
62
|
+
/** A wrangler config block, the top level or an `env.<env>` block: the
|
|
63
|
+
* keys targets read, typed. */
|
|
64
|
+
export interface WranglerEnv {
|
|
65
|
+
readonly [key: string]: unknown;
|
|
66
|
+
name?: unknown;
|
|
67
|
+
vars?: Record<string, unknown>;
|
|
68
|
+
secrets?: {
|
|
69
|
+
required?: string[];
|
|
70
|
+
};
|
|
71
|
+
workflows?: {
|
|
72
|
+
binding: string;
|
|
73
|
+
name: string;
|
|
74
|
+
}[];
|
|
75
|
+
compatibility_date?: unknown;
|
|
76
|
+
compatibility_flags?: string[];
|
|
77
|
+
}
|
|
78
|
+
/** Workspace package `pkg`'s wrangler config path. */
|
|
79
|
+
export declare const wranglerConfig: (app: ReleaseApp, pkg: string) => string;
|
|
80
|
+
/** `pkg`'s `env.<env>` block as written; undefined while its config or
|
|
81
|
+
* the block is missing. */
|
|
82
|
+
export declare const readEnvBlock: (app: ReleaseApp, pkg: string, env: string) => WranglerEnv | undefined;
|
|
83
|
+
/** `env`'s target as its wrangler configs declare it, and every gap in
|
|
84
|
+
* those configs; the target is undefined while a gap leaves a piece of
|
|
85
|
+
* it undeclared. Throws on an env outside `app.envs`. */
|
|
86
|
+
export declare function resolveTarget(app: ReleaseApp, env: string): {
|
|
87
|
+
target?: Target;
|
|
88
|
+
gaps: Gap[];
|
|
89
|
+
};
|
|
90
|
+
/** Each script of `target` with the secrets it holds. */
|
|
91
|
+
export declare function secretsByScript(target: Target): [script: string, secrets: readonly string[]][];
|
|
92
|
+
/** The scripts of `target`. */
|
|
93
|
+
export declare const scriptsOf: (target: Target) => string[];
|
|
94
|
+
export {};
|