@astralarium/repo-deploy-release 0.0.0-stage → 0.1.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 +138 -0
  6. package/dist/src/app.js +101 -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 +16 -0
  54. package/dist/testkit/release-checks.js +57 -0
  55. package/dist/testkit/release.d.ts +38 -0
  56. package/dist/testkit/release.js +55 -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
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,62 @@
1
- # Temporary Holding Version
1
+ # `@astralarium/repo-deploy-release`
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
+ Release deploy envs to Cloudflare Workers
4
+ from the operator's machine via wrangler.
5
+
6
+ ```sh
7
+ deploy-release --app <module> --help
8
+ ```
9
+
10
+ Deploys require clean checkouts.
11
+
12
+ ```ts
13
+ const site = {
14
+ deploy: "blue-green",
15
+ packages: { web: "web", worker: "worker" },
16
+ deployer: "deployer",
17
+ } as const;
18
+
19
+ export const release = defineReleaseApp({
20
+ // checkouts, secretsDir, …
21
+ envs: {
22
+ staging: site,
23
+ production: { ...site, bootstrapping: true },
24
+ translator: {
25
+ deploy: "in-place",
26
+ workers: [
27
+ { package: "worker", build: "wrangler" },
28
+ { package: "web", build: "vite" },
29
+ ],
30
+ },
31
+ },
32
+ });
33
+ ```
34
+
35
+ A `blue-green` env updates the idle slot,
36
+ then the deployer (`@astralarium/repo-deploy-workflows`) cuts over as scheduled.
37
+ Slots add `-blue`/`-green`;
38
+ the generated deployer deploys as `<deployer>-<env>`.
39
+
40
+ ```sh
41
+ # Cut over at 4 AM Pacific tomorrow
42
+ deploy-release --app <module> deploy --env production
43
+ # Cut over now, ending ongoing work
44
+ deploy-release --app <module> deploy --env production --at now --force
45
+ # Cut over in 90 minutes
46
+ deploy-release --app <module> stage --env production
47
+ deploy-release --app <module> cutover --env production --at 1h30m
48
+ # Rollback to the previous build
49
+ deploy-release --app <module> rollback --env production
50
+ ```
51
+
52
+ An `in-place` env deploys immediately
53
+
54
+ ```sh
55
+ deploy-release --app <module> deploy --env translator
56
+ ```
57
+
58
+ New env: declare in `envs` with `bootstrapping: true`, then follow
59
+ `check --env <env>` until provisioned.
60
+
61
+ `testRelease` (`./testkit/release`) checks the release declaration
62
+ against wrangler configs.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ /** `deploy-release --app <module> <command>`: releases the app module's
3
+ * `release` declaration; `--help` lists commands and env. */
4
+ import { loadApp } from "@astralarium/repo-deploy-info/server";
5
+ import { defineReleaseApp } from "../src/app.js";
6
+ import { main } from "../src/main.js";
7
+ await main(process.argv.slice(2), (app) => loadApp(app, "release", (value) => defineReleaseApp(value)));
@@ -0,0 +1,138 @@
1
+ import { type Checkouts } from "@astralarium/repo-deploy-info/server";
2
+ import type { Sql, TransactionSql } from "@astralarium/repo-postgres/sql";
3
+ import type { EnvVars } from "./process.ts";
4
+ /** How a worker package builds for a release. */
5
+ export type WorkerBuild =
6
+ /** `pnpm --filter <package> build`: Vite bakes `DEPLOY_INFO` and writes
7
+ * wrangler's redirected config for `CLOUDFLARE_ENV`. */
8
+ "vite"
9
+ /** `wrangler deploy --dry-run` into a bundle `requireReleaseBundle`
10
+ * checks, uploaded `--no-bundle`. */
11
+ | "wrangler";
12
+ /** The command name, as messages cite it. */
13
+ export declare const PROGRAM = "deploy-release";
14
+ /** A worker an in-place env deploys: `wrangler deploy` of its build. */
15
+ export interface WorkerSpec {
16
+ /** Workspace package holding the wrangler config; its `env.<env>` block
17
+ * names the script, `SITE_ORIGIN` and the required secrets. */
18
+ package: string;
19
+ build: WorkerBuild;
20
+ /** Vars set at deploy (`--var`), beside `BUILD`. */
21
+ vars?: Readonly<Record<string, string>>;
22
+ /** Site path this worker answers `{ build }` on; the smoke test reads
23
+ * it after each deploy. Default `BUILD_JSON_PATH`. */
24
+ buildPath?: string;
25
+ /** Secrets the code reads, declared from it; an env block's
26
+ * `secrets.required` lacking one is a config gap. */
27
+ secrets?: readonly string[];
28
+ }
29
+ /** Packages of a blue/green release. Each env block's script (its
30
+ * `name`, else wrangler's `<top-level name>-<env>`) names the deploy. */
31
+ export interface SlotPackages {
32
+ /** Web worker: Vite-built, uploaded as versions to its script. */
33
+ web: string;
34
+ /** Slotted worker: wrangler-built, deployed to `<script>-blue` and
35
+ * `<script>-green`. */
36
+ worker: string;
37
+ }
38
+ /** Secrets of a blue/green release, by script. */
39
+ export interface SlotSecrets {
40
+ /** The web worker's. */
41
+ web: readonly string[];
42
+ /** Each slot's. */
43
+ slot: readonly string[];
44
+ }
45
+ /** Settings every env declares beside its deploy. */
46
+ interface EnvReleaseBase {
47
+ /** Variable holding the env's database URL. A blue/green env defaults
48
+ * to `<ENV>_DB_URL`; an in-place env has a database only when set, and
49
+ * then holds the deploy lease through `deploy` and `secrets`. */
50
+ dbUrlVar?: string;
51
+ /** Still being provisioned: `check` reports its gaps and exits 0; its
52
+ * commands refuse on gaps like any env's. */
53
+ bootstrapping?: boolean;
54
+ }
55
+ /** An env staging each build on its idle slot, then cutting over on its
56
+ * own schedule. */
57
+ export interface BlueGreenEnv extends EnvReleaseBase {
58
+ deploy: "blue-green";
59
+ packages: SlotPackages;
60
+ /** Deployer worker `deploy-release` generates
61
+ * (`@astralarium/repo-deploy-workflows/deployer`): deploys as `<deployer>-<env>`,
62
+ * its workflows as `<deployer>-<env>-<flow>`. */
63
+ deployer: string;
64
+ /** Secrets the code reads, declared from it; the web or worker env
65
+ * block's `secrets.required` lacking one is a config gap. */
66
+ secrets?: SlotSecrets;
67
+ /** Site paths answering `{ build }` from the web worker and the live
68
+ * slot; the smoke test reads them after each stage. Default
69
+ * `[BUILD_JSON_PATH]`. */
70
+ buildPaths?: readonly string[];
71
+ /** Whether the slotted worker's `OpsControl` drains its build's ongoing
72
+ * work (`OpsControlRpc.drain`): the deployer binds `DEPLOYER_FLOWS.drain`,
73
+ * the cutover drains the live slot, `--force` on `stage`/`deploy` drains
74
+ * the idle slot. Unset, `stage`/`deploy` wait out `ongoingWork` alone. */
75
+ drain?: boolean;
76
+ }
77
+ /** An env deploying its workers at once. */
78
+ export interface InPlaceEnv extends EnvReleaseBase {
79
+ deploy: "in-place";
80
+ /** Deployed in order. */
81
+ workers: readonly WorkerSpec[];
82
+ }
83
+ /** How one env deploys. */
84
+ export type EnvRelease = BlueGreenEnv | InPlaceEnv;
85
+ /** Removes the app's state of builds outside `kept`, the builds the
86
+ * deployment runs after the deploy (a blue/green env: the live and the
87
+ * staged build; an in-place env: the deployed build). Runs in a
88
+ * transaction fenced on the deploy lease. */
89
+ export type RetireBuilds = (tx: TransactionSql, kept: readonly string[]) => Promise<void>;
90
+ /** Counts the work items still running on `build` (players in matches,
91
+ * open sessions), 0 once there are none. `stage`/`deploy` wait for 0 on
92
+ * the idle slot before replacing its build, unless `--force` (with
93
+ * `drain`) ends them. */
94
+ export type OngoingWork = (sql: Sql, build: string) => Promise<number>;
95
+ /** The app as `deploy-release` releases it. */
96
+ export interface ReleaseApp {
97
+ /** Checkouts minted into every build; `pull` siblings fast-forward
98
+ * as each build command starts. */
99
+ checkouts: Checkouts;
100
+ /** Every deploy env (a deployed build's `CLOUDFLARE_ENV`) and how it
101
+ * deploys. */
102
+ envs: Readonly<Record<string, EnvRelease>>;
103
+ /** Absolute directory of the `.env.<env>` secrets files `secrets`
104
+ * uploads, each copied from the `.tmpl` beside it. */
105
+ secretsDir: string;
106
+ /** Wrangler config file within each package; default `wrangler.jsonc`. */
107
+ wranglerConfig?: string;
108
+ /** Process environment variables the app's builds read, with a brief
109
+ * for help. A build sees those `--build-env` sets (`buildEnvOf`). */
110
+ buildEnv?: Readonly<Record<string, string>>;
111
+ /** Cleans up after each deploy of an env with a database. */
112
+ retireBuilds?: RetireBuilds;
113
+ /** Work `stage`/`deploy` wait out on the idle slot; absent, they replace
114
+ * the idle slot's build at once. */
115
+ ongoingWork?: OngoingWork;
116
+ }
117
+ /** `app`, validated (`releaseAppSchema`); throws `ExpectedError` naming
118
+ * each problem. */
119
+ export declare function defineReleaseApp(app: ReleaseApp): ReleaseApp;
120
+ /** Site paths a blue/green stage smoke-tests (`BlueGreenEnv.buildPaths`). */
121
+ export declare const slotBuildPaths: (release: BlueGreenEnv) => readonly string[];
122
+ /** Every deploy env of `app`. */
123
+ export declare const envNames: (app: ReleaseApp) => string[];
124
+ /** `app`'s wrangler config file name, within each package. */
125
+ export declare const wranglerConfigName: (app: ReleaseApp) => string;
126
+ /** `env`'s secrets file. */
127
+ export declare const secretsFile: (app: ReleaseApp, env: string) => string;
128
+ /** Variable holding `env`'s database URL: its `dbUrlVar`, else
129
+ * `<ENV>_DB_URL` for a blue/green env; undefined for an in-place env
130
+ * without one. */
131
+ export declare function dbUrlVar(env: string, release: BlueGreenEnv): string;
132
+ export declare function dbUrlVar(env: string, release: EnvRelease): string | undefined;
133
+ /** `--build-env` values: `ReleaseApp.buildEnv` variables as NAME, VALUE. */
134
+ export type BuildEnv = readonly (readonly [name: string, value: string])[];
135
+ /** The `buildEnv` variables of a build: `given`, the operator's other
136
+ * values removed. */
137
+ export declare const buildEnvOf: (app: ReleaseApp, given?: BuildEnv) => EnvVars;
138
+ export {};
@@ -0,0 +1,101 @@
1
+ /** The app as `deploy-release` releases it: its declaration and the
2
+ * validation every command starts from. */
3
+ import { isAbsolute, join } from "node:path";
4
+ import { BUILD_JSON_PATH, BUILD_VAR } from "@astralarium/repo-deploy-info";
5
+ import { checkoutsSchema, } from "@astralarium/repo-deploy-info/server";
6
+ import { ExpectedError } from "@astralarium/repo-telemetry";
7
+ import { z } from "zod";
8
+ /** The command name, as messages cite it. */
9
+ export const PROGRAM = "deploy-release";
10
+ /** A non-empty string. */
11
+ const text = z.string().min(1);
12
+ /** A site path: starts with `/`, free of `?` and `#`. */
13
+ const sitePath = z.string().regex(/^\/[^?#]*$/, "expected a path like /x");
14
+ /** A hook as declared: a function. */
15
+ const hook = () => z.custom((value) => typeof value === "function", "expected a function");
16
+ /** Fields every env declares beside its deploy. */
17
+ const envBase = {
18
+ dbUrlVar: text.optional(),
19
+ bootstrapping: z.boolean().optional(),
20
+ };
21
+ /** Vars `deploy` sets on every worker itself. */
22
+ const RESERVED_VARS = [BUILD_VAR];
23
+ /** `ReleaseApp` as declared. */
24
+ const releaseAppSchema = z
25
+ .strictObject({
26
+ checkouts: checkoutsSchema,
27
+ envs: z
28
+ .record(text, z.discriminatedUnion("deploy", [
29
+ z.strictObject({
30
+ deploy: z.literal("blue-green"),
31
+ packages: z.strictObject({ web: text, worker: text }),
32
+ deployer: text,
33
+ secrets: z
34
+ .strictObject({ web: z.array(text), slot: z.array(text) })
35
+ .optional(),
36
+ buildPaths: z.array(sitePath).min(1).optional(),
37
+ drain: z.boolean().optional(),
38
+ ...envBase,
39
+ }),
40
+ z.strictObject({
41
+ deploy: z.literal("in-place"),
42
+ workers: z
43
+ .array(z.strictObject({
44
+ package: text,
45
+ build: z.enum(["vite", "wrangler"]),
46
+ vars: z.record(text, z.string()).optional(),
47
+ buildPath: sitePath.optional(),
48
+ secrets: z.array(text).optional(),
49
+ }))
50
+ .min(1),
51
+ ...envBase,
52
+ }),
53
+ ]))
54
+ .refine((envs) => Object.keys(envs).length > 0, "expected an env"),
55
+ secretsDir: text.refine(isAbsolute, "expected an absolute path"),
56
+ wranglerConfig: text.optional(),
57
+ buildEnv: z.record(text, text).optional(),
58
+ retireBuilds: hook().optional(),
59
+ ongoingWork: hook().optional(),
60
+ })
61
+ .check((ctx) => {
62
+ for (const [env, release] of Object.entries(ctx.value.envs))
63
+ if (release.deploy === "in-place")
64
+ for (const [i, { vars = {} }] of release.workers.entries())
65
+ for (const name of Object.keys(vars))
66
+ if (RESERVED_VARS.includes(name))
67
+ ctx.issues.push({
68
+ code: "custom",
69
+ input: name,
70
+ path: ["envs", env, "workers", i, "vars", name],
71
+ message: "set by deploy",
72
+ });
73
+ });
74
+ /** `app`, validated (`releaseAppSchema`); throws `ExpectedError` naming
75
+ * each problem. */
76
+ export function defineReleaseApp(app) {
77
+ const result = releaseAppSchema.safeParse(app);
78
+ if (!result.success)
79
+ throw new ExpectedError(`ReleaseApp:\n${z.prettifyError(result.error)}`);
80
+ return app;
81
+ }
82
+ /** Site paths a blue/green stage smoke-tests (`BlueGreenEnv.buildPaths`). */
83
+ export const slotBuildPaths = (release) => release.buildPaths ?? [BUILD_JSON_PATH];
84
+ /** Every deploy env of `app`. */
85
+ export const envNames = (app) => Object.keys(app.envs);
86
+ /** `app`'s wrangler config file name, within each package. */
87
+ export const wranglerConfigName = (app) => app.wranglerConfig ?? "wrangler.jsonc";
88
+ /** `env`'s secrets file. */
89
+ export const secretsFile = (app, env) => join(app.secretsDir, `.env.${env}`);
90
+ export function dbUrlVar(env, release) {
91
+ return (release.dbUrlVar ??
92
+ (release.deploy === "blue-green"
93
+ ? `${env.toUpperCase()}_DB_URL`
94
+ : undefined));
95
+ }
96
+ /** The `buildEnv` variables of a build: `given`, the operator's other
97
+ * values removed. */
98
+ export const buildEnvOf = (app, given = []) => ({
99
+ ...Object.fromEntries(Object.keys(app.buildEnv ?? {}).map((name) => [name, undefined])),
100
+ ...Object.fromEntries(given),
101
+ });
@@ -0,0 +1,18 @@
1
+ /** A deployed env's bundle build inputs. */
2
+ export interface ReleaseBundle {
3
+ env: string;
4
+ /** JSON `DeployInfo` the bundle bakes in; a worker reading none
5
+ * (the deployer) leaves it out. */
6
+ deployInfo?: string;
7
+ }
8
+ /** `wrangler` args building the minified worker bundle into `outdir`:
9
+ * for `release`'s env, else the top-level dev config. */
10
+ export declare function workerBundleArgs(outdir: string, release?: ReleaseBundle): string[];
11
+ /** The sources a source map in `mapDir` lists, as absolute paths. */
12
+ export declare function bundleSources(sourceMap: string, mapDir: string): string[];
13
+ /** The dev-only and test support sources a source map in `mapDir`
14
+ * lists, relative to the workspace root. */
15
+ export declare function devTestSources(sourceMap: string, mapDir: string): string[];
16
+ /** Throws when the worker bundle in `outdir` lists dev-only or test
17
+ * support sources in its source map. */
18
+ export declare function requireReleaseBundle(outdir: string): Promise<void>;
@@ -0,0 +1,41 @@
1
+ /** Release checks of a built worker bundle. */
2
+ import { readFile } from "node:fs/promises";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { isDevFile, isTestFile } from "@astralarium/repo-package-files";
5
+ import { ExpectedError } from "@astralarium/repo-telemetry";
6
+ import { ownWorkspaceRoot } from "./workspace.js";
7
+ /** `wrangler` args building the minified worker bundle into `outdir`:
8
+ * for `release`'s env, else the top-level dev config. */
9
+ export function workerBundleArgs(outdir, release) {
10
+ return [
11
+ "deploy",
12
+ "--dry-run",
13
+ "--outdir",
14
+ outdir,
15
+ "--minify",
16
+ ...(release ? ["--env", release.env] : []),
17
+ ...(release?.deployInfo !== undefined
18
+ ? ["--define", `__DEPLOY_INFO__:${release.deployInfo}`]
19
+ : []),
20
+ ];
21
+ }
22
+ /** The sources a source map in `mapDir` lists, as absolute paths. */
23
+ export function bundleSources(sourceMap, mapDir) {
24
+ const { sources, sourceRoot = "" } = JSON.parse(sourceMap);
25
+ return sources.map((source) => resolve(mapDir, sourceRoot, source));
26
+ }
27
+ /** The dev-only and test support sources a source map in `mapDir`
28
+ * lists, relative to the workspace root. */
29
+ export function devTestSources(sourceMap, mapDir) {
30
+ return bundleSources(sourceMap, mapDir)
31
+ .filter((path) => isDevFile(path) || isTestFile(path))
32
+ .map((path) => relative(ownWorkspaceRoot(), path));
33
+ }
34
+ /** Throws when the worker bundle in `outdir` lists dev-only or test
35
+ * support sources in its source map. */
36
+ export async function requireReleaseBundle(outdir) {
37
+ const map = await readFile(join(outdir, "index.js.map"), "utf8");
38
+ const found = devTestSources(map, outdir);
39
+ if (found.length)
40
+ throw new ExpectedError(`The worker bundle carries dev or test code (${found.join(", ")}); its env must define import.meta.env.DEV false in the worker's wrangler config, and shipped code loads dev modules only through import() under it.`);
41
+ }
@@ -0,0 +1,44 @@
1
+ import { type ReleaseApp } from "./app.ts";
2
+ import type { EnvVars } from "./process.ts";
3
+ import { type Gap } from "./provision.ts";
4
+ import { type Target } from "./target.ts";
5
+ /** What holds a command up; its env's other gaps warn. */
6
+ export interface Needs {
7
+ /** Gap areas holding it up. */
8
+ readonly areas: readonly Gap["area"][];
9
+ /** Packages of `target` the command deploys from: config gaps of other
10
+ * packages warn. Default: every package. */
11
+ readonly deploys?: (target: Target) => readonly string[];
12
+ }
13
+ /** Needs of a command deploying builds (`init`, `stage`, `deploy`):
14
+ * every package's config and the deploy state's database. */
15
+ export declare const BUILDS: Needs;
16
+ /** Needs of a command deploying the generated deployer alone
17
+ * (`cutover`, `rollback`): a resolved target and the deploy state's
18
+ * database; package config gaps warn. */
19
+ export declare const DEPLOYER: Needs;
20
+ /** Needs of `settle`: the worker's config (its crons deploy from it)
21
+ * and the deploy state's database. */
22
+ export declare const DEPLOYER_AND_WORKER: Needs;
23
+ /** Needs of a command on the deploy state alone. */
24
+ export declare const STATE: Needs;
25
+ /** Needs of `secrets`: the secrets file and the deploy state's database. */
26
+ export declare const UPLOADS: Needs;
27
+ /** Needs of a command reading alone (`status`): none. */
28
+ export declare const READS: Needs;
29
+ /** Every gap of `env` (`provisioning`). */
30
+ export declare const envGaps: (app: ReleaseApp, env: string, environment?: EnvVars) => Gap[];
31
+ /** True for an env `app` marks bootstrapping. */
32
+ export declare const isBootstrapping: (app: ReleaseApp, env: string) => boolean;
33
+ /** The app's test of its declaration against what the repo commits:
34
+ * throws `ExpectedError` listing each config gap of a required env, each
35
+ * template gap of any env, and each bootstrapping env whose configs hold
36
+ * no gap. Secrets files and the process environment stay unread. */
37
+ export declare function assertReleaseConfig(app: ReleaseApp): void;
38
+ /** `env`'s target for a command held up by gaps in `needs`: logs every
39
+ * other gap as a warning. Throws `unprovisioned` while a gap in `needs`
40
+ * holds it up, or a config gap leaves the target undeclared. */
41
+ export declare function provisionedTarget(app: ReleaseApp, invocation: string, env: string, needs: Needs, environment?: EnvVars): Target;
42
+ /** `check`: prints every gap of `envs`; returns the exit status, 1 on a
43
+ * gap of an env other than a bootstrapping one. */
44
+ export declare function runCheck(app: ReleaseApp, envs: readonly string[], environment?: EnvVars): number;
@@ -0,0 +1,128 @@
1
+ /** Each env's provisioning gaps, read from its wrangler configs, secrets
2
+ * files and the process environment: `deploy-release check`, and the
3
+ * gate every other command passes. */
4
+ import { ExpectedError, log } from "@astralarium/repo-telemetry";
5
+ import { envNames } from "./app.js";
6
+ import { databaseGap, secretsGaps, templateGaps, unprovisioned, } from "./provision.js";
7
+ import { resolveTarget, secretsByScript } from "./target.js";
8
+ /** Needs of a command deploying builds (`init`, `stage`, `deploy`):
9
+ * every package's config and the deploy state's database. */
10
+ export const BUILDS = { areas: ["config", "database"] };
11
+ /** Needs of a command deploying the generated deployer alone
12
+ * (`cutover`, `rollback`): a resolved target and the deploy state's
13
+ * database; package config gaps warn. */
14
+ export const DEPLOYER = {
15
+ areas: ["config", "database"],
16
+ deploys: () => [],
17
+ };
18
+ /** Needs of `settle`: the worker's config (its crons deploy from it)
19
+ * and the deploy state's database. */
20
+ export const DEPLOYER_AND_WORKER = {
21
+ areas: ["config", "database"],
22
+ deploys: (target) => target.kind === "blue-green" ? [target.packages.worker] : [],
23
+ };
24
+ /** Needs of a command on the deploy state alone. */
25
+ export const STATE = { areas: ["database"] };
26
+ /** Needs of `secrets`: the secrets file and the deploy state's database. */
27
+ export const UPLOADS = { areas: ["secrets", "database"] };
28
+ /** Needs of a command reading alone (`status`): none. */
29
+ export const READS = { areas: [] };
30
+ /** Every secret `target`'s scripts hold, once each. */
31
+ const secretKeys = (target) => [
32
+ ...new Set(secretsByScript(target).flatMap(([, keys]) => keys)),
33
+ ];
34
+ /** `env`'s target (`resolveTarget`) and every gap: its wrangler configs',
35
+ * then, once those declare its target, its secrets template's, its
36
+ * secrets file's and its database variable's in `environment`. */
37
+ function provisioning(app, env, environment = process.env) {
38
+ const { target, gaps } = resolveTarget(app, env);
39
+ if (!target)
40
+ return { gaps };
41
+ const keys = secretKeys(target);
42
+ gaps.push(...templateGaps(target.secretsFile, keys), ...secretsGaps(target.secretsFile, keys));
43
+ if (target.dbUrlVar && !environment[target.dbUrlVar])
44
+ gaps.push(databaseGap(env, target.dbUrlVar));
45
+ return { target, gaps };
46
+ }
47
+ /** Every gap of `env` (`provisioning`). */
48
+ export const envGaps = (app, env, environment = process.env) => provisioning(app, env, environment).gaps;
49
+ /** True for an env `app` marks bootstrapping. */
50
+ export const isBootstrapping = (app, env) => app.envs[env]?.bootstrapping ?? false;
51
+ /** The app's test of its declaration against what the repo commits:
52
+ * throws `ExpectedError` listing each config gap of a required env, each
53
+ * template gap of any env, and each bootstrapping env whose configs hold
54
+ * no gap. Secrets files and the process environment stay unread. */
55
+ export function assertReleaseConfig(app) {
56
+ const problems = [];
57
+ for (const env of envNames(app)) {
58
+ const bootstrapping = isBootstrapping(app, env);
59
+ const { target, gaps } = resolveTarget(app, env);
60
+ if (bootstrapping && !gaps.length)
61
+ problems.push(`${env}: provisioned; drop ReleaseApp.envs.${env}.bootstrapping`);
62
+ const reported = [
63
+ ...(bootstrapping ? [] : gaps),
64
+ ...(target ? templateGaps(target.secretsFile, secretKeys(target)) : []),
65
+ ];
66
+ for (const g of reported)
67
+ problems.push(`${env}: ${g.where} ${g.problem}`, ` next: ${g.step}`);
68
+ }
69
+ if (problems.length)
70
+ throw new ExpectedError(["ReleaseApp disagrees with the repo:", ...problems].join("\n"));
71
+ }
72
+ /** True when `gap` holds up a command with `needs` on `target`. */
73
+ function holds(gap, needs, target) {
74
+ if (!needs.areas.includes(gap.area))
75
+ return false;
76
+ const deployed = needs.deploys?.(target);
77
+ return (gap.area !== "config" ||
78
+ !gap.package ||
79
+ !deployed ||
80
+ deployed.includes(gap.package));
81
+ }
82
+ /** `env`'s target for a command held up by gaps in `needs`: logs every
83
+ * other gap as a warning. Throws `unprovisioned` while a gap in `needs`
84
+ * holds it up, or a config gap leaves the target undeclared. */
85
+ export function provisionedTarget(app, invocation, env, needs, environment = process.env) {
86
+ const { target, gaps } = provisioning(app, env, environment);
87
+ const holding = gaps.filter((g) => target ? holds(g, needs, target) : g.area === "config");
88
+ for (const { where, problem } of gaps.filter((g) => !holding.includes(g)))
89
+ log.warn("deploy_release.gap", "{env}: {where} {problem}", {
90
+ env,
91
+ where,
92
+ problem,
93
+ });
94
+ if (!target || holding.length)
95
+ throw unprovisioned(invocation, env, holding);
96
+ if (gaps.length)
97
+ log.warn("deploy_release.gap_steps", "{check} lists each next step", { check: `${invocation} check --env ${env}` });
98
+ return target;
99
+ }
100
+ /** `env`'s gaps as `check` prints them: a heading, marking a
101
+ * bootstrapping env, then each gap over its next step. */
102
+ function formatGaps(env, gaps, bootstrapping = false) {
103
+ if (!gaps.length)
104
+ return [
105
+ bootstrapping
106
+ ? `${env}: provisioned; drop ReleaseApp.envs.${env}.bootstrapping`
107
+ : `${env}: provisioned`,
108
+ ];
109
+ const count = `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"}`;
110
+ return [
111
+ bootstrapping ? `${env}: bootstrapping, ${count}` : `${env}: ${count}`,
112
+ ...gaps.flatMap((g) => [
113
+ ` ${g.where} ${g.problem}`,
114
+ ` next: ${g.step}`,
115
+ ]),
116
+ ];
117
+ }
118
+ /** `check`: prints every gap of `envs`; returns the exit status, 1 on a
119
+ * gap of an env other than a bootstrapping one. */
120
+ export function runCheck(app, envs, environment = process.env) {
121
+ const reports = envs.map((env) => ({
122
+ env,
123
+ gaps: envGaps(app, env, environment),
124
+ bootstrapping: isBootstrapping(app, env),
125
+ }));
126
+ process.stdout.write(`${reports.flatMap((r) => formatGaps(r.env, r.gaps, r.bootstrapping)).join("\n")}\n`);
127
+ return reports.some((r) => r.gaps.length && !r.bootstrapping) ? 1 : 0;
128
+ }
@@ -0,0 +1,70 @@
1
+ /** Cloudflare as `deploy-release` reaches it: reads and simple writes
2
+ * through the API under wrangler's login; bundling and config-driven
3
+ * writes through wrangler. */
4
+ import { type DeploymentRecord, type DeploymentVersion, type WorkerVersion } from "@astralarium/repo-deploy-info";
5
+ import { type RunOptions } from "./process.ts";
6
+ /** A workflow instance status. */
7
+ export type InstanceStatus = "queued" | "running" | "paused" | "errored" | "terminated" | "complete" | "waiting" | "waitingForPause";
8
+ /** A workflow instance as the API reports it. */
9
+ export interface InstanceRecord {
10
+ status: InstanceStatus;
11
+ /** What the run returned, once complete. */
12
+ output?: unknown;
13
+ }
14
+ /** Statuses of a cutover past its sleep: it finishes or compensates. */
15
+ export declare const RUNNING: readonly InstanceStatus[];
16
+ /** Statuses of a cutover waiting for its time, or retrying a step. */
17
+ export declare const WAITING: readonly InstanceStatus[];
18
+ /** Unix seconds, for workflow instance ids. */
19
+ export declare const epoch: () => number;
20
+ /** Version id at `percentage` in `deployment`. */
21
+ export declare const idAt: (deployment: DeploymentRecord | undefined, percentage: number) => string | undefined;
22
+ /** How `Cloudflare.wrangler` runs wrangler. */
23
+ export interface WranglerOptions extends RunOptions {
24
+ /** Reads the config a Vite build redirected wrangler to
25
+ * (`.wrangler/deploy/config.json`) over the package's own. */
26
+ redirected?: boolean;
27
+ /** Config file path over the package's own. */
28
+ config?: string;
29
+ }
30
+ /** The account wrangler is logged into. */
31
+ export declare class Cloudflare {
32
+ #private;
33
+ constructor(signal: AbortSignal, pkg: string, config: string);
34
+ /** Runs wrangler in workspace package `pkg`'s directory on its config
35
+ * file (`--config`): an entry path in `args` would move wrangler's
36
+ * lookup to the entry's directory. */
37
+ wrangler(pkg: string, args: readonly string[], { redirected, config, ...options }?: WranglerOptions): Promise<string>;
38
+ /** The id of the one account wrangler reaches. */
39
+ accountId(): Promise<string>;
40
+ /** Wrangler's OAuth token, reused `TOKEN_REUSE_MS`; a failed read is
41
+ * retried by the next call. */
42
+ token(): Promise<string>;
43
+ /** One API call, as `CfApi`. */
44
+ api<T>(method: "GET" | "PUT" | "POST" | "PATCH", path: string, body?: unknown): Promise<T>;
45
+ /** `script`'s deployments, newest first; none when it is absent. */
46
+ deployments(script: string): Promise<DeploymentRecord[]>;
47
+ /** Version of `script` at `percentage` in its latest deployment. */
48
+ versionAt(script: string, percentage: number): Promise<string | undefined>;
49
+ /** Version `id` of `script`. */
50
+ version(script: string, id: string): Promise<WorkerVersion>;
51
+ /** `script`'s recent deployable versions; none when it is absent. */
52
+ versions(script: string): Promise<WorkerVersion[]>;
53
+ /** Names of `script`'s secrets; none when it is absent. */
54
+ secretNames(script: string): Promise<string[]>;
55
+ /** `script`'s cron expressions. */
56
+ crons(script: string): Promise<string[]>;
57
+ /** Clears `script`'s crons. */
58
+ clearCrons(script: string): Promise<void>;
59
+ /** Deploys `versions` of `script`. */
60
+ deploy(script: string, versions: readonly DeploymentVersion[], message: string): Promise<void>;
61
+ /** Ids of workflow `flow`'s instances in `status`; none while the
62
+ * workflow is absent. */
63
+ instances(flow: string, status: InstanceStatus): Promise<string[]>;
64
+ /** Starts workflow `flow` as instance `id` with `params`. */
65
+ trigger(flow: string, id: string, params: unknown): Promise<void>;
66
+ /** Instance `id` of workflow `flow`. */
67
+ instance(flow: string, id: string): Promise<InstanceRecord>;
68
+ /** Terminates instance `id` of workflow `flow`. */
69
+ terminate(flow: string, id: string): Promise<void>;
70
+ }