@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
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
|
-
#
|
|
1
|
+
# `@astralarium/repo-deploy-release`
|
|
2
2
|
|
|
3
|
-
|
|
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,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,143 @@
|
|
|
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
|
+
/** Retries the app's queue consumers take (`@astralarium/repo-postgres/queue`
|
|
117
|
+
* `consumeClaimed`'s `maxRetries`); `testRelease` checks each consumer's
|
|
118
|
+
* `max_retries` against it; unset, it checks the targets declare
|
|
119
|
+
* no consumers. */
|
|
120
|
+
queueMaxRetries?: number;
|
|
121
|
+
}
|
|
122
|
+
/** `app`, validated (`releaseAppSchema`); throws `ExpectedError` naming
|
|
123
|
+
* each problem. */
|
|
124
|
+
export declare function defineReleaseApp(app: ReleaseApp): ReleaseApp;
|
|
125
|
+
/** Site paths a blue/green stage smoke-tests (`BlueGreenEnv.buildPaths`). */
|
|
126
|
+
export declare const slotBuildPaths: (release: BlueGreenEnv) => readonly string[];
|
|
127
|
+
/** Every deploy env of `app`. */
|
|
128
|
+
export declare const envNames: (app: ReleaseApp) => string[];
|
|
129
|
+
/** `app`'s wrangler config file name, within each package. */
|
|
130
|
+
export declare const wranglerConfigName: (app: ReleaseApp) => string;
|
|
131
|
+
/** `env`'s secrets file. */
|
|
132
|
+
export declare const secretsFile: (app: ReleaseApp, env: string) => string;
|
|
133
|
+
/** Variable holding `env`'s database URL: its `dbUrlVar`, else
|
|
134
|
+
* `<ENV>_DB_URL` for a blue/green env; undefined for an in-place env
|
|
135
|
+
* without one. */
|
|
136
|
+
export declare function dbUrlVar(env: string, release: BlueGreenEnv): string;
|
|
137
|
+
export declare function dbUrlVar(env: string, release: EnvRelease): string | undefined;
|
|
138
|
+
/** `--build-env` values: `ReleaseApp.buildEnv` variables as NAME, VALUE. */
|
|
139
|
+
export type BuildEnv = readonly (readonly [name: string, value: string])[];
|
|
140
|
+
/** The `buildEnv` variables of a build: `given`, the operator's other
|
|
141
|
+
* values removed. */
|
|
142
|
+
export declare const buildEnvOf: (app: ReleaseApp, given?: BuildEnv) => EnvVars;
|
|
143
|
+
export {};
|
package/dist/src/app.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
queueMaxRetries: z.number().int().nonnegative().optional(),
|
|
61
|
+
})
|
|
62
|
+
.check((ctx) => {
|
|
63
|
+
for (const [env, release] of Object.entries(ctx.value.envs))
|
|
64
|
+
if (release.deploy === "in-place")
|
|
65
|
+
for (const [i, { vars = {} }] of release.workers.entries())
|
|
66
|
+
for (const name of Object.keys(vars))
|
|
67
|
+
if (RESERVED_VARS.includes(name))
|
|
68
|
+
ctx.issues.push({
|
|
69
|
+
code: "custom",
|
|
70
|
+
input: name,
|
|
71
|
+
path: ["envs", env, "workers", i, "vars", name],
|
|
72
|
+
message: "set by deploy",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
/** `app`, validated (`releaseAppSchema`); throws `ExpectedError` naming
|
|
76
|
+
* each problem. */
|
|
77
|
+
export function defineReleaseApp(app) {
|
|
78
|
+
const result = releaseAppSchema.safeParse(app);
|
|
79
|
+
if (!result.success)
|
|
80
|
+
throw new ExpectedError(`ReleaseApp:\n${z.prettifyError(result.error)}`);
|
|
81
|
+
return app;
|
|
82
|
+
}
|
|
83
|
+
/** Site paths a blue/green stage smoke-tests (`BlueGreenEnv.buildPaths`). */
|
|
84
|
+
export const slotBuildPaths = (release) => release.buildPaths ?? [BUILD_JSON_PATH];
|
|
85
|
+
/** Every deploy env of `app`. */
|
|
86
|
+
export const envNames = (app) => Object.keys(app.envs);
|
|
87
|
+
/** `app`'s wrangler config file name, within each package. */
|
|
88
|
+
export const wranglerConfigName = (app) => app.wranglerConfig ?? "wrangler.jsonc";
|
|
89
|
+
/** `env`'s secrets file. */
|
|
90
|
+
export const secretsFile = (app, env) => join(app.secretsDir, `.env.${env}`);
|
|
91
|
+
export function dbUrlVar(env, release) {
|
|
92
|
+
return (release.dbUrlVar ??
|
|
93
|
+
(release.deploy === "blue-green"
|
|
94
|
+
? `${env.toUpperCase()}_DB_URL`
|
|
95
|
+
: undefined));
|
|
96
|
+
}
|
|
97
|
+
/** The `buildEnv` variables of a build: `given`, the operator's other
|
|
98
|
+
* values removed. */
|
|
99
|
+
export const buildEnvOf = (app, given = []) => ({
|
|
100
|
+
...Object.fromEntries(Object.keys(app.buildEnv ?? {}).map((name) => [name, undefined])),
|
|
101
|
+
...Object.fromEntries(given),
|
|
102
|
+
});
|
|
@@ -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
|
+
}
|