@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,246 @@
1
+ /** What `deploy-release` releases: each env's target, read from the
2
+ * wrangler configs that deploy it. */
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { BUILD_JSON_PATH, parseDeployEnv, } from "@astralarium/repo-deploy-info";
6
+ import { ExpectedError } from "@astralarium/repo-telemetry";
7
+ import JSON5 from "json5";
8
+ import { dbUrlVar, envNames, secretsFile, slotBuildPaths, wranglerConfigName, } from "./app.js";
9
+ import { bindingGaps, shown } from "./provision.js";
10
+ import { packageDir } from "./workspace.js";
11
+ /** Workspace package `pkg`'s wrangler config path. */
12
+ export const wranglerConfig = (app, pkg) => join(packageDir(pkg), wranglerConfigName(app));
13
+ /** The wrangler config at `path`; undefined while the file is missing,
14
+ * throws on one that is not JSON5. */
15
+ function readWrangler(path) {
16
+ if (!existsSync(path))
17
+ return undefined;
18
+ try {
19
+ return JSON5.parse(readFileSync(path, "utf8"));
20
+ }
21
+ catch (e) {
22
+ throw new ExpectedError(`${path} is not JSON5: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
23
+ }
24
+ }
25
+ /** `pkg`'s `env.<env>` block as written; undefined while its config or
26
+ * the block is missing. */
27
+ export const readEnvBlock = (app, pkg, env) => readWrangler(wranglerConfig(app, pkg))?.env?.[env];
28
+ /** Secrets `block` requires (`secrets.required`); `declared`, the app's
29
+ * declaration at `at`, must be among them, each one lacking a gap added
30
+ * to `gaps`. */
31
+ function requiredSecrets(block, declared, at, gaps) {
32
+ if (!block)
33
+ return [];
34
+ const required = block.env.secrets?.required ?? [];
35
+ const lacking = declared.filter((key) => !required.includes(key));
36
+ if (lacking.length) {
37
+ const them = lacking.length === 1 ? "it" : "them";
38
+ gaps.push({
39
+ area: "config",
40
+ package: block.package,
41
+ where: `${block.where} secrets.required`,
42
+ problem: `lacks ${lacking.join(", ")}, declared in ${at}`,
43
+ step: `Add ${them} here: secrets uploads what the env block requires.`,
44
+ });
45
+ }
46
+ return required;
47
+ }
48
+ /** `pkg`'s `env.<env>` block, its binding and name gaps added to `gaps`;
49
+ * undefined, the gap added, while its config or block is missing. Each
50
+ * gap names `pkg`. */
51
+ function envBlock(app, pkg, env, gaps) {
52
+ const path = wranglerConfig(app, pkg);
53
+ const config = readWrangler(path);
54
+ if (!config) {
55
+ gaps.push({
56
+ area: "config",
57
+ package: pkg,
58
+ where: shown(path),
59
+ problem: "is missing",
60
+ step: `Write ${pkg}'s wrangler config with an env.${env} block.`,
61
+ });
62
+ return undefined;
63
+ }
64
+ const file = shown(path);
65
+ const where = `${file} env.${env}`;
66
+ const block = config.env?.[env];
67
+ if (!block) {
68
+ gaps.push({
69
+ area: "config",
70
+ package: pkg,
71
+ where,
72
+ problem: "is missing",
73
+ step: "Add it: its vars and every binding the top level declares.",
74
+ });
75
+ return undefined;
76
+ }
77
+ gaps.push(...bindingGaps(block, config, where).map((g) => ({ ...g, package: pkg })));
78
+ const base = text(config.name);
79
+ const script = text(block.name) ?? (base && `${base}-${env}`);
80
+ if (!script)
81
+ gaps.push({
82
+ area: "config",
83
+ package: pkg,
84
+ where,
85
+ problem: "lacks name",
86
+ step: "Name the script: the top-level name (deploying as <name>-<env>) or the env block's.",
87
+ });
88
+ return { env: block, where, package: pkg, script, top: config, file };
89
+ }
90
+ /** A non-empty string, else undefined. */
91
+ const text = (value) => typeof value === "string" && value ? value : undefined;
92
+ /** Site origin `block` names (`vars.SITE_ORIGIN`); undefined, the gap
93
+ * added to `gaps`, while unset. */
94
+ function siteOriginOf(block, gaps) {
95
+ const origin = text(block.env.vars?.SITE_ORIGIN);
96
+ if (!origin)
97
+ gaps.push({
98
+ area: "config",
99
+ package: block.package,
100
+ where: block.where,
101
+ problem: "lacks vars.SITE_ORIGIN",
102
+ step: "Set vars.SITE_ORIGIN to the site's origin (https://host).",
103
+ });
104
+ return origin;
105
+ }
106
+ /** Runtime `block` deploys on: its `compatibility_date` and
107
+ * `compatibility_flags`, else the top level's; undefined, the gap added
108
+ * to `gaps`, while the date is unset. */
109
+ function runtimeOf(block, gaps) {
110
+ const compatibilityDate = text(block.env.compatibility_date ?? block.top.compatibility_date);
111
+ if (!compatibilityDate) {
112
+ gaps.push({
113
+ area: "config",
114
+ package: block.package,
115
+ where: block.file,
116
+ problem: "lacks compatibility_date",
117
+ step: "Set the worker's compatibility_date at the top level.",
118
+ });
119
+ return undefined;
120
+ }
121
+ return {
122
+ compatibilityDate,
123
+ compatibilityFlags: block.env.compatibility_flags ?? block.top.compatibility_flags ?? [],
124
+ };
125
+ }
126
+ /** The blue/green target of `env`, its gaps added to `gaps`; undefined
127
+ * while a gap leaves a piece of it undeclared. */
128
+ function slotTarget(app, env, release, gaps) {
129
+ const { packages, secrets: declared, drain } = release;
130
+ const webBlock = envBlock(app, packages.web, env, gaps);
131
+ const workerBlock = envBlock(app, packages.worker, env, gaps);
132
+ const secrets = {
133
+ web: requiredSecrets(webBlock, declared?.web ?? [], `ReleaseApp.envs.${env}.secrets.web`, gaps),
134
+ slot: requiredSecrets(workerBlock, declared?.slot ?? [], `ReleaseApp.envs.${env}.secrets.slot`, gaps),
135
+ };
136
+ const siteOrigin = webBlock && siteOriginOf(webBlock, gaps);
137
+ const runtime = workerBlock && runtimeOf(workerBlock, gaps);
138
+ if (!webBlock?.script || !workerBlock?.script || !siteOrigin || !runtime)
139
+ return undefined;
140
+ const slotBase = workerBlock.script;
141
+ const deployer = `${release.deployer}-${env}`;
142
+ const flow = (name) => `${deployer}-${name}`;
143
+ return {
144
+ kind: "blue-green",
145
+ env,
146
+ packages,
147
+ deployment: {
148
+ webScript: webBlock.script,
149
+ slotScripts: { blue: `${slotBase}-blue`, green: `${slotBase}-green` },
150
+ siteOrigin,
151
+ },
152
+ siteOrigin,
153
+ deployer,
154
+ runtime,
155
+ flows: {
156
+ cutover: flow("cutover"),
157
+ settle: flow("settle"),
158
+ drain: drain ? flow("drain") : undefined,
159
+ },
160
+ secrets,
161
+ buildPaths: slotBuildPaths(release),
162
+ dbUrlVar: dbUrlVar(env, release),
163
+ secretsFile: secretsFile(app, env),
164
+ };
165
+ }
166
+ /** `spec`, the `index`th worker of `env`, as its wrangler config deploys
167
+ * it, with its site; its gaps added to `gaps`. */
168
+ function releasedWorker(app, env, spec, index, gaps) {
169
+ const block = envBlock(app, spec.package, env, gaps);
170
+ if (!block)
171
+ return undefined;
172
+ const { script } = block;
173
+ const siteOrigin = siteOriginOf(block, gaps);
174
+ const secrets = requiredSecrets(block, spec.secrets ?? [], `ReleaseApp.envs.${env}.workers[${index}].secrets`, gaps);
175
+ if (!script || !siteOrigin)
176
+ return undefined;
177
+ return [
178
+ {
179
+ ...spec,
180
+ script,
181
+ secrets,
182
+ buildPath: spec.buildPath ?? BUILD_JSON_PATH,
183
+ },
184
+ siteOrigin,
185
+ ];
186
+ }
187
+ /** The in-place target of `env`, its gaps added to `gaps`; its workers
188
+ * must agree on the site. */
189
+ function workerTarget(app, env, release, gaps) {
190
+ const released = release.workers.map((spec, i) => releasedWorker(app, env, spec, i, gaps));
191
+ const resolved = released.filter((r) => r !== undefined);
192
+ if (resolved.length < released.length)
193
+ return undefined;
194
+ const [, siteOrigin] = resolved[0];
195
+ if (resolved.some(([, origin]) => origin !== siteOrigin)) {
196
+ gaps.push({
197
+ area: "config",
198
+ where: `env.${env} vars.SITE_ORIGIN`,
199
+ problem: `differs between workers: ${resolved
200
+ .map(([w, origin]) => `${w.package} ${origin}`)
201
+ .join(", ")}`,
202
+ step: "Set one site origin in every worker's env block.",
203
+ });
204
+ return undefined;
205
+ }
206
+ return {
207
+ kind: "in-place",
208
+ env,
209
+ workers: resolved.map(([worker]) => worker),
210
+ siteOrigin,
211
+ secretsFile: secretsFile(app, env),
212
+ dbUrlVar: dbUrlVar(env, release),
213
+ };
214
+ }
215
+ /** `env`'s declared release; throws on an env outside `app.envs`. */
216
+ const envRelease = (app, env) => app.envs[parseDeployEnv(env, envNames(app))];
217
+ /** `env`'s target as its wrangler configs declare it, and every gap in
218
+ * those configs; the target is undefined while a gap leaves a piece of
219
+ * it undeclared. Throws on an env outside `app.envs`. */
220
+ export function resolveTarget(app, env) {
221
+ const gaps = [];
222
+ const release = envRelease(app, env);
223
+ const target = release.deploy === "in-place"
224
+ ? workerTarget(app, env, release, gaps)
225
+ : slotTarget(app, env, release, gaps);
226
+ return { target, gaps };
227
+ }
228
+ /** Each script of `target` with the secrets it holds. */
229
+ export function secretsByScript(target) {
230
+ if (target.kind === "in-place")
231
+ return target.workers.map((w) => [w.script, w.secrets]);
232
+ const { webScript, slotScripts } = target.deployment;
233
+ const { secrets } = target;
234
+ return [
235
+ [webScript, secrets.web],
236
+ [slotScripts.blue, secrets.slot],
237
+ [slotScripts.green, secrets.slot],
238
+ ];
239
+ }
240
+ /** The scripts of `target`. */
241
+ export const scriptsOf = (target) => target.kind === "in-place"
242
+ ? target.workers.map((w) => w.script)
243
+ : [
244
+ target.deployment.webScript,
245
+ ...Object.values(target.deployment.slotScripts),
246
+ ];
@@ -0,0 +1,28 @@
1
+ /** Web versions and slot builds: which build is live, which is next. */
2
+ import { type DeploymentRecord, type Slot, type WorkerVersion } from "@astralarium/repo-deploy-info";
3
+ import type { SlotSession } from "./session.ts";
4
+ /** A web version with its slot and build. */
5
+ export interface WebVersion {
6
+ id: string;
7
+ slot: Slot;
8
+ build: string;
9
+ }
10
+ /** The live web version and the next one staged beside it. */
11
+ export interface Staged {
12
+ live: WebVersion;
13
+ next: WebVersion;
14
+ }
15
+ /** Slot of web `version` (its `SLOT` var); undefined when unslotted. */
16
+ export declare const slotOf: (version: WorkerVersion) => Slot | undefined;
17
+ /** Build of `slot`'s version at 100%; undefined when none is deployed. */
18
+ export declare function slotBuild(s: SlotSession, slot: Slot): Promise<string | undefined>;
19
+ /** The 100% web version; throws unless it has a slot and build. */
20
+ export declare function readLive(s: SlotSession): Promise<WebVersion>;
21
+ /** `live` and the 0% web version of the latest deployment; throws unless
22
+ * the 0% one is a build on the other slot, which still runs it. */
23
+ export declare function readStaged(s: SlotSession, live: WebVersion): Promise<Staged>;
24
+ /** Versions once at 100%, newest deployment first, other than `live`. */
25
+ export declare function earlierLiveVersions(deployments: readonly DeploymentRecord[], live: string): string[];
26
+ /** Version id of the `version-upload` entry in wrangler's output-file
27
+ * `jsonl`; throws when absent. */
28
+ export declare function uploadedVersionId(jsonl: string): string;
@@ -0,0 +1,70 @@
1
+ /** Web versions and slot builds: which build is live, which is next. */
2
+ import { buildOf, isSlot, SLOT_VAR, varOf, } from "@astralarium/repo-deploy-info";
3
+ import { ExpectedError } from "@astralarium/repo-telemetry";
4
+ import { idAt } from "./cloudflare.js";
5
+ /** Slot of web `version` (its `SLOT` var); undefined when unslotted. */
6
+ export const slotOf = (version) => {
7
+ const slot = varOf(version, SLOT_VAR);
8
+ return isSlot(slot) ? slot : undefined;
9
+ };
10
+ /** Build of `slot`'s version at 100%; undefined when none is deployed. */
11
+ export async function slotBuild(s, slot) {
12
+ const script = s.target.deployment.slotScripts[slot];
13
+ const id = await s.cf.versionAt(script, 100);
14
+ return id && buildOf(await s.cf.version(script, id));
15
+ }
16
+ /** The web script's latest deployment. */
17
+ const latestWeb = async (s) => (await s.cf.deployments(s.target.deployment.webScript))[0];
18
+ /** The 100% web version; throws unless it has a slot and build. */
19
+ export async function readLive(s) {
20
+ const web = s.target.deployment.webScript;
21
+ const id = idAt(await latestWeb(s), 100);
22
+ if (!id)
23
+ throw new ExpectedError(`No version of ${web} is live: run ${s.cli("init")}.`);
24
+ const version = await s.cf.version(web, id);
25
+ const slot = slotOf(version);
26
+ const build = buildOf(version);
27
+ if (!slot || !build)
28
+ throw new ExpectedError(`No slotted build is live: run ${s.cli("init")}.`);
29
+ return { id, slot, build };
30
+ }
31
+ /** `live` and the 0% web version of the latest deployment; throws unless
32
+ * the 0% one is a build on the other slot, which still runs it. */
33
+ export async function readStaged(s, live) {
34
+ const web = s.target.deployment.webScript;
35
+ const id = idAt(await latestWeb(s), 0);
36
+ if (!id)
37
+ throw new ExpectedError(`No next build: run ${s.cli("stage")}.`);
38
+ const version = await s.cf.version(web, id);
39
+ const slot = slotOf(version);
40
+ const build = buildOf(version);
41
+ if (!slot || !build)
42
+ throw new ExpectedError(`The 0% version (${id}) is not a valid next build: slot '${slot ?? ""}', build '${build ?? ""}'.`);
43
+ if (slot === live.slot)
44
+ throw new ExpectedError(`The 0% version (${id}) is on the live slot (${slot}): run ${s.cli("stage")}.`);
45
+ const held = await slotBuild(s, slot);
46
+ if (held !== build)
47
+ throw new ExpectedError(`${s.target.deployment.slotScripts[slot]} runs build '${held ?? ""}', not the staged '${build}': run ${s.cli("stage")}.`);
48
+ return { live, next: { id, slot, build } };
49
+ }
50
+ /** Versions once at 100%, newest deployment first, other than `live`. */
51
+ export function earlierLiveVersions(deployments, live) {
52
+ const ids = deployments
53
+ .toSorted((a, b) => Date.parse(b.created_on) - Date.parse(a.created_on))
54
+ .flatMap((d) => d.versions.filter((v) => v.percentage === 100))
55
+ .map((v) => v.version_id)
56
+ .filter((id) => id !== live);
57
+ return [...new Set(ids)];
58
+ }
59
+ /** Version id of the `version-upload` entry in wrangler's output-file
60
+ * `jsonl`; throws when absent. */
61
+ export function uploadedVersionId(jsonl) {
62
+ const upload = jsonl
63
+ .split("\n")
64
+ .filter((line) => line.trim())
65
+ .map((line) => JSON.parse(line))
66
+ .find((entry) => entry.type === "version-upload");
67
+ if (!upload?.version_id)
68
+ throw new Error("versions upload reported no version id");
69
+ return upload.version_id;
70
+ }
@@ -0,0 +1,11 @@
1
+ /** Nearest directory at or above `from` holding `pnpm-workspace.yaml`;
2
+ * throws `ExpectedError` outside a workspace. */
3
+ export declare function requireWorkspaceRoot(from: string): string;
4
+ /** Root of the workspace holding this package: linked from its
5
+ * `packages/`, or installed under its `node_modules/`. Found on first
6
+ * use, so a package outside a workspace fails inside a command, where
7
+ * `main` reports it. */
8
+ export declare function ownWorkspaceRoot(): string;
9
+ /** Directory of package `name` of the workspace at `root` (this
10
+ * package's by default); throws when the workspace lacks it. */
11
+ export declare function packageDir(name: string, root?: string): string;
@@ -0,0 +1,40 @@
1
+ /** The pnpm workspace this package is installed in: its root and its
2
+ * packages' directories by name. */
3
+ import { workspacePackages, workspaceRoot, } from "@astralarium/repo-package-files";
4
+ import { ExpectedError } from "@astralarium/repo-telemetry";
5
+ /** Nearest directory at or above `from` holding `pnpm-workspace.yaml`;
6
+ * throws `ExpectedError` outside a workspace. */
7
+ export function requireWorkspaceRoot(from) {
8
+ const root = workspaceRoot(from);
9
+ if (root === undefined)
10
+ throw new ExpectedError(`${from} is outside a pnpm workspace`);
11
+ return root;
12
+ }
13
+ /** `ownWorkspaceRoot()` once found. */
14
+ let ownRoot;
15
+ /** Root of the workspace holding this package: linked from its
16
+ * `packages/`, or installed under its `node_modules/`. Found on first
17
+ * use, so a package outside a workspace fails inside a command, where
18
+ * `main` reports it. */
19
+ export function ownWorkspaceRoot() {
20
+ return (ownRoot ??= requireWorkspaceRoot(import.meta.dirname));
21
+ }
22
+ /** Directory of each named package, by workspace root; read once per
23
+ * root. */
24
+ const packagesByRoot = new Map();
25
+ /** Directory of package `name` of the workspace at `root` (this
26
+ * package's by default); throws when the workspace lacks it. */
27
+ export function packageDir(name, root = ownWorkspaceRoot()) {
28
+ let packages = packagesByRoot.get(root);
29
+ if (!packages) {
30
+ packages = new Map();
31
+ for (const { dir, manifest } of workspacePackages(root))
32
+ if (typeof manifest.name === "string")
33
+ packages.set(manifest.name, dir);
34
+ packagesByRoot.set(root, packages);
35
+ }
36
+ const dir = packages.get(name);
37
+ if (!dir)
38
+ throw new ExpectedError(`${name} is not a package of the workspace at ${root}.`);
39
+ return dir;
40
+ }
@@ -0,0 +1,4 @@
1
+ /** Holds the deploy state's row of the database at `connectionString` for
2
+ * the enclosing suite via an advisory lock; each test starts with it
3
+ * clear, and the suite leaves it clear. */
4
+ export declare function holdDeployState(connectionString: string): void;
@@ -0,0 +1,36 @@
1
+ /** Test support: the deploy state's one row (`deploy.state`), shared
2
+ * by every suite writing it. */
3
+ import postgres from "postgres";
4
+ import { afterAll, beforeAll, beforeEach } from "vitest";
5
+ /** Advisory-lock name the suites writing `deploy.state` take turns on. */
6
+ const DEPLOY_STATE_LOCK = "deploy.state";
7
+ /** Ceiling on a suite's wait for the deploy state while another holds it. */
8
+ const HOLD_TIMEOUT_MS = 600_000;
9
+ /** Clears the deploy state: no live build, update or lease. */
10
+ async function clearDeployState(sql) {
11
+ await sql `
12
+ UPDATE deploy.state
13
+ SET live_build = NULL, update_at = NULL, update_run = NULL,
14
+ version = version + 1,
15
+ lease_holder = NULL, lease_operation = NULL, lease_expires_at = NULL
16
+ `;
17
+ }
18
+ /** Holds the deploy state's row of the database at `connectionString` for
19
+ * the enclosing suite via an advisory lock; each test starts with it
20
+ * clear, and the suite leaves it clear. */
21
+ export function holdDeployState(connectionString) {
22
+ let sql;
23
+ let lock;
24
+ beforeAll(async () => {
25
+ sql = postgres(connectionString);
26
+ lock = await sql.reserve();
27
+ await lock `SELECT pg_advisory_lock(hashtextextended(${DEPLOY_STATE_LOCK}, 0))`;
28
+ }, HOLD_TIMEOUT_MS);
29
+ beforeEach(() => clearDeployState(sql));
30
+ afterAll(async () => {
31
+ await clearDeployState(sql);
32
+ await lock `SELECT pg_advisory_unlock(hashtextextended(${DEPLOY_STATE_LOCK}, 0))`;
33
+ lock.release();
34
+ await sql.end({ timeout: 5 });
35
+ });
36
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReleaseApp } from "../src/app.ts";
2
+ /** What `testReleaseBundles` builds. */
3
+ export interface ReleaseBundlesSuite {
4
+ /** The release declaration naming the worker's envs. */
5
+ app: ReleaseApp;
6
+ /** The wrangler-built worker's workspace package; each env whose
7
+ * target deploys it bundles with `--env`. */
8
+ package: string;
9
+ /** A dev-only source the top-level (dev) bundle carries, relative to
10
+ * the workspace root: shows the check sees dev code. */
11
+ devSource: string;
12
+ }
13
+ /** Tests of `package`'s minified dry-run bundles: the dev bundle carries
14
+ * `devSource`; the bundle of each env deploying it carries no dev or
15
+ * test code. Throws while no env deploys `package`. */
16
+ export declare function testReleaseBundles({ app, package: pkg, devSource, }: ReleaseBundlesSuite): void;
@@ -0,0 +1,56 @@
1
+ /** Test suite of a wrangler-bundled worker's deploy bundles, built as
2
+ * `deploy-release` builds them. */
3
+ import { execFile } from "node:child_process";
4
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { afterAll, describe, expect, it } from "vitest";
9
+ import { devTestSources, requireReleaseBundle, workerBundleArgs, } from "../src/bundle.js";
10
+ import { wranglerConfig } from "../src/target.js";
11
+ import { packageDir } from "../src/workspace.js";
12
+ import { releasedTargets } from "./release.js";
13
+ import { bundledEnvs } from "./release-checks.js";
14
+ /** Tests of `package`'s minified dry-run bundles: the dev bundle carries
15
+ * `devSource`; the bundle of each env deploying it carries no dev or
16
+ * test code. Throws while no env deploys `package`. */
17
+ export function testReleaseBundles({ app, package: pkg, devSource, }) {
18
+ const envs = bundledEnvs(releasedTargets(app), pkg);
19
+ if (!envs.length)
20
+ throw new Error(`No env of the release wrangler-bundles ${pkg}.`);
21
+ const appDir = packageDir(pkg);
22
+ const config = wranglerConfig(app, pkg);
23
+ const wrangler = join(appDir, "node_modules/.bin/wrangler");
24
+ const dirs = [];
25
+ afterAll(() => Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true }))));
26
+ /** The directory of the bundle for `env` (top level when absent). */
27
+ async function bundle(env) {
28
+ const outdir = await mkdtemp(join(tmpdir(), "release-bundle-"));
29
+ dirs.push(outdir);
30
+ await promisify(execFile)(wrangler, [
31
+ ...workerBundleArgs(outdir, env === undefined
32
+ ? undefined
33
+ : {
34
+ env,
35
+ deployInfo: JSON.stringify({
36
+ build: "bundle-test",
37
+ components: {},
38
+ env,
39
+ }),
40
+ }),
41
+ "--config",
42
+ config,
43
+ ], { cwd: appDir });
44
+ return outdir;
45
+ }
46
+ describe("worker deploy bundle", () => {
47
+ it(`carries ${devSource} in the dev bundle`, async () => {
48
+ const outdir = await bundle();
49
+ const map = await readFile(join(outdir, "index.js.map"), "utf8");
50
+ expect(devTestSources(map, outdir)).toContain(devSource);
51
+ });
52
+ it.each(envs)("ships an --env %s bundle free of dev and test code", async (env) => {
53
+ await expect(requireReleaseBundle(await bundle(env))).resolves.toBeUndefined();
54
+ });
55
+ });
56
+ }
@@ -0,0 +1,19 @@
1
+ import type { ReleasedTarget } from "./release.ts";
2
+ /** Every package of `released` names the target's site; the web worker
3
+ * (a blue/green target's `web`, an in-place target's any) routes
4
+ * its host. */
5
+ export declare function checkSite({ target, blocks, packages }: ReleasedTarget): void;
6
+ /** The packages of `released` bind one Hyperdrive config; once
7
+ * provisioned, a real id none of `others` binds. A target without a
8
+ * database (`dbUrlVar`) binds none. */
9
+ export declare function checkDatabase(released: ReleasedTarget, others: readonly ReleasedTarget[]): void;
10
+ /** Every queue consumer of `released` declares `max_retries` of
11
+ * `maxRetries`; with `maxRetries` undefined, `released` declares none. */
12
+ export declare function checkQueueRetries({ blocks, packages }: ReleasedTarget, maxRetries: number | undefined): void;
13
+ /** A blue/green target's web worker binds both slot scripts. */
14
+ export declare function checkSlotBindings({ target, blocks }: ReleasedTarget): void;
15
+ /** Each script of `targets` belongs to one target. */
16
+ export declare function checkScripts(targets: readonly ReleasedTarget[]): void;
17
+ /** Envs of `targets` that wrangler-bundle `pkg`: a blue/green env's
18
+ * slotted worker, an in-place env's `wrangler` worker. */
19
+ export declare const bundledEnvs: (targets: readonly ReleasedTarget[], pkg: string) => string[];
@@ -0,0 +1,71 @@
1
+ /** Assertions the release suites run on an app's released targets:
2
+ * site, database, queue retries, slot bindings, scripts and
3
+ * wrangler-bundled envs. */
4
+ import { expect } from "vitest";
5
+ import { scriptsOf } from "../src/target.js";
6
+ /** A Hyperdrive id. */
7
+ const HYPERDRIVE_ID = /^[0-9a-f]{32}$/;
8
+ /** Hosts `block`'s routes serve. */
9
+ const routedHosts = (block) => (block?.routes ?? []).map((route) => (typeof route === "string" ? route : route.pattern).split("/")[0]);
10
+ /** Services `block` binds. */
11
+ const servicesOf = (block) => (block?.services ?? []).map((s) => s.service);
12
+ /** Hyperdrive ids `block` binds. */
13
+ const hyperdriveIds = (block) => (block?.hyperdrive ?? []).map((h) => h.id);
14
+ /** Every package of `released` names the target's site; the web worker
15
+ * (a blue/green target's `web`, an in-place target's any) routes
16
+ * its host. */
17
+ export function checkSite({ target, blocks, packages }) {
18
+ for (const pkg of packages)
19
+ expect(blocks[pkg]?.vars?.SITE_ORIGIN, `${pkg} SITE_ORIGIN`).toBe(target.siteOrigin);
20
+ const routing = target.kind === "blue-green" ? [target.packages.web] : packages;
21
+ expect(routing.flatMap((pkg) => routedHosts(blocks[pkg]))).toContain(new URL(target.siteOrigin).host);
22
+ }
23
+ /** The packages of `released` bind one Hyperdrive config; once
24
+ * provisioned, a real id none of `others` binds. A target without a
25
+ * database (`dbUrlVar`) binds none. */
26
+ export function checkDatabase(released, others) {
27
+ const ids = (t) => t.packages.flatMap((pkg) => hyperdriveIds(t.blocks[pkg]));
28
+ const own = [...new Set(ids(released))];
29
+ if (released.target.dbUrlVar === undefined) {
30
+ expect(own, "Hyperdrive ids bound without a database").toEqual([]);
31
+ return;
32
+ }
33
+ expect(own, "Hyperdrive ids bound").toHaveLength(1);
34
+ if (released.bootstrapping)
35
+ return;
36
+ expect(own[0]).toMatch(HYPERDRIVE_ID);
37
+ expect(others.flatMap(ids)).not.toContain(own[0]);
38
+ }
39
+ /** Every queue consumer of `released` declares `max_retries` of
40
+ * `maxRetries`; with `maxRetries` undefined, `released` declares none. */
41
+ export function checkQueueRetries({ blocks, packages }, maxRetries) {
42
+ for (const pkg of packages) {
43
+ const consumers = blocks[pkg]?.queues?.consumers ?? [];
44
+ if (maxRetries === undefined) {
45
+ expect(consumers.map(({ queue }) => queue), `${pkg} queue consumers without queueMaxRetries`).toEqual([]);
46
+ continue;
47
+ }
48
+ for (const { queue, max_retries } of consumers)
49
+ expect(max_retries, `${pkg} ${queue} max_retries`).toBe(maxRetries);
50
+ }
51
+ }
52
+ /** A blue/green target's web worker binds both slot scripts. */
53
+ export function checkSlotBindings({ target, blocks }) {
54
+ if (target.kind !== "blue-green")
55
+ return;
56
+ const { web } = target.packages;
57
+ for (const script of Object.values(target.deployment.slotScripts))
58
+ expect(servicesOf(blocks[web]), `${web} services`).toContain(script);
59
+ }
60
+ /** Each script of `targets` belongs to one target. */
61
+ export function checkScripts(targets) {
62
+ const scripts = targets.flatMap((t) => scriptsOf(t.target));
63
+ expect(scripts.filter((s, i) => scripts.indexOf(s) !== i)).toEqual([]);
64
+ }
65
+ /** Envs of `targets` that wrangler-bundle `pkg`: a blue/green env's
66
+ * slotted worker, an in-place env's `wrangler` worker. */
67
+ export const bundledEnvs = (targets, pkg) => targets
68
+ .filter(({ target }) => target.kind === "in-place"
69
+ ? target.workers.some((w) => w.package === pkg && w.build === "wrangler")
70
+ : target.packages.worker === pkg)
71
+ .map(({ env }) => env);
@@ -0,0 +1,44 @@
1
+ import { type ReleaseApp } from "../src/app.ts";
2
+ import { type Target, type WranglerEnv } from "../src/target.ts";
3
+ /** An `env.<env>` block: the keys the suite reads, typed. */
4
+ export interface ReleasedBlock extends WranglerEnv {
5
+ routes?: (string | {
6
+ pattern: string;
7
+ })[];
8
+ services?: {
9
+ binding: string;
10
+ service: string;
11
+ entrypoint?: string;
12
+ }[];
13
+ hyperdrive?: {
14
+ binding: string;
15
+ id: string;
16
+ }[];
17
+ workflows?: {
18
+ binding: string;
19
+ name: string;
20
+ class_name?: string;
21
+ }[];
22
+ queues?: {
23
+ consumers?: {
24
+ queue: string;
25
+ max_retries?: number;
26
+ }[];
27
+ };
28
+ }
29
+ /** A target with the env blocks of its packages. */
30
+ export interface ReleasedTarget {
31
+ env: string;
32
+ target: Target;
33
+ bootstrapping: boolean;
34
+ /** Each package's `env.<env>` block; undefined while missing. */
35
+ blocks: Readonly<Record<string, ReleasedBlock | undefined>>;
36
+ /** Packages serving the site (`packagesOf`). */
37
+ packages: readonly string[];
38
+ }
39
+ /** Every target of `app` its configs declare, with their env blocks;
40
+ * an env whose target is undeclared is left out. */
41
+ export declare function releasedTargets(app: ReleaseApp): ReleasedTarget[];
42
+ /** Tests of `app`'s declaration against the repo's wrangler configs and
43
+ * secrets templates, every declared target's. */
44
+ export declare function testRelease(app: ReleaseApp): void;