@astralarium/repo-deploy-info 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.
- package/LICENSE +21 -0
- package/README.md +12 -2
- package/dist/bin/siblings-pull.d.ts +2 -0
- package/dist/bin/siblings-pull.js +15 -0
- package/dist/src/build-fetch.d.ts +10 -0
- package/dist/src/build-fetch.js +32 -0
- package/dist/src/checkout/index.d.ts +18 -0
- package/dist/src/checkout/index.js +17 -0
- package/dist/src/checkout/vite.d.ts +5 -0
- package/dist/src/checkout/vite.js +25 -0
- package/dist/src/cloudflare.d.ts +49 -0
- package/dist/src/cloudflare.js +62 -0
- package/dist/src/control.d.ts +45 -0
- package/dist/src/control.js +15 -0
- package/dist/src/deployer.d.ts +89 -0
- package/dist/src/deployer.js +39 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/info.d.ts +26 -0
- package/dist/src/info.js +42 -0
- package/dist/src/live-build.d.ts +16 -0
- package/dist/src/live-build.js +70 -0
- package/dist/src/serve-json.d.ts +5 -0
- package/dist/src/serve-json.js +9 -0
- package/dist/src/server/app-module.server.d.ts +21 -0
- package/dist/src/server/app-module.server.js +66 -0
- package/dist/src/server/checkouts.server.d.ts +46 -0
- package/dist/src/server/checkouts.server.js +38 -0
- package/dist/src/server/git.server.d.ts +6 -0
- package/dist/src/server/git.server.js +17 -0
- package/dist/src/server/index.d.ts +6 -0
- package/dist/src/server/index.js +6 -0
- package/dist/src/server/mint.server.d.ts +10 -0
- package/dist/src/server/mint.server.js +28 -0
- package/dist/src/server/siblings.server.d.ts +31 -0
- package/dist/src/server/siblings.server.js +63 -0
- package/dist/src/slots.d.ts +26 -0
- package/dist/src/slots.js +35 -0
- package/dist/src/stale-build.d.ts +34 -0
- package/dist/src/stale-build.js +81 -0
- package/dist/src/start.d.ts +7 -0
- package/dist/src/start.js +26 -0
- package/dist/src/version-check.d.ts +38 -0
- package/dist/src/version-check.js +35 -0
- package/dist/src/vite.d.ts +26 -0
- package/dist/src/vite.js +51 -0
- package/dist/testkit/index.d.ts +22 -0
- package/dist/testkit/index.js +42 -0
- package/package.json +68 -3
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,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@astralarium/repo-deploy-info`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build identity and metadata.
|
|
4
|
+
Detects stale clients and routes blue/green slots.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// app module, loaded by bins as `--app <module>`
|
|
8
|
+
export const checkouts = async () =>
|
|
9
|
+
(await import("./src/checkouts.server.ts")).CHECKOUTS; // defineCheckouts
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`./vite` bakes the build's `DeployInfo` and serves `/build.json`.
|
|
13
|
+
A server-function call from another build answers 409.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** `siblings-pull --app <module>`: fast-forwards the app's upstream-fed
|
|
3
|
+
* siblings (`Checkouts` `pull`); a dirty checkout warns. */
|
|
4
|
+
import { log } from "@astralarium/repo-telemetry";
|
|
5
|
+
import { appArgs, loadCheckouts } from "../src/server/app-module.server.js";
|
|
6
|
+
import { pullSiblings } from "../src/server/siblings.server.js";
|
|
7
|
+
const USAGE = "Usage: siblings-pull --app <module>";
|
|
8
|
+
try {
|
|
9
|
+
const { app } = appArgs(process.argv.slice(2), USAGE, { positionals: 0 });
|
|
10
|
+
pullSiblings(await loadCheckouts(app));
|
|
11
|
+
}
|
|
12
|
+
catch (exception) {
|
|
13
|
+
log.error("siblings_pull.failed", "siblings-pull failed", { exception });
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The answering worker runs another build than this bundle. */
|
|
2
|
+
export declare class VersionMismatchError extends Error {
|
|
3
|
+
constructor();
|
|
4
|
+
}
|
|
5
|
+
/** `fetch` naming this bundle's build. */
|
|
6
|
+
export type BuildHeaderFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
7
|
+
/** `fetch` naming this bundle's build in `header`; a 409 answer whose
|
|
8
|
+
* JSON `error` is `VERSION_MISMATCH` raises stale and throws
|
|
9
|
+
* `VersionMismatchError`. Other answers pass through. */
|
|
10
|
+
export declare function buildHeaderFetch(header: string): BuildHeaderFetch;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Plain `fetch` of a versioned app: requests name this bundle's build. */
|
|
2
|
+
import { deployInfo } from "./info.js";
|
|
3
|
+
import { raiseStale } from "./stale-build.js";
|
|
4
|
+
import { VERSION_MISMATCH } from "./version-check.js";
|
|
5
|
+
/** The answering worker runs another build than this bundle. */
|
|
6
|
+
export class VersionMismatchError extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super(VERSION_MISMATCH);
|
|
9
|
+
this.name = "VersionMismatchError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** `fetch` naming this bundle's build in `header`; a 409 answer whose
|
|
13
|
+
* JSON `error` is `VERSION_MISMATCH` raises stale and throws
|
|
14
|
+
* `VersionMismatchError`. Other answers pass through. */
|
|
15
|
+
export function buildHeaderFetch(header) {
|
|
16
|
+
return async (input, init) => {
|
|
17
|
+
const headers = new Headers(init?.headers);
|
|
18
|
+
headers.set(header, deployInfo.build);
|
|
19
|
+
const res = await fetch(input, { ...init, headers });
|
|
20
|
+
if (res.status === 409) {
|
|
21
|
+
const body = (await res
|
|
22
|
+
.clone()
|
|
23
|
+
.json()
|
|
24
|
+
.catch(() => null));
|
|
25
|
+
if (body?.error === VERSION_MISMATCH) {
|
|
26
|
+
raiseStale();
|
|
27
|
+
throw new VersionMismatchError();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return res;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Drift between a recorded build and the dev server's checkouts. */
|
|
2
|
+
import type { DeployComponent } from "../info.ts";
|
|
3
|
+
/** Path the dev server serves the checkouts' current commits at. */
|
|
4
|
+
export declare const CHECKOUT_JSON_PATH = "/checkout.json";
|
|
5
|
+
/** A recorded component the checkout holds at another rev, or lacks. */
|
|
6
|
+
interface ComponentDrift {
|
|
7
|
+
name: string;
|
|
8
|
+
/** Rev the build recorded. */
|
|
9
|
+
recorded: string;
|
|
10
|
+
/** Rev the checkout holds; undefined when it lacks the component. */
|
|
11
|
+
checkout: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
/** Components of `recorded` that `checkout` holds at another rev or
|
|
14
|
+
* lacks. */
|
|
15
|
+
export declare function checkDrift(recorded: Record<string, DeployComponent>, checkout: Record<string, DeployComponent>): ComponentDrift[];
|
|
16
|
+
/** The dev server's checkouts' current commits by component name. */
|
|
17
|
+
export declare function fetchCheckout(): Promise<Record<string, DeployComponent>>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Path the dev server serves the checkouts' current commits at. */
|
|
2
|
+
export const CHECKOUT_JSON_PATH = "/checkout.json";
|
|
3
|
+
/** Components of `recorded` that `checkout` holds at another rev or
|
|
4
|
+
* lacks. */
|
|
5
|
+
export function checkDrift(recorded, checkout) {
|
|
6
|
+
return Object.entries(recorded).flatMap(([name, { rev }]) => {
|
|
7
|
+
const held = checkout[name]?.rev;
|
|
8
|
+
return held === rev ? [] : [{ name, recorded: rev, checkout: held }];
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
/** The dev server's checkouts' current commits by component name. */
|
|
12
|
+
export async function fetchCheckout() {
|
|
13
|
+
const res = await fetch(CHECKOUT_JSON_PATH);
|
|
14
|
+
if (!res.ok)
|
|
15
|
+
throw new Error(`HTTP ${res.status}`);
|
|
16
|
+
return (await res.json());
|
|
17
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Vite plugin serving the dev server's checkouts' current commits. */
|
|
2
|
+
import type { Plugin } from "vite";
|
|
3
|
+
/** Serves `dirs`' current commits (`checkoutComponents`) at
|
|
4
|
+
* `CHECKOUT_JSON_PATH` on dev servers, read per request. */
|
|
5
|
+
export declare function checkoutInfo(dirs: Record<string, string>): Plugin;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { serveJson } from "../serve-json.js";
|
|
2
|
+
import { componentOf } from "../server/mint.server.js";
|
|
3
|
+
import { CHECKOUT_JSON_PATH } from "./index.js";
|
|
4
|
+
/** Each checkout's current commit by component name, per `dirs`
|
|
5
|
+
* (`componentDirs`); checkouts git can't read are left out. */
|
|
6
|
+
function checkoutComponents(dirs) {
|
|
7
|
+
return Object.fromEntries(Object.entries(dirs).flatMap(([name, dir]) => {
|
|
8
|
+
try {
|
|
9
|
+
return [[name, componentOf(dir)]];
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
/** Serves `dirs`' current commits (`checkoutComponents`) at
|
|
17
|
+
* `CHECKOUT_JSON_PATH` on dev servers, read per request. */
|
|
18
|
+
export function checkoutInfo(dirs) {
|
|
19
|
+
return {
|
|
20
|
+
name: "checkout-info",
|
|
21
|
+
configureServer(server) {
|
|
22
|
+
serveJson(server, CHECKOUT_JSON_PATH, () => JSON.stringify(checkoutComponents(dirs)));
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** One Cloudflare API call under the account: `path` is account-relative.
|
|
2
|
+
* Resolves to the response's `result`. */
|
|
3
|
+
export type CfApi = <T>(method: "GET" | "PUT" | "POST" | "PATCH", path: string, body?: unknown) => Promise<T>;
|
|
4
|
+
/** A failed `CfApi` call. */
|
|
5
|
+
export declare class CfApiError extends Error {
|
|
6
|
+
/** HTTP status of the response. */
|
|
7
|
+
readonly status: number;
|
|
8
|
+
constructor(message: string, status: number);
|
|
9
|
+
}
|
|
10
|
+
/** `CfApi` over `fetchFn` with a bearer `token`; throws `CfApiError` on an
|
|
11
|
+
* HTTP or API failure. */
|
|
12
|
+
export declare function cfApi(token: string, accountId: string, fetchFn?: typeof fetch): CfApi;
|
|
13
|
+
/** A deployment's version split. */
|
|
14
|
+
export interface DeploymentVersion {
|
|
15
|
+
version_id: string;
|
|
16
|
+
percentage: number;
|
|
17
|
+
}
|
|
18
|
+
/** One deployment of a script: its version split. */
|
|
19
|
+
export interface DeploymentRecord {
|
|
20
|
+
created_on: string;
|
|
21
|
+
versions: DeploymentVersion[];
|
|
22
|
+
}
|
|
23
|
+
/** One uploaded version of a script. */
|
|
24
|
+
export interface WorkerVersion {
|
|
25
|
+
id: string;
|
|
26
|
+
number: number;
|
|
27
|
+
resources?: {
|
|
28
|
+
bindings?: {
|
|
29
|
+
type: string;
|
|
30
|
+
name: string;
|
|
31
|
+
text?: string;
|
|
32
|
+
}[];
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Plain-text var `name` of `version`; undefined when unset. */
|
|
36
|
+
export declare const varOf: (version: WorkerVersion, name: string) => string | undefined;
|
|
37
|
+
/** Build of `version` (its `BUILD_VAR`, which secret changes keep);
|
|
38
|
+
* undefined when unset. */
|
|
39
|
+
export declare const buildOf: (version: WorkerVersion) => string | undefined;
|
|
40
|
+
/** `script`'s deployments, newest first. */
|
|
41
|
+
export declare function scriptDeployments(api: CfApi, script: string): Promise<DeploymentRecord[]>;
|
|
42
|
+
/** Version `id` of `script`. */
|
|
43
|
+
export declare const scriptVersion: (api: CfApi, script: string, id: string) => Promise<WorkerVersion>;
|
|
44
|
+
/** `script`'s cron expressions. */
|
|
45
|
+
export declare function scriptCrons(api: CfApi, script: string): Promise<string[]>;
|
|
46
|
+
/** Sets `script`'s crons to `crons`, replacing any. */
|
|
47
|
+
export declare function putScriptCrons(api: CfApi, script: string, crons: readonly string[]): Promise<void>;
|
|
48
|
+
/** Deploys `versions` of `script`, split by percentage. */
|
|
49
|
+
export declare function deployScript(api: CfApi, script: string, versions: readonly DeploymentVersion[], message: string): Promise<void>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** The Cloudflare API under one account: script deployments, versions
|
|
2
|
+
* and crons. */
|
|
3
|
+
import { BUILD_VAR } from "./deployer.js";
|
|
4
|
+
/** A failed `CfApi` call. */
|
|
5
|
+
export class CfApiError extends Error {
|
|
6
|
+
/** HTTP status of the response. */
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "CfApiError";
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** `CfApi` over `fetchFn` with a bearer `token`; throws `CfApiError` on an
|
|
15
|
+
* HTTP or API failure. */
|
|
16
|
+
export function cfApi(token, accountId, fetchFn = fetch) {
|
|
17
|
+
return async (method, path, body) => {
|
|
18
|
+
const res = await fetchFn(`https://api.cloudflare.com/client/v4/accounts/${accountId}/${path}`, {
|
|
19
|
+
method,
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
},
|
|
24
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
const json = (await res.json().catch(() => null));
|
|
27
|
+
if (!res.ok || !json?.success)
|
|
28
|
+
throw new CfApiError(`${method} ${path}: ${res.status} ${json?.errors?.map((e) => e.message).join("; ") ?? ""}`, res.status);
|
|
29
|
+
return json.result;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Plain-text var `name` of `version`; undefined when unset. */
|
|
33
|
+
export const varOf = (version, name) => version.resources?.bindings?.find((b) => b.type === "plain_text" && b.name === name)?.text;
|
|
34
|
+
/** Build of `version` (its `BUILD_VAR`, which secret changes keep);
|
|
35
|
+
* undefined when unset. */
|
|
36
|
+
export const buildOf = (version) => varOf(version, BUILD_VAR);
|
|
37
|
+
/** `script`'s deployments, newest first. */
|
|
38
|
+
export async function scriptDeployments(api, script) {
|
|
39
|
+
const { deployments } = await api("GET", `workers/scripts/${script}/deployments`);
|
|
40
|
+
return deployments;
|
|
41
|
+
}
|
|
42
|
+
/** Version `id` of `script`. */
|
|
43
|
+
export const scriptVersion = (api, script, id) => api("GET", `workers/scripts/${script}/versions/${id}`);
|
|
44
|
+
/** `script`'s cron expressions. */
|
|
45
|
+
export async function scriptCrons(api, script) {
|
|
46
|
+
const { schedules } = await api("GET", `workers/scripts/${script}/schedules`);
|
|
47
|
+
return schedules.map((s) => s.cron);
|
|
48
|
+
}
|
|
49
|
+
/** Sets `script`'s crons to `crons`, replacing any. */
|
|
50
|
+
export async function putScriptCrons(api, script, crons) {
|
|
51
|
+
await api("PUT", `workers/scripts/${script}/schedules`, crons.map((cron) => ({ cron })));
|
|
52
|
+
}
|
|
53
|
+
/** Body of a deployment of `versions`. */
|
|
54
|
+
const deploymentBody = (versions, message) => ({
|
|
55
|
+
strategy: "percentage",
|
|
56
|
+
versions,
|
|
57
|
+
annotations: { "workers/message": message },
|
|
58
|
+
});
|
|
59
|
+
/** Deploys `versions` of `script`, split by percentage. */
|
|
60
|
+
export async function deployScript(api, script, versions, message) {
|
|
61
|
+
await api("POST", `workers/scripts/${script}/deployments`, deploymentBody(versions, message));
|
|
62
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The ops contract between the deployer and a slotted worker. */
|
|
2
|
+
/** A scheduled update reaches clients this long before it takes over, on
|
|
3
|
+
* the database clock. */
|
|
4
|
+
export declare const UPDATE_NOTICE_LEAD_MS: number;
|
|
5
|
+
/** RPC a slotted worker offers the deployer. `run` names the cutover run
|
|
6
|
+
* owning the scheduled update (one at a time); times are ms epochs. */
|
|
7
|
+
export interface OpsControlRpc {
|
|
8
|
+
/** Schedules `run`'s update at `updateAt`, closing each queue its lead
|
|
9
|
+
* ahead and replacing another run's pending update. Refused while
|
|
10
|
+
* another run's update is due; resolves whether it landed. */
|
|
11
|
+
close(run: string, updateAt: number): Promise<boolean>;
|
|
12
|
+
/** Withdraws the scheduled update and reopens its queues, fenced to
|
|
13
|
+
* `run` owning it or none (null: unconditional). Resolves whether the
|
|
14
|
+
* fence held. */
|
|
15
|
+
reopen(run: string | null): Promise<boolean>;
|
|
16
|
+
/** True while `run` owns the scheduled update. */
|
|
17
|
+
scheduledFor(run: string): Promise<boolean>;
|
|
18
|
+
/** True while `run` owns the scheduled update and its notice is out
|
|
19
|
+
* (`UPDATE_NOTICE_LEAD_MS` ahead, database clock). */
|
|
20
|
+
noticed(run: string): Promise<boolean>;
|
|
21
|
+
/** True while `run` owns the scheduled update and the database clock
|
|
22
|
+
* has reached it. */
|
|
23
|
+
due(run: string): Promise<boolean>;
|
|
24
|
+
/** The build this slot runs. */
|
|
25
|
+
build(): Promise<string>;
|
|
26
|
+
/** Names `build` live and reopens as `reopen(run)` in one transaction;
|
|
27
|
+
* the build lands either way. Resolves whether the fence held; rejects
|
|
28
|
+
* with `ForeignBuildError` for another slot's build. */
|
|
29
|
+
release(build: string, run: string | null): Promise<boolean>;
|
|
30
|
+
/** Pushes the deploy state to every user present on this slot's build;
|
|
31
|
+
* resolves to the count pushed. */
|
|
32
|
+
publish(): Promise<number>;
|
|
33
|
+
/** Ends the ongoing work of this slot's build (the cutover's drain step;
|
|
34
|
+
* `--force` on `stage`/`deploy`); resolves to the count ended. A slot whose
|
|
35
|
+
* builds hold no ongoing work leaves it out (`BlueGreenEnv.drain` unset). */
|
|
36
|
+
drain?(): Promise<number>;
|
|
37
|
+
}
|
|
38
|
+
/** `release` of a build other than the slot's. RPC carries an error's
|
|
39
|
+
* `name`; `isForeignBuild` matches on it. */
|
|
40
|
+
export declare class ForeignBuildError extends Error {
|
|
41
|
+
static readonly NAME = "ForeignBuildError";
|
|
42
|
+
constructor(slotBuild: string, build: string);
|
|
43
|
+
}
|
|
44
|
+
/** True for a `ForeignBuildError`, an RPC-carried one included. */
|
|
45
|
+
export declare const isForeignBuild: (e: unknown) => e is Error;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The ops contract between the deployer and a slotted worker. */
|
|
2
|
+
/** A scheduled update reaches clients this long before it takes over, on
|
|
3
|
+
* the database clock. */
|
|
4
|
+
export const UPDATE_NOTICE_LEAD_MS = 4 * 60 * 60_000;
|
|
5
|
+
/** `release` of a build other than the slot's. RPC carries an error's
|
|
6
|
+
* `name`; `isForeignBuild` matches on it. */
|
|
7
|
+
export class ForeignBuildError extends Error {
|
|
8
|
+
static NAME = "ForeignBuildError";
|
|
9
|
+
constructor(slotBuild, build) {
|
|
10
|
+
super(`release: slot runs ${slotBuild}, not ${build}`);
|
|
11
|
+
this.name = ForeignBuildError.NAME;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** True for a `ForeignBuildError`, an RPC-carried one included. */
|
|
15
|
+
export const isForeignBuild = (e) => e instanceof Error && e.name === ForeignBuildError.NAME;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** The contract between `deploy-release` and the deployer's
|
|
2
|
+
* workflows: slots, the scripts acted on, and each workflow's params. */
|
|
3
|
+
/** A slotted-worker slot. */
|
|
4
|
+
export type Slot = "blue" | "green";
|
|
5
|
+
/** Every slot. */
|
|
6
|
+
export declare const SLOTS: readonly Slot[];
|
|
7
|
+
/** True for a slot name. */
|
|
8
|
+
export declare const isSlot: (value: unknown) => value is Slot;
|
|
9
|
+
/** The slot other than `slot`. */
|
|
10
|
+
export declare const otherSlot: (slot: Slot) => Slot;
|
|
11
|
+
/** Var naming a web version's slot; `deploy-release` sets it at upload,
|
|
12
|
+
* the web worker routes slot requests by it. */
|
|
13
|
+
export declare const SLOT_VAR = "SLOT";
|
|
14
|
+
/** Var naming a version's build; `deploy-release` sets it at upload. */
|
|
15
|
+
export declare const BUILD_VAR = "BUILD";
|
|
16
|
+
/** The deployer's var holding its `Deployment`. */
|
|
17
|
+
export declare const DEPLOYMENT_VAR = "DEPLOYMENT";
|
|
18
|
+
/** The deployer's workflow bindings, by timeline. `drain` is bound only
|
|
19
|
+
* by an env whose slots drain (`BlueGreenEnv.drain`). */
|
|
20
|
+
export declare const DEPLOYER_FLOWS: {
|
|
21
|
+
readonly cutover: "CUTOVER";
|
|
22
|
+
readonly settle: "SETTLE";
|
|
23
|
+
readonly drain: "DRAIN";
|
|
24
|
+
};
|
|
25
|
+
/** A deployer timeline. */
|
|
26
|
+
export type DeployerFlow = keyof typeof DEPLOYER_FLOWS;
|
|
27
|
+
/** Workflow class behind each binding (`@astralarium/repo-deploy-workflows/deployer`
|
|
28
|
+
* exports them). */
|
|
29
|
+
export declare const DEPLOYER_CLASSES: Record<DeployerFlow, string>;
|
|
30
|
+
/** Entrypoint of a slotted worker the deployer's slot controls bind. */
|
|
31
|
+
export declare const CONTROL_ENTRYPOINT = "OpsControl";
|
|
32
|
+
/** Service binding of `slot`'s `CONTROL_ENTRYPOINT`: `CONTROL_<SLOT>`. */
|
|
33
|
+
export type ControlBinding = `CONTROL_${Uppercase<Slot>}`;
|
|
34
|
+
/** The deployer's service binding of `slot`'s control. */
|
|
35
|
+
export declare const controlBinding: (slot: Slot) => ControlBinding;
|
|
36
|
+
/** Scripts and site a deploy acts on (the deployer's `DEPLOYMENT_VAR`). */
|
|
37
|
+
export interface Deployment {
|
|
38
|
+
/** Web worker script. */
|
|
39
|
+
webScript: string;
|
|
40
|
+
/** Slotted worker script of each slot. */
|
|
41
|
+
slotScripts: Record<Slot, string>;
|
|
42
|
+
/** The site the web script serves; checked after the switch. */
|
|
43
|
+
siteOrigin: string;
|
|
44
|
+
}
|
|
45
|
+
/** One scheduled cutover. */
|
|
46
|
+
export interface CutoverParams {
|
|
47
|
+
/** Cloudflare account holding the scripts. */
|
|
48
|
+
accountId: string;
|
|
49
|
+
/** Live web version, at 100%. */
|
|
50
|
+
liveVersion: string;
|
|
51
|
+
/** Web version of the next build, at 0%; goes live alone at the cutover. */
|
|
52
|
+
nextVersion: string;
|
|
53
|
+
/** Slot of the live build. */
|
|
54
|
+
liveSlot: Slot;
|
|
55
|
+
/** Idle slot, holding the next build. */
|
|
56
|
+
idleSlot: Slot;
|
|
57
|
+
/** Next build: the next version's `BUILD_VAR`. */
|
|
58
|
+
build: string;
|
|
59
|
+
/** ISO time the next build takes over. */
|
|
60
|
+
cutoverAt: string;
|
|
61
|
+
/** Site paths answering `{ build }` from the web worker and the live
|
|
62
|
+
* slot; the cutover checks each (`BlueGreenEnv.buildPaths`). */
|
|
63
|
+
buildPaths: readonly string[];
|
|
64
|
+
/** Whether the cutover ends the live slot's ongoing work
|
|
65
|
+
* (`OpsControlRpc.drain`) before the switch (`BlueGreenEnv.drain`). */
|
|
66
|
+
drain: boolean;
|
|
67
|
+
}
|
|
68
|
+
/** A settle onto the live build. */
|
|
69
|
+
export interface SettleParams {
|
|
70
|
+
/** Slot of the live build. */
|
|
71
|
+
slot: Slot;
|
|
72
|
+
/** Live build: the 100% web version's `BUILD_VAR`. */
|
|
73
|
+
build: string;
|
|
74
|
+
}
|
|
75
|
+
/** A drain of one slot's ongoing work (`--force` on `stage`/`deploy`). */
|
|
76
|
+
export interface DrainParams {
|
|
77
|
+
/** Slot whose build's work ends. */
|
|
78
|
+
slot: Slot;
|
|
79
|
+
}
|
|
80
|
+
/** Cutover run's workflow output: `released` named its build live;
|
|
81
|
+
* `refused` (another run's update due at close) and `superseded` (another
|
|
82
|
+
* run took over) end unchanged. */
|
|
83
|
+
export type CutoverOutcome = "released" | "refused" | "superseded";
|
|
84
|
+
/** Worst-case summed retry budget of a cutover's token-bound steps,
|
|
85
|
+
* compensation included. */
|
|
86
|
+
export declare const CUTOVER_TOKEN_BUDGET_MS: number;
|
|
87
|
+
/** API token lifetime past the scheduled cutover: `CUTOVER_TOKEN_BUDGET_MS`
|
|
88
|
+
* plus slack for a late cutover (staging overrunning its time). */
|
|
89
|
+
export declare const CUTOVER_TOKEN_MARGIN_MS: number;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The contract between `deploy-release` and the deployer's
|
|
2
|
+
* workflows: slots, the scripts acted on, and each workflow's params. */
|
|
3
|
+
/** Every slot. */
|
|
4
|
+
export const SLOTS = ["blue", "green"];
|
|
5
|
+
/** True for a slot name. */
|
|
6
|
+
export const isSlot = (value) => SLOTS.includes(value);
|
|
7
|
+
/** The slot other than `slot`. */
|
|
8
|
+
export const otherSlot = (slot) => slot === "blue" ? "green" : "blue";
|
|
9
|
+
/** Var naming a web version's slot; `deploy-release` sets it at upload,
|
|
10
|
+
* the web worker routes slot requests by it. */
|
|
11
|
+
export const SLOT_VAR = "SLOT";
|
|
12
|
+
/** Var naming a version's build; `deploy-release` sets it at upload. */
|
|
13
|
+
export const BUILD_VAR = "BUILD";
|
|
14
|
+
/** The deployer's var holding its `Deployment`. */
|
|
15
|
+
export const DEPLOYMENT_VAR = "DEPLOYMENT";
|
|
16
|
+
/** The deployer's workflow bindings, by timeline. `drain` is bound only
|
|
17
|
+
* by an env whose slots drain (`BlueGreenEnv.drain`). */
|
|
18
|
+
export const DEPLOYER_FLOWS = {
|
|
19
|
+
cutover: "CUTOVER",
|
|
20
|
+
settle: "SETTLE",
|
|
21
|
+
drain: "DRAIN",
|
|
22
|
+
};
|
|
23
|
+
/** Workflow class behind each binding (`@astralarium/repo-deploy-workflows/deployer`
|
|
24
|
+
* exports them). */
|
|
25
|
+
export const DEPLOYER_CLASSES = {
|
|
26
|
+
cutover: "CutoverWorkflow",
|
|
27
|
+
settle: "SettleWorkflow",
|
|
28
|
+
drain: "DrainWorkflow",
|
|
29
|
+
};
|
|
30
|
+
/** Entrypoint of a slotted worker the deployer's slot controls bind. */
|
|
31
|
+
export const CONTROL_ENTRYPOINT = "OpsControl";
|
|
32
|
+
/** The deployer's service binding of `slot`'s control. */
|
|
33
|
+
export const controlBinding = (slot) => `CONTROL_${slot.toUpperCase()}`;
|
|
34
|
+
/** Worst-case summed retry budget of a cutover's token-bound steps,
|
|
35
|
+
* compensation included. */
|
|
36
|
+
export const CUTOVER_TOKEN_BUDGET_MS = 2 * 60 * 60_000;
|
|
37
|
+
/** API token lifetime past the scheduled cutover: `CUTOVER_TOKEN_BUDGET_MS`
|
|
38
|
+
* plus slack for a late cutover (staging overrunning its time). */
|
|
39
|
+
export const CUTOVER_TOKEN_MARGIN_MS = 6 * 60 * 60_000;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A build's identity and the deploy contract bundles share: version
|
|
2
|
+
* checks, stale builds and the blue/green deployer. */
|
|
3
|
+
export * from "./build-fetch.ts";
|
|
4
|
+
export * from "./cloudflare.ts";
|
|
5
|
+
export * from "./control.ts";
|
|
6
|
+
export * from "./deployer.ts";
|
|
7
|
+
export * from "./info.ts";
|
|
8
|
+
export * from "./live-build.ts";
|
|
9
|
+
export * from "./stale-build.ts";
|
|
10
|
+
export * from "./version-check.ts";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A build's identity and the deploy contract bundles share: version
|
|
2
|
+
* checks, stale builds and the blue/green deployer. */
|
|
3
|
+
export * from "./build-fetch.js";
|
|
4
|
+
export * from "./cloudflare.js";
|
|
5
|
+
export * from "./control.js";
|
|
6
|
+
export * from "./deployer.js";
|
|
7
|
+
export * from "./info.js";
|
|
8
|
+
export * from "./live-build.js";
|
|
9
|
+
export * from "./stale-build.js";
|
|
10
|
+
export * from "./version-check.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Site path serving this bundle's `DeployInfo`. */
|
|
2
|
+
export declare const BUILD_JSON_PATH = "/build.json";
|
|
3
|
+
/** One bundled repo checkout. */
|
|
4
|
+
export interface DeployComponent {
|
|
5
|
+
/** Full commit hash. */
|
|
6
|
+
rev: string;
|
|
7
|
+
}
|
|
8
|
+
/** One build's identity; fields hold opaque values. */
|
|
9
|
+
export interface DeployInfo {
|
|
10
|
+
/** Per-deploy id the version check compares; "dev" outside deploys. */
|
|
11
|
+
build: string;
|
|
12
|
+
/** One entry per bundled checkout (`Checkouts`), by component name;
|
|
13
|
+
* empty in dev. */
|
|
14
|
+
components: Record<string, DeployComponent>;
|
|
15
|
+
/** Deploy env the build targets; "dev" outside deploys. */
|
|
16
|
+
env: string;
|
|
17
|
+
}
|
|
18
|
+
/** Deploy info of dev servers and tests. */
|
|
19
|
+
export declare const DEV_DEPLOY_INFO: DeployInfo;
|
|
20
|
+
/** `value` as one of the app's deploy `envs`; throws on others. */
|
|
21
|
+
export declare function parseDeployEnv<E extends string>(value: string, envs: readonly E[]): E;
|
|
22
|
+
/** Parses minted `DeployInfo` JSON naming one of `envs`; throws
|
|
23
|
+
* `ExpectedError` on a malformed shape. */
|
|
24
|
+
export declare function parseDeployInfo(text: string, envs: readonly string[]): DeployInfo;
|
|
25
|
+
/** This bundle's deploy info. */
|
|
26
|
+
export declare const deployInfo: DeployInfo;
|
package/dist/src/info.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** This build's identity, shared by every bundle of an app. */
|
|
2
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
3
|
+
/** Site path serving this bundle's `DeployInfo`. */
|
|
4
|
+
export const BUILD_JSON_PATH = "/build.json";
|
|
5
|
+
/** Deploy info of dev servers and tests. */
|
|
6
|
+
export const DEV_DEPLOY_INFO = {
|
|
7
|
+
build: "dev",
|
|
8
|
+
components: {},
|
|
9
|
+
env: "dev",
|
|
10
|
+
};
|
|
11
|
+
/** `value` as one of the app's deploy `envs`; throws on others. */
|
|
12
|
+
export function parseDeployEnv(value, envs) {
|
|
13
|
+
if (envs.includes(value))
|
|
14
|
+
return value;
|
|
15
|
+
throw new ExpectedError(`Unknown deploy env "${value}"; expected one of ${envs.join(", ")}`);
|
|
16
|
+
}
|
|
17
|
+
/** Parses minted `DeployInfo` JSON naming one of `envs`; throws
|
|
18
|
+
* `ExpectedError` on a malformed shape. */
|
|
19
|
+
export function parseDeployInfo(text, envs) {
|
|
20
|
+
const value = JSON.parse(text);
|
|
21
|
+
if (typeof value !== "object" || value === null)
|
|
22
|
+
throw new ExpectedError("DeployInfo: expected an object");
|
|
23
|
+
const { build, components, env } = value;
|
|
24
|
+
if (typeof build !== "string" || build === "")
|
|
25
|
+
throw new ExpectedError("DeployInfo: expected a build id");
|
|
26
|
+
if (typeof components !== "object" || components === null)
|
|
27
|
+
throw new ExpectedError("DeployInfo: expected components");
|
|
28
|
+
for (const [name, component] of Object.entries(components)) {
|
|
29
|
+
const { rev } = (component ?? {});
|
|
30
|
+
if (typeof rev !== "string")
|
|
31
|
+
throw new ExpectedError(`DeployInfo: malformed component "${name}"`);
|
|
32
|
+
}
|
|
33
|
+
if (typeof env !== "string")
|
|
34
|
+
throw new ExpectedError("DeployInfo: expected an env");
|
|
35
|
+
return {
|
|
36
|
+
build,
|
|
37
|
+
components: components,
|
|
38
|
+
env: parseDeployEnv(env, envs),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** This bundle's deploy info. */
|
|
42
|
+
export const deployInfo = typeof __DEPLOY_INFO__ === "undefined" ? DEV_DEPLOY_INFO : __DEPLOY_INFO__;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** `watchLiveBuild`: served-build checks driven by deploy writes. */
|
|
2
|
+
import { type ServedCheck } from "./stale-build.ts";
|
|
3
|
+
/** Rechecks of the site's build across deploy writes. */
|
|
4
|
+
export interface LiveBuildWatch {
|
|
5
|
+
/** Takes a deploy write's `live_build` and `version`. */
|
|
6
|
+
update(liveBuild: string | null, version: number): void;
|
|
7
|
+
/** Cancels the running check; the next `update` starts afresh. */
|
|
8
|
+
cancel(): void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Checks the site's build on deploy writes naming a live build other than
|
|
12
|
+
* `check.build`. One check per live build; writes during it coalesce into
|
|
13
|
+
* one follow-up once it settles current. A settled check reruns on a newer
|
|
14
|
+
* version (a compensated cutover's switch back keeps `live_build`).
|
|
15
|
+
*/
|
|
16
|
+
export declare function watchLiveBuild(onStale: () => void, check: ServedCheck): LiveBuildWatch;
|