@astralarium/repo-deploy-release 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/dist/bin/deploy-release.d.ts +2 -0
  4. package/dist/bin/deploy-release.js +7 -0
  5. package/dist/src/app.d.ts +143 -0
  6. package/dist/src/app.js +102 -0
  7. package/dist/src/bundle.d.ts +18 -0
  8. package/dist/src/bundle.js +41 -0
  9. package/dist/src/check.d.ts +44 -0
  10. package/dist/src/check.js +128 -0
  11. package/dist/src/cloudflare.d.ts +70 -0
  12. package/dist/src/cloudflare.js +170 -0
  13. package/dist/src/commands.d.ts +55 -0
  14. package/dist/src/commands.js +651 -0
  15. package/dist/src/cutover-at.d.ts +23 -0
  16. package/dist/src/cutover-at.js +152 -0
  17. package/dist/src/cutovers.d.ts +62 -0
  18. package/dist/src/cutovers.js +112 -0
  19. package/dist/src/deployer.d.ts +36 -0
  20. package/dist/src/deployer.js +63 -0
  21. package/dist/src/index.d.ts +2 -0
  22. package/dist/src/index.js +2 -0
  23. package/dist/src/lease.d.ts +36 -0
  24. package/dist/src/lease.js +112 -0
  25. package/dist/src/main.d.ts +6 -0
  26. package/dist/src/main.js +43 -0
  27. package/dist/src/process.d.ts +34 -0
  28. package/dist/src/process.js +68 -0
  29. package/dist/src/prompt.d.ts +5 -0
  30. package/dist/src/prompt.js +69 -0
  31. package/dist/src/provision.d.ts +36 -0
  32. package/dist/src/provision.js +230 -0
  33. package/dist/src/schema.d.ts +2 -0
  34. package/dist/src/schema.js +12 -0
  35. package/dist/src/session.d.ts +81 -0
  36. package/dist/src/session.js +275 -0
  37. package/dist/src/state.d.ts +55 -0
  38. package/dist/src/state.js +115 -0
  39. package/dist/src/status.d.ts +65 -0
  40. package/dist/src/status.js +224 -0
  41. package/dist/src/steps.d.ts +80 -0
  42. package/dist/src/steps.js +389 -0
  43. package/dist/src/target.d.ts +94 -0
  44. package/dist/src/target.js +246 -0
  45. package/dist/src/web.d.ts +28 -0
  46. package/dist/src/web.js +70 -0
  47. package/dist/src/workspace.d.ts +11 -0
  48. package/dist/src/workspace.js +40 -0
  49. package/dist/testkit/index.d.ts +4 -0
  50. package/dist/testkit/index.js +36 -0
  51. package/dist/testkit/release-bundles.d.ts +16 -0
  52. package/dist/testkit/release-bundles.js +56 -0
  53. package/dist/testkit/release-checks.d.ts +19 -0
  54. package/dist/testkit/release-checks.js +71 -0
  55. package/dist/testkit/release.d.ts +44 -0
  56. package/dist/testkit/release.js +61 -0
  57. package/package.json +64 -3
  58. package/sql/before-reset.sql +22 -0
  59. package/sql/schema.sql +164 -0
  60. package/sql/scrub.sql +5 -0
@@ -0,0 +1,61 @@
1
+ /** Test suite of an app's release declaration against the wrangler
2
+ * configs deploying it, and the released targets it reads from them. */
3
+ import { describe, it } from "vitest";
4
+ import { envNames } from "../src/app.js";
5
+ import { assertReleaseConfig, isBootstrapping } from "../src/check.js";
6
+ import { readEnvBlock, resolveTarget, } from "../src/target.js";
7
+ import { checkDatabase, checkQueueRetries, checkScripts, checkSite, checkSlotBindings, } from "./release-checks.js";
8
+ /** Packages of `target`: the web and slotted workers, or an in-place
9
+ * env's workers. */
10
+ const packagesOf = (target) => target.kind === "blue-green"
11
+ ? [target.packages.web, target.packages.worker]
12
+ : target.workers.map((w) => w.package);
13
+ /** Every target of `app` its configs declare, with their env blocks;
14
+ * an env whose target is undeclared is left out. */
15
+ export function releasedTargets(app) {
16
+ return envNames(app).flatMap((env) => {
17
+ const { target } = resolveTarget(app, env);
18
+ if (!target)
19
+ return [];
20
+ const packages = packagesOf(target);
21
+ return [
22
+ {
23
+ env,
24
+ target,
25
+ bootstrapping: isBootstrapping(app, env),
26
+ blocks: Object.fromEntries(packages.map((pkg) => [pkg, readEnvBlock(app, pkg, env)])),
27
+ packages,
28
+ },
29
+ ];
30
+ });
31
+ }
32
+ /** Tests of `app`'s declaration against the repo's wrangler configs and
33
+ * secrets templates, every declared target's. */
34
+ export function testRelease(app) {
35
+ const targets = releasedTargets(app);
36
+ const { queueMaxRetries } = app;
37
+ it("declares the release its wrangler configs and secrets templates commit: bootstrapping exactly the envs with a config gap", () => {
38
+ assertReleaseConfig(app);
39
+ });
40
+ it("names each script in one target", () => {
41
+ checkScripts(targets);
42
+ });
43
+ describe.each(targets)("the $env target", (released) => {
44
+ it("serves the site every worker names, routed through the web worker", () => {
45
+ checkSite(released);
46
+ });
47
+ it("shares one Hyperdrive config between its workers, a real one of its own once provisioned, or none without a database", () => {
48
+ checkDatabase(released, targets.filter((t) => t.env !== released.env));
49
+ });
50
+ it(queueMaxRetries === undefined
51
+ ? "declares no queue consumers, the app lacking queue retries"
52
+ : "gives each queue consumer the app's queue retries", () => {
53
+ checkQueueRetries(released, queueMaxRetries);
54
+ });
55
+ });
56
+ describe.each(targets.filter((t) => t.target.kind === "blue-green"))("the $env blue/green target", (released) => {
57
+ it("binds both slot scripts in the web worker", () => {
58
+ checkSlotBindings(released);
59
+ });
60
+ });
61
+ }
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@astralarium/repo-deploy-release",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "homepage": "https://github.com/astralarium/repo/tree/main/packages/repo-deploy-release#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/astralarium/repo.git",
9
+ "directory": "packages/repo-deploy-release"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "dist",
14
+ "sql"
15
+ ],
16
+ "exports": {
17
+ ".": "./dist/src/index.js",
18
+ "./schema": "./dist/src/schema.js",
19
+ "./state": "./dist/src/state.js",
20
+ "./testkit": "./dist/testkit/index.js",
21
+ "./testkit/release": "./dist/testkit/release.js",
22
+ "./testkit/release-bundles": "./dist/testkit/release-bundles.js"
23
+ },
24
+ "bin": {
25
+ "deploy-release": "./dist/bin/deploy-release.js"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "dependencies": {
31
+ "@stricli/core": "^1.3.0",
32
+ "json5": "^2.2.3",
33
+ "postgres": "^3.4.9",
34
+ "zod": "^4.6.7",
35
+ "@astralarium/repo-deploy-info": "^0.1.1",
36
+ "@astralarium/repo-postgres": "^0.2.0",
37
+ "@astralarium/repo-telemetry": "^0.2.0",
38
+ "@astralarium/repo-package-files": "^0.1.0",
39
+ "@astralarium/repo-deploy-workflows": "^0.1.1"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^24.2.0",
43
+ "eslint": "^10.2.1",
44
+ "typescript": "^6.0.3",
45
+ "vitest": "^5.0.3",
46
+ "wrangler": "^4.132.0",
47
+ "@astralarium/repo-config-typescript": "^0.1.0",
48
+ "@astralarium/repo-config-vitest": "^0.2.0",
49
+ "@astralarium/repo-config-eslint": "^0.3.0"
50
+ },
51
+ "peerDependencies": {
52
+ "vitest": "^5.0.3"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "vitest": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "engines": {
60
+ "node": ">=24.2"
61
+ },
62
+ "scripts": {
63
+ "check-types": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit",
64
+ "lint": "eslint",
65
+ "test": "vitest"
66
+ }
6
67
  }
@@ -0,0 +1,22 @@
1
+ -- Deploy lease guard, ahead of reset.sql's drop: raises while a deploy
2
+ -- command holds the lease; proceeds while the deploy schema is absent. The
3
+ -- table lock holds through the drop: a lease taken meanwhile waits, then
4
+ -- fails.
5
+ DO $$
6
+ DECLARE
7
+ holder text;
8
+ operation text;
9
+ expires_at timestamptz;
10
+ BEGIN
11
+ IF to_regclass('deploy.state') IS NULL THEN
12
+ RETURN;
13
+ END IF;
14
+ LOCK TABLE deploy.state IN ACCESS EXCLUSIVE MODE;
15
+ SELECT d.lease_holder, d.lease_operation, d.lease_expires_at
16
+ INTO holder, operation, expires_at
17
+ FROM deploy.state d;
18
+ IF expires_at > now() THEN
19
+ RAISE EXCEPTION 'A deploy command holds the lease: % (%). Wait for it, or run deploy-release unlock for the env.',
20
+ holder, operation;
21
+ END IF;
22
+ END $$;
package/sql/schema.sql ADDED
@@ -0,0 +1,164 @@
1
+ -- Deploy state in the `deploy` schema: the live build, the scheduled
2
+ -- update and the deploy lease. Loads under `@astralarium/repo-postgres`'s roles:
3
+ -- app_admin owns the schema, app_service is the workers' login.
4
+ SET ROLE app_admin;
5
+
6
+ CREATE SCHEMA deploy AUTHORIZATION app_admin;
7
+
8
+ GRANT USAGE ON SCHEMA deploy TO app_service;
9
+
10
+ -- A `deploy-release` command that changes the deployment.
11
+ CREATE TYPE deploy.operation AS ENUM
12
+ ('init', 'stage', 'deploy', 'cutover', 'rollback', 'settle', 'cancel',
13
+ 'secrets');
14
+
15
+ -- Deployment state, one row: live build, scheduled update, and the lease
16
+ -- of the command changing the deployment. `version` rises with each build
17
+ -- or update write, in commit order (writers hold the row lock).
18
+
19
+ CREATE TABLE deploy.state
20
+ (
21
+ id boolean PRIMARY KEY DEFAULT true CHECK (id),
22
+ version bigint NOT NULL DEFAULT 0,
23
+ -- The live build; null before the first release.
24
+ live_build text,
25
+ -- The scheduled update; the app closes to new work ahead of it.
26
+ update_at timestamptz,
27
+ -- The cutover run owning the update: its writes fence on it.
28
+ update_run text CHECK ((update_run IS NULL) = (update_at IS NULL)),
29
+ -- The lease: who changes the deployment, doing what, until when.
30
+ lease_holder text,
31
+ lease_operation deploy.operation,
32
+ lease_expires_at timestamptz,
33
+ CHECK ((lease_holder IS NULL) = (lease_operation IS NULL)
34
+ AND (lease_holder IS NULL) = (lease_expires_at IS NULL))
35
+ );
36
+
37
+ INSERT INTO deploy.state DEFAULT VALUES;
38
+
39
+ GRANT SELECT, UPDATE ON deploy.state TO app_service;
40
+
41
+ -- Takes the lease for `p_holder` doing `p_operation` for `p_ttl` when
42
+ -- free, expired, or its own (a renewal keeps its operation). One row:
43
+ -- whether taken, and the lease as it stands.
44
+ CREATE FUNCTION deploy.take_lease(
45
+ p_holder text,
46
+ p_operation deploy.operation,
47
+ p_ttl interval
48
+ )
49
+ RETURNS TABLE (
50
+ taken boolean,
51
+ holder text,
52
+ operation deploy.operation,
53
+ expires_at timestamptz
54
+ )
55
+ LANGUAGE plpgsql
56
+ SET search_path = pg_catalog
57
+ AS $$
58
+ BEGIN
59
+ UPDATE deploy.state d
60
+ SET lease_holder = p_holder,
61
+ lease_operation = CASE
62
+ WHEN d.lease_holder = p_holder AND d.lease_expires_at > now()
63
+ THEN d.lease_operation
64
+ ELSE p_operation
65
+ END,
66
+ lease_expires_at = now() + p_ttl
67
+ WHERE d.lease_holder IS NULL
68
+ OR d.lease_holder = p_holder
69
+ OR d.lease_expires_at < now()
70
+ RETURNING true, d.lease_holder, d.lease_operation, d.lease_expires_at
71
+ INTO taken, holder, operation, expires_at;
72
+ IF NOT FOUND THEN
73
+ -- The lease as the race winner left it.
74
+ SELECT false, d.lease_holder, d.lease_operation, d.lease_expires_at
75
+ INTO taken, holder, operation, expires_at
76
+ FROM deploy.state d;
77
+ END IF;
78
+ RETURN NEXT;
79
+ END;
80
+ $$;
81
+
82
+ COMMENT ON FUNCTION deploy.take_lease(text, deploy.operation, interval) IS
83
+ 'Takes or renews the deploy lease; reports the lease as it stands.';
84
+ REVOKE ALL ON FUNCTION deploy.take_lease(text, deploy.operation, interval) FROM PUBLIC;
85
+ GRANT EXECUTE ON FUNCTION deploy.take_lease(text, deploy.operation, interval) TO app_service;
86
+
87
+ -- Extends `p_holder`'s unexpired lease to `p_ttl` from now. One row:
88
+ -- whether renewed, and the lease as it stands.
89
+ CREATE FUNCTION deploy.renew_lease(p_holder text, p_ttl interval)
90
+ RETURNS TABLE (
91
+ renewed boolean,
92
+ holder text,
93
+ operation deploy.operation,
94
+ expires_at timestamptz
95
+ )
96
+ LANGUAGE plpgsql
97
+ SET search_path = pg_catalog
98
+ AS $$
99
+ BEGIN
100
+ UPDATE deploy.state d
101
+ SET lease_expires_at = now() + p_ttl
102
+ WHERE d.lease_holder = p_holder AND d.lease_expires_at > now()
103
+ RETURNING true, d.lease_holder, d.lease_operation, d.lease_expires_at
104
+ INTO renewed, holder, operation, expires_at;
105
+ IF NOT FOUND THEN
106
+ SELECT false, d.lease_holder, d.lease_operation, d.lease_expires_at
107
+ INTO renewed, holder, operation, expires_at
108
+ FROM deploy.state d;
109
+ END IF;
110
+ RETURN NEXT;
111
+ END;
112
+ $$;
113
+
114
+ COMMENT ON FUNCTION deploy.renew_lease(text, interval) IS
115
+ 'Extends the holder''s unexpired deploy lease; reports the lease as it stands.';
116
+ REVOKE ALL ON FUNCTION deploy.renew_lease(text, interval) FROM PUBLIC;
117
+ GRANT EXECUTE ON FUNCTION deploy.renew_lease(text, interval) TO app_service;
118
+
119
+ -- Row-locks the deploy state for the calling transaction while `p_holder`
120
+ -- holds an unexpired lease; raises otherwise. Command writes follow in the
121
+ -- same transaction.
122
+ CREATE FUNCTION deploy.fence_lease(p_holder text)
123
+ RETURNS void
124
+ LANGUAGE plpgsql
125
+ SET search_path = pg_catalog
126
+ AS $$
127
+ DECLARE
128
+ holder text;
129
+ expires_at timestamptz;
130
+ BEGIN
131
+ SELECT d.lease_holder, d.lease_expires_at INTO holder, expires_at
132
+ FROM deploy.state d FOR UPDATE;
133
+ IF NOT coalesce(holder = p_holder AND expires_at > now(), false) THEN
134
+ RAISE EXCEPTION 'deploy lease lost: % holds it until %',
135
+ coalesce(holder, 'no one'), coalesce(expires_at::text, 'released');
136
+ END IF;
137
+ END;
138
+ $$;
139
+
140
+ COMMENT ON FUNCTION deploy.fence_lease(text) IS
141
+ 'Locks the deploy state while the holder''s lease stands; raises otherwise.';
142
+ REVOKE ALL ON FUNCTION deploy.fence_lease(text) FROM PUBLIC;
143
+ GRANT EXECUTE ON FUNCTION deploy.fence_lease(text) TO app_service;
144
+
145
+ -- Drops the lease held by `p_holder`; a null holder breaks any. Returns
146
+ -- the holder dropped; null when none matched.
147
+ CREATE FUNCTION deploy.drop_lease(p_holder text)
148
+ RETURNS text
149
+ LANGUAGE sql
150
+ SET search_path = pg_catalog
151
+ AS $$
152
+ UPDATE deploy.state
153
+ SET lease_holder = NULL, lease_operation = NULL, lease_expires_at = NULL
154
+ WHERE lease_holder IS NOT NULL
155
+ AND (p_holder IS NULL OR lease_holder = p_holder)
156
+ RETURNING old.lease_holder;
157
+ $$;
158
+
159
+ COMMENT ON FUNCTION deploy.drop_lease(text) IS
160
+ 'Drops the deploy lease of a holder, or any with a null holder.';
161
+ REVOKE ALL ON FUNCTION deploy.drop_lease(text) FROM PUBLIC;
162
+ GRANT EXECUTE ON FUNCTION deploy.drop_lease(text) TO app_service;
163
+
164
+ SET ROLE NONE;
package/sql/scrub.sql ADDED
@@ -0,0 +1,5 @@
1
+ -- Snapshot scrub: the copy deploys nothing; it serves any build.
2
+ UPDATE deploy.state
3
+ SET live_build = NULL, update_at = NULL, update_run = NULL,
4
+ lease_holder = NULL, lease_operation = NULL, lease_expires_at = NULL,
5
+ version = version + 1;