flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow

- deploy/stack.jsonc lists all 22 units with their databases, stages, secrets and first-deploy setup; dependencies are read from Cargo and npm, not hand-listed. - scripts/deploy.mjs plans from each Worker's recorded commit, applies migrations in parallel, deploys stage by stage four at a time, and tags every deploy with its commit. - wasm-opt at -O1 and a pinned worker-build make Rust builds about three times faster, for a few percent of compressed size. - .g1t/workflows/deploy.yml deploys g1t from g1t on push to main or by hand; docs/DEPLOYING.md covers the token, first deploy and rollback.

syntaqxcommitted Parent0f4cf62Browse files
38 files+2781−860/38 viewed
+180−0
1+# Deploys g1t.sh from main, with g1t's own Actions. What it does is
2+# scripts/deploy.mjs, the same tool a person runs; docs/DEPLOYING.md is the
3+# guide.
4+#
5+# check the deploy manifest is consistent, and the tool's tests pass
6+# plan what changed since each Worker's live commit, and pending migrations
7+# migrate pending D1 migrations, before any code
8+# core, edge, front the units of each stage, in jobs that share a build;
9+# a stage starts only when the one before it succeeded
10+#
11+# Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row) and
12+# the variable CLOUDFLARE_ACCOUNT_ID, and api.cloudflare.com among the
13+# project's allowed domains (Settings, Guardrails). See docs/DEPLOYING.md.
14+name: Deploy
15+
16+on:
17+ push:
18+ branches: [main]
19+ workflow_dispatch:
20+ inputs:
21+ units:
22+ description: "Units to deploy whether or not they changed, comma separated (empty: what changed)"
23+ type: string
24+ default: ""
25+ all:
26+ description: "Deploy every unit"
27+ type: boolean
28+ default: false
29+ dry_run:
30+ description: "Plan only: deploy nothing"
31+ type: boolean
32+ default: false
33+
34+# One deploy at a time, and never one cut off halfway: the next waits.
35+concurrency:
36+ group: deploy-production
37+ cancel-in-progress: false
38+
39+env:
40+ CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
41+ CARGO_TERM_COLOR: never
42+ WRANGLER_SEND_METRICS: "false"
43+
44+jobs:
45+ check:
46+ name: Check
47+ runs-on: ubuntu-latest
48+ timeout-minutes: 20
49+ steps:
50+ - uses: actions/checkout@v5
51+ - name: Install Wrangler
52+ run: npm ci --workspaces=false --no-audit --no-fund
53+ - name: The manifest matches every wrangler.jsonc
54+ run: node scripts/deploy.mjs manifest --check
55+ - name: The deploy tool's tests
56+ run: npm run test:deploy
57+
58+ plan:
59+ name: Plan
60+ needs: check
61+ runs-on: ubuntu-latest
62+ environment: production
63+ timeout-minutes: 15
64+ outputs:
65+ migrate: ${{ steps.plan.outputs.migrate }}
66+ migrate_units: ${{ steps.plan.outputs.migrate_units }}
67+ has_core: ${{ steps.plan.outputs.has_core }}
68+ core: ${{ steps.plan.outputs.core }}
69+ has_edge: ${{ steps.plan.outputs.has_edge }}
70+ edge: ${{ steps.plan.outputs.edge }}
71+ has_front: ${{ steps.plan.outputs.has_front }}
72+ front: ${{ steps.plan.outputs.front }}
73+ steps:
74+ - uses: actions/checkout@v5
75+ with:
76+ # Each Worker's live commit is compared with this one.
77+ fetch-depth: 0
78+ - name: Install Wrangler
79+ run: npm ci --workspaces=false --no-audit --no-fund
80+ - name: Plan
81+ id: plan
82+ env:
83+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
84+ UNITS: ${{ inputs.units }}
85+ ALL: ${{ inputs.all }}
86+ run: |
87+ args=()
88+ if [ -n "$UNITS" ]; then args+=(--only "$UNITS" --force); fi
89+ if [ "$ALL" = "true" ]; then args+=(--all); fi
90+ node scripts/deploy.mjs plan "${args[@]}" --github-output
91+
92+ migrate:
93+ name: Migrations
94+ needs: plan
95+ if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }}
96+ runs-on: ubuntu-latest
97+ environment: production
98+ timeout-minutes: 20
99+ steps:
100+ - uses: actions/checkout@v5
101+ - name: Install Wrangler
102+ run: npm ci --workspaces=false --no-audit --no-fund
103+ - name: Apply pending migrations
104+ env:
105+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
106+ run: node scripts/deploy.mjs migrate --only "${{ needs.plan.outputs.migrate_units }}"
107+
108+ core:
109+ name: core (${{ matrix.group }})
110+ needs: [plan, migrate]
111+ if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.migrate.result == 'success' || needs.migrate.result == 'skipped') && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }}
112+ runs-on: ubuntu-latest
113+ environment: production
114+ timeout-minutes: 60
115+ strategy:
116+ # A deploy cut off halfway is worse than one that finishes: the other
117+ # jobs of a stage run on when one fails, and the next stage does not.
118+ fail-fast: false
119+ max-parallel: 4
120+ matrix: ${{ fromJSON(needs.plan.outputs.core) }}
121+ steps: &deploy
122+ - uses: actions/checkout@v5
123+ with:
124+ fetch-depth: 0
125+ # Rust workers: the wasm target, and worker-build kept between runs
126+ # (its version is pinned in scripts/build-rust-worker.mjs).
127+ - name: Rust for Workers
128+ if: ${{ matrix.rust }}
129+ run: rustup target add wasm32-unknown-unknown
130+ - name: Cache worker-build
131+ if: ${{ matrix.rust }}
132+ uses: actions/cache@v4
133+ with:
134+ path: ~/.cargo/bin/worker-build
135+ key: worker-build-${{ runner.os }}-${{ hashFiles('scripts/build-rust-worker.mjs') }}
136+ - name: Cache worker-build's tools (wasm-bindgen, esbuild)
137+ if: ${{ matrix.rust }}
138+ uses: actions/cache@v4
139+ with:
140+ path: ~/.cache/worker-build
141+ key: worker-build-tools-${{ runner.os }}-${{ hashFiles('scripts/build-rust-worker.mjs') }}
142+ - name: Cache crates
143+ if: ${{ matrix.rust }}
144+ uses: actions/cache@v4
145+ with:
146+ path: ~/.cargo/registry/cache
147+ key: cargo-crates-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
148+ restore-keys: cargo-crates-${{ runner.os }}-
149+ - name: Install
150+ run: node scripts/deploy.mjs install --only "${{ matrix.units }}"
151+ - name: Deploy ${{ matrix.units }}
152+ env:
153+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
154+ run: node scripts/deploy.mjs deploy --only "${{ matrix.units }}" --force --no-migrations --concurrency 2
155+
156+ edge:
157+ name: edge (${{ matrix.group }})
158+ needs: [plan, migrate, core]
159+ if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.migrate.result == 'success' || needs.migrate.result == 'skipped') && (needs.core.result == 'success' || needs.core.result == 'skipped') && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }}
160+ runs-on: ubuntu-latest
161+ environment: production
162+ timeout-minutes: 60
163+ strategy:
164+ fail-fast: false
165+ max-parallel: 4
166+ matrix: ${{ fromJSON(needs.plan.outputs.edge) }}
167+ steps: *deploy
168+
169+ front:
170+ name: front (${{ matrix.group }})
171+ needs: [plan, migrate, core, edge]
172+ if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.migrate.result == 'success' || needs.migrate.result == 'skipped') && (needs.core.result == 'success' || needs.core.result == 'skipped') && (needs.edge.result == 'success' || needs.edge.result == 'skipped') && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }}
173+ runs-on: ubuntu-latest
174+ environment: production
175+ timeout-minutes: 60
176+ strategy:
177+ fail-fast: false
178+ max-parallel: 4
179+ matrix: ${{ fromJSON(needs.plan.outputs.front) }}
180+ steps: *deploy
+9−0
5252 in `packages/contracts`).
5353 - Look at what you changed in a browser. Screenshots catch what type
5454 checks do not.
55+
56+## Deploying
57+
58+Pushes to `main` deploy themselves: `.g1t/workflows/deploy.yml` runs
59+`scripts/deploy.mjs`, which deploys only the parts that changed, migrations
60+first. Every deployable part is listed in `deploy/stack.jsonc`; a new service
61+or app goes there (`npm run test:deploy` says what is missing). See
62+[docs/DEPLOYING.md](docs/DEPLOYING.md) for the tool, the workflow, rollbacks
63+and adding a service.
+5−0
1616 worker.workspace = true
1717 base64 = "0.22"
1818 form_urlencoded = "1"
19+
20+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
21+# time (docs/DEPLOYING.md, "Build speed").
22+[package.metadata.wasm-pack.profile.release]
23+wasm-opt = ["-O1"]
+1−1
44 "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
55 "compatibility_date": "2026-09-26",
66 "main": "build/index.js",
7− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
7+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
88 "workers_dev": false,
99 // One Worker, two hostnames: REST on api, MCP on mcp. Both are thin
1010 // adapters over the same operations.
+158−0
1+//! g1t's own workflows (`.g1t/workflows/*.yml`), read by the same parser
2+//! and expressions the actions service runs them with: each reads, nothing
3+//! in it is unsupported, and the deploy workflow's jobs start, wait and
4+//! stop as docs/DEPLOYING.md says.
5+
6+use std::path::PathBuf;
7+
8+use g1t_actions::expr::{self, Scope, Status};
9+use g1t_actions::matrix;
10+use g1t_actions::workflow::{self, Severity, Workflow};
11+use serde_json::{Map, Value, json};
12+
13+fn workflows_dir() -> PathBuf {
14+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.g1t/workflows")
15+}
16+
17+fn read(name: &str) -> Workflow {
18+ let source = std::fs::read_to_string(workflows_dir().join(name)).unwrap();
19+ workflow::parse(&source).unwrap_or_else(|problem| panic!("{name}: {problem}"))
20+}
21+
22+#[test]
23+fn every_workflow_reads_and_runs_on_g1t() {
24+ let mut count = 0;
25+ for entry in std::fs::read_dir(workflows_dir()).unwrap() {
26+ let path = entry.unwrap().path();
27+ if path.extension().and_then(|e| e.to_str()) != Some("yml") {
28+ continue;
29+ }
30+ let name = path.file_name().unwrap().to_string_lossy().to_string();
31+ let workflow = read(&name);
32+ let unsupported: Vec<_> = workflow.notes.iter().filter(|n| n.severity != Severity::Info).collect();
33+ assert!(unsupported.is_empty(), "{name}: {unsupported:?}");
34+ count += 1;
35+ }
36+ assert!(count >= 1);
37+}
38+
39+/// The plan job's outputs for a deploy of `stages` (each with its jobs).
40+fn plan_outputs(migrate: bool, core: &[(&str, &str, bool)], edge: &[(&str, &str, bool)], front: &[(&str, &str, bool)]) -> Value {
41+ let matrix = |jobs: &[(&str, &str, bool)]| {
42+ let include: Vec<Value> = if jobs.is_empty() {
43+ vec![json!({ "group": "none", "units": "" })]
44+ } else {
45+ jobs.iter().map(|(group, units, rust)| json!({ "group": group, "units": units, "rust": rust })).collect()
46+ };
47+ json!({ "include": include }).to_string()
48+ };
49+ json!({
50+ "migrate": migrate.to_string(),
51+ "migrate_units": if migrate { "events" } else { "" },
52+ "has_core": (!core.is_empty()).to_string(),
53+ "core": matrix(core),
54+ "has_edge": (!edge.is_empty()).to_string(),
55+ "edge": matrix(edge),
56+ "has_front": (!front.is_empty()).to_string(),
57+ "front": matrix(front),
58+ })
59+}
60+
61+/// Whether `job` starts, given its needs' results, as the actions service
62+/// decides it (services/actions/src/plan.rs `decide`): any need that did
63+/// not succeed makes the status a failure.
64+fn starts(workflow: &Workflow, job: &str, needs: &[(&str, &str)], outputs: &Value, inputs: Value, cancelled: bool) -> bool {
65+ let job = workflow.jobs.iter().find(|j| j.id == job).unwrap();
66+ let mut needs_ctx = Map::new();
67+ let mut status = if cancelled { Status::Cancelled } else { Status::Success };
68+ for need in &job.needs {
69+ let result = needs.iter().find(|(name, _)| name == need).map(|(_, r)| *r).unwrap_or("success");
70+ if result != "success" && matches!(status, Status::Success) {
71+ status = Status::Failure;
72+ }
73+ let outputs = if need == "plan" { outputs.clone() } else { json!({}) };
74+ needs_ctx.insert(need.clone(), json!({ "result": result, "outputs": outputs }));
75+ }
76+ let mut contexts = Map::new();
77+ contexts.insert("needs".into(), Value::Object(needs_ctx));
78+ contexts.insert("inputs".into(), inputs);
79+ contexts.insert("github".into(), json!({ "event_name": "push", "ref": "refs/heads/main" }));
80+ let scope = Scope { contexts: &contexts, status, hash_files: None };
81+ expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap()
82+}
83+
84+#[test]
85+fn deploy_runs_on_main_and_by_hand_one_at_a_time() {
86+ let deploy = read("deploy.yml");
87+ let push = deploy.trigger("push").unwrap();
88+ assert!(push.branches.allows("main"));
89+ assert!(!push.branches.allows("feature"));
90+ let dispatch = deploy.trigger("workflow_dispatch").unwrap();
91+ for input in ["units", "all", "dry_run"] {
92+ assert!(dispatch.inputs.contains_key(input), "{input}");
93+ }
94+ let concurrency = deploy.concurrency.as_ref().unwrap();
95+ assert_eq!(concurrency.group, "deploy-production");
96+ assert_eq!(concurrency.cancel_in_progress, json!(false));
97+ assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front"]);
98+ // The stages share their steps (a YAML alias), and read the token only
99+ // where they deploy.
100+ let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
101+ assert_eq!(steps("core"), steps("edge"));
102+ assert_eq!(steps("core"), steps("front"));
103+ let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
104+ assert!(!source.contains("cancel-in-progress: true"));
105+}
106+
107+#[test]
108+fn deploy_stages_follow_one_another() {
109+ let deploy = read("deploy.yml");
110+ let all = plan_outputs(true, &[("rust", "events,repos", true), ("ts", "projects", false)], &[("rust", "api", true)], &[("web", "web", false)]);
111+ let push = json!({});
112+
113+ // Everything succeeds: each stage runs after the last.
114+ assert!(starts(&deploy, "migrate", &[], &all, push.clone(), false));
115+ assert!(starts(&deploy, "core", &[("migrate", "success")], &all, push.clone(), false));
116+ assert!(starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), false));
117+ assert!(starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "success")], &all, push.clone(), false));
118+
119+ // No migrations: the migrate job is skipped, and core still runs.
120+ let none = plan_outputs(false, &[("ts", "projects", false)], &[], &[("web", "web", false)]);
121+ assert!(!starts(&deploy, "migrate", &[], &none, push.clone(), false));
122+ assert!(starts(&deploy, "core", &[("migrate", "skipped")], &none, push.clone(), false));
123+ // An empty stage is skipped, and the next one still runs.
124+ assert!(!starts(&deploy, "edge", &[("migrate", "skipped"), ("core", "success")], &none, push.clone(), false));
125+ assert!(starts(&deploy, "front", &[("migrate", "skipped"), ("core", "success"), ("edge", "skipped")], &none, push.clone(), false));
126+
127+ // A failure stops every later stage.
128+ assert!(!starts(&deploy, "core", &[("migrate", "failure")], &all, push.clone(), false));
129+ assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "failure")], &all, push.clone(), false));
130+ assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "success"), ("edge", "failure")], &all, push.clone(), false));
131+ assert!(!starts(&deploy, "front", &[("migrate", "success"), ("core", "failure"), ("edge", "skipped")], &all, push.clone(), false));
132+ // A cancelled run starts nothing more.
133+ assert!(!starts(&deploy, "edge", &[("migrate", "success"), ("core", "success")], &all, push.clone(), true));
134+
135+ // A dry run plans and stops.
136+ let dry = json!({ "dry_run": true, "units": "", "all": false });
137+ assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false));
138+ assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry, false));
139+}
140+
141+#[test]
142+fn deploy_stage_matrices_come_from_the_plan() {
143+ let deploy = read("deploy.yml");
144+ let outputs = plan_outputs(false, &[("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)], &[], &[]);
145+ let core = deploy.jobs.iter().find(|j| j.id == "core").unwrap();
146+ assert!(!core.fail_fast);
147+ assert_eq!(core.max_parallel, Some(4));
148+ let mut contexts = Map::new();
149+ contexts.insert("needs".into(), json!({ "plan": { "result": "success", "outputs": outputs } }));
150+ let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
151+ let value = expr::interpolate_value(core.matrix.as_ref().unwrap(), &scope).unwrap();
152+ let jobs = matrix::expand(&value).unwrap();
153+ let groups: Vec<(&str, &str, bool)> = jobs
154+ .iter()
155+ .map(|c| (c["group"].as_str().unwrap(), c["units"].as_str().unwrap(), c["rust"].as_bool().unwrap()))
156+ .collect();
157+ assert_eq!(groups, [("rust-1", "events,work", true), ("rust-2", "repos", true), ("ts", "projects,og", false)]);
158+}
+12−17
3434
3535 const PUBLIC_URL = (process.env.PUBLIC_URL ?? "http://localhost:8787").replace(/\/$/, "");
3636
37+// What runs, and what is off, is each unit's `self_host` in
38+// deploy/stack.jsonc: the list hosted g1t deploys from.
39+const STACK = Object.values(parseJsonc(readFileSync(join(root, "deploy/stack.jsonc"), "utf8")).units);
40+
3741 /** Services that run, in the order Wrangler is given them (the site first). */
38−export const RUNNING = [
39− { name: "g1t", dir: "apps/web", web: true },
40− { name: "g1t-identity", dir: "services/identity" },
41− { name: "g1t-repos", dir: "services/repos" },
42− { name: "g1t-work", dir: "services/work" },
43− { name: "g1t-events", dir: "services/events" },
44− { name: "g1t-projects", dir: "services/projects" },
45− { name: "g1t-search", dir: "services/search" },
46− { name: "g1t-billing", dir: "services/billing" },
47− { name: "g1t-security", dir: "services/security" },
48− { name: "g1t-actions", dir: "services/actions" },
49− { name: "g1t-webhooks", dir: "services/webhooks" },
50− { name: "g1t-integrations", dir: "services/integrations" },
51− { name: "g1t-deployments", dir: "services/deployments" },
52−];
42+export const RUNNING = STACK.filter((unit) => unit.self_host === "run")
43+ .map((unit) => ({ name: unit.worker, dir: unit.path, web: unit.kind === "react-router" }))
44+ .sort((a, b) => Number(b.web) - Number(a.web));
5345
54−/** Services that are off in phase 1, and what the off Worker calls them. */
55−const OFF = {
46+/** What the off Worker calls each service that is off in phase 1. */
47+const OFF_NAMES = {
5648 "g1t-runner": "Agents",
5749 "g1t-context": "Context search and memory",
5850 };
51+const OFF = Object.fromEntries(
52+ STACK.filter((unit) => unit.self_host === "off").map((unit) => [unit.worker, OFF_NAMES[unit.worker] ?? unit.worker]),
53+);
5954
6055 /** Sealing keys, by the service that holds each (hosted: Wrangler secrets). */
6156 const SECRETS = {
+261−0
1+// Everything g1t deploys to Cloudflare, in one place. Read by
2+// scripts/deploy.mjs (plan, deploy), deploy/self-host/configs.mjs (what a
3+// self-hosted installation runs) and the tests in scripts/deploy/.
4+// docs/DEPLOYING.md explains each field and how to add a unit.
5+//
6+// What is written here is what the Wrangler configs cannot say. The rest is
7+// read from each unit's wrangler.jsonc, never copied: its D1 databases and
8+// migrations, the services it binds to, its KV, R2, queues and routes. The
9+// shared crates and packages a unit is built from are read from Cargo's and
10+// npm's workspace metadata. `worker` and `d1` are written here too, so the
11+// file reads as an inventory, and a test checks they match the configs.
12+{
13+ // Deployed in this order. A stage starts only when the one before it
14+ // succeeded. A unit binds only to units in its own stage or an earlier
15+ // one (a test checks it), so new code never calls a service that has
16+ // not shipped yet. Within a stage, units go out in parallel.
17+ //
18+ // migrations: every pending D1 migration, before any code.
19+ // core: the services, reached through service bindings.
20+ // edge: public endpoints other than the site: API, MCP, models, g1t.page, status.
21+ // front: the site, sudo and the docs.
22+ "stages": ["migrations", "core", "edge", "front"],
23+
24+ // Names for the resources the configs refer to by id, for setup
25+ // commands and the docs. A test checks every KV id in a config is here.
26+ "resources": {
27+ "kv": {
28+ "16a4232cb746418db53782aa068be693": "g1t-actions-blobs",
29+ "e627b571f07047e187c03e1fc2b3bbdd": "g1t-avatars",
30+ "14bc5c233d4c46a5bbf23b5367cce5fd": "g1t-domains",
31+ "be765052d0124c2a935b3db4dff99f1f": "g1t-repos-git-cache"
32+ }
33+ },
34+
35+ // Each deployable unit, by short name (`--only events,web`).
36+ //
37+ // kind: rust-worker (worker-build), ts-worker (Wrangler bundles it),
38+ // react-router (vite build first), astro (astro build first).
39+ // secrets: names only; set with `npx wrangler secret put NAME` in its folder.
40+ // setup: one-time steps no config can say, for a first deploy.
41+ // self_host: what deploy/self-host does with it: "run" (in the one
42+ // workerd), "off" (bound to the off Worker), "separate" (a
43+ // process of its own), or "none".
44+ // inputs: files outside its folder it is built from that no workspace
45+ // metadata names (a test finds such imports).
46+ // image: a Containers image, built with Docker on deploy.
47+ "units": {
48+ "events": {
49+ "path": "services/events",
50+ "kind": "rust-worker",
51+ "worker": "g1t-events",
52+ "d1": { "database": "g1t-events", "migrations": "migrations" },
53+ "stage": "core",
54+ "secrets": [],
55+ "self_host": "run"
56+ },
57+ "identity": {
58+ "path": "services/identity",
59+ "kind": "rust-worker",
60+ "worker": "g1t-identity",
61+ "d1": { "database": "g1t", "migrations": "migrations" },
62+ "stage": "core",
63+ "secrets": ["GITHUB_APP_CLIENT_SECRET", "IDENTITY_KEY"],
64+ "setup": ["Email Sending on g1t.sh (the EMAIL binding)"],
65+ "self_host": "run"
66+ },
67+ "repos": {
68+ "path": "services/repos",
69+ "kind": "rust-worker",
70+ "worker": "g1t-repos",
71+ "d1": { "database": "g1t-repos", "migrations": "migrations" },
72+ "stage": "core",
73+ "secrets": ["REPOS_KEY"],
74+ "setup": ["The Artifacts namespace `g1t` (the ARTIFACTS binding)"],
75+ "self_host": "run"
76+ },
77+ "work": {
78+ "path": "services/work",
79+ "kind": "rust-worker",
80+ "worker": "g1t-work",
81+ "d1": { "database": "g1t-work", "migrations": "migrations" },
82+ "stage": "core",
83+ "secrets": [],
84+ "self_host": "run"
85+ },
86+ "search": {
87+ "path": "services/search",
88+ "kind": "rust-worker",
89+ "worker": "g1t-search",
90+ "d1": { "database": "g1t-search", "migrations": "migrations" },
91+ "stage": "core",
92+ "secrets": [],
93+ "self_host": "run"
94+ },
95+ "projects": {
96+ "path": "services/projects",
97+ "kind": "ts-worker",
98+ "worker": "g1t-projects",
99+ "d1": { "database": "g1t-projects", "migrations": "migrations" },
100+ "stage": "core",
101+ "secrets": [],
102+ "self_host": "run"
103+ },
104+ "billing": {
105+ "path": "services/billing",
106+ "kind": "rust-worker",
107+ "worker": "g1t-billing",
108+ "d1": { "database": "g1t-billing", "migrations": "migrations" },
109+ "stage": "core",
110+ "secrets": ["STRIPE_SECRET_KEY", "CLOUDFLARE_USAGE_TOKEN"],
111+ "self_host": "run"
112+ },
113+ "integrations": {
114+ "path": "services/integrations",
115+ "kind": "rust-worker",
116+ "worker": "g1t-integrations",
117+ "d1": { "database": "g1t-integrations", "migrations": "migrations" },
118+ "stage": "core",
119+ "secrets": ["INTEGRATIONS_KEY", "GITHUB_APP_PRIVATE_KEY", "GITHUB_APP_WEBHOOK_SECRET"],
120+ "self_host": "run"
121+ },
122+ "webhooks": {
123+ "path": "services/webhooks",
124+ "kind": "rust-worker",
125+ "worker": "g1t-webhooks",
126+ "d1": { "database": "g1t-webhooks", "migrations": "migrations" },
127+ "stage": "core",
128+ "secrets": ["WEBHOOKS_KEY"],
129+ "self_host": "run"
130+ },
131+ "actions": {
132+ "path": "services/actions",
133+ "kind": "rust-worker",
134+ "worker": "g1t-actions",
135+ "d1": { "database": "g1t-actions", "migrations": "migrations" },
136+ "stage": "core",
137+ "secrets": ["ACTIONS_KEY"],
138+ "self_host": "run"
139+ },
140+ "security": {
141+ "path": "services/security",
142+ "kind": "rust-worker",
143+ "worker": "g1t-security",
144+ "d1": { "database": "g1t-security", "migrations": "migrations" },
145+ "stage": "core",
146+ "secrets": [],
147+ "self_host": "run"
148+ },
149+ "deployments": {
150+ "path": "services/deployments",
151+ "kind": "ts-worker",
152+ "worker": "g1t-deployments",
153+ "d1": { "database": "g1t-deployments", "migrations": "migrations" },
154+ "stage": "core",
155+ "secrets": ["CLOUDFLARE_API_TOKEN"],
156+ "setup": [
157+ "Workers for Platforms, and the dispatch namespace: scripts/setup-deployments.sh",
158+ "Custom domains (Cloudflare for SaaS on g1t.page): scripts/setup-custom-domains.sh"
159+ ],
160+ "self_host": "run"
161+ },
162+ "runner": {
163+ "path": "services/runner",
164+ "kind": "ts-worker",
165+ "worker": "g1t-runner",
166+ "stage": "core",
167+ "secrets": ["AI_GATEWAY_TOKEN"],
168+ "setup": ["Containers on the account; Docker on the machine that deploys a new image"],
169+ "image": {
170+ "dockerfile": "services/runner/Dockerfile",
171+ // The image compiles this crate (and what it depends on).
172+ "crate": "g1t-runner"
173+ },
174+ "self_host": "off"
175+ },
176+ "context": {
177+ "path": "services/context",
178+ "kind": "ts-worker",
179+ "worker": "g1t-context",
180+ "d1": { "database": "g1t-context", "migrations": "migrations" },
181+ "stage": "core",
182+ "secrets": [],
183+ "setup": [
184+ "The Vectorize index: npx wrangler vectorize create g1t-context --dimensions=768 --metric=cosine, with metadata indexes on workspace, kind, project and private"
185+ ],
186+ "self_host": "off"
187+ },
188+ "og": {
189+ "path": "services/og",
190+ "kind": "ts-worker",
191+ "worker": "g1t-og",
192+ "stage": "core",
193+ "secrets": [],
194+ "setup": ["Browser Rendering on the account (the BROWSER binding)"],
195+ // The roadmap cards read the site's roadmap.
196+ "inputs": ["apps/web/app/lib/roadmap.ts"],
197+ "self_host": "none"
198+ },
199+ "api": {
200+ "path": "apps/api",
201+ "kind": "rust-worker",
202+ "worker": "g1t-api",
203+ "stage": "edge",
204+ "secrets": [],
205+ "self_host": "none"
206+ },
207+ "models": {
208+ "path": "services/models",
209+ "kind": "ts-worker",
210+ "worker": "g1t-models",
211+ "stage": "edge",
212+ "secrets": ["AI_GATEWAY_TOKEN"],
213+ "setup": ["The AI Gateway `g1t`"],
214+ "self_host": "none"
215+ },
216+ "pages": {
217+ "path": "services/pages",
218+ "kind": "ts-worker",
219+ "worker": "g1t-pages",
220+ "stage": "edge",
221+ "secrets": [],
222+ "setup": ["A proxied wildcard DNS record on g1t.page (`*`, AAAA 100::): scripts/setup-deployments.sh"],
223+ "self_host": "none"
224+ },
225+ "status": {
226+ "path": "apps/status",
227+ "kind": "ts-worker",
228+ "worker": "g1t-status",
229+ "d1": { "database": "g1t-status", "migrations": "migrations" },
230+ "stage": "edge",
231+ "secrets": ["STATUS_SECRET"],
232+ "setup": ["Email Sending on g1t.sh (the EMAIL binding)"],
233+ "self_host": "separate"
234+ },
235+ "web": {
236+ "path": "apps/web",
237+ "kind": "react-router",
238+ "worker": "g1t",
239+ "stage": "front",
240+ "secrets": [],
241+ "self_host": "run"
242+ },
243+ "sudo": {
244+ "path": "apps/sudo",
245+ "kind": "react-router",
246+ "worker": "g1t-sudo",
247+ "stage": "front",
248+ "secrets": [],
249+ "setup": ["A Cloudflare Access application on sudo.g1t.sh; its AUD tag is ACCESS_AUD"],
250+ "self_host": "none"
251+ },
252+ "docs": {
253+ "path": "apps/docs",
254+ "kind": "astro",
255+ "worker": "g1t-docs",
256+ "stage": "front",
257+ "secrets": [],
258+ "self_host": "none"
259+ }
260+ }
261+}
+394−0
1+# Deploying g1t
2+
3+How g1t.sh gets to Cloudflare: one manifest that lists every deployable
4+unit, one tool that deploys only what changed, and a g1t Actions workflow
5+that runs that tool on every push to `main`. Internal: the public
6+self-hosting guide is `apps/docs/src/content/docs/guides/self-hosting.md`.
7+
8+| Piece | Where |
9+| --- | --- |
10+| The manifest | `deploy/stack.jsonc` |
11+| The tool | `scripts/deploy.mjs` (library and tests in `scripts/deploy/`) |
12+| Rust Worker builds | `scripts/build-rust-worker.mjs`, every Rust unit's build command |
13+| The workflow | `.g1t/workflows/deploy.yml` |
14+| The old entry point | `scripts/deploy.sh`, now a wrapper |
15+
16+## The manifest
17+
18+`deploy/stack.jsonc` names every unit: each folder with a `wrangler.jsonc`.
19+It is JSONC rather than TOML so that Node reads it with no dependency,
20+with the same parser as the Wrangler configs, and comments stay possible.
21+
22+| Field | |
23+| --- | --- |
24+| `path` | The unit's folder. |
25+| `kind` | `rust-worker` (built by worker-build), `ts-worker` (Wrangler bundles it), `react-router` (`vite build` first), `astro` (`astro build` first). |
26+| `worker` | The Worker's name. Must match its `wrangler.jsonc`. |
27+| `d1` | `{ database, migrations }`, when it has a database. Must match its `wrangler.jsonc`. |
28+| `stage` | `core`, `edge` or `front` (below). |
29+| `secrets` | The Wrangler secrets it needs, by name. `node scripts/deploy.mjs doctor` checks they are set. |
30+| `setup` | One-time steps no config can say, for a first deploy. |
31+| `inputs` | Files outside its folder it is built from that no workspace metadata names. A test finds such imports. |
32+| `image` | A Containers image (`dockerfile`, and the `crate` it compiles), which needs Docker to build. |
33+| `self_host` | `run`, `off`, `separate` or `none`: what `deploy/self-host/configs.mjs` does with it. |
34+
35+What a unit is **built from** is never listed by hand. The tool reads it:
36+Rust path dependencies from `cargo metadata`, workspace packages from each
37+`package.json`, closed transitively. `node scripts/deploy.mjs manifest`
38+prints the result:
39+
40+```
41+unit stage kind worker d1 built from (besides its folder)
42+events core rust-worker g1t-events g1t-events crates/contracts crates/kit
43+repos core rust-worker g1t-repos g1t-repos crates/contracts crates/kit crates/scan crates/secrets
44+runner core ts-worker g1t-runner crates/actions crates/runner packages/contracts
45+og core ts-worker g1t-og packages/contracts
46+web front react-router g1t packages/contracts packages/theme
47+...
48+```
49+
50+Root files count too: `Cargo.toml`, `Cargo.lock` and
51+`scripts/build-rust-worker.mjs` for Rust units; `package-lock.json` and
52+`tsconfig.base.json` for the others; `package.json` for all. A lockfile
53+change counts for a unit only if a package in that unit's graph changed,
54+read from the lockfile itself (`scripts/deploy/lockfiles.mjs`), so bumping
55+`sharp` for the docs does not redeploy the Rust services.
56+
57+### Stages
58+
59+| Stage | What | Units |
60+| --- | --- | --- |
61+| `migrations` | Every pending D1 migration, in parallel, before any code | each unit's `d1` |
62+| `core` | Services reached through bindings | the services, `og` |
63+| `edge` | Public endpoints other than the site | `api`, `models`, `pages`, `status` |
64+| `front` | The site, sudo, the docs | `web`, `sudo`, `docs` |
65+
66+A stage starts only when the one before it succeeded. Inside a stage units
67+deploy in parallel. The rule the tests enforce: **a unit binds only to units
68+in its own stage or an earlier one**, so new code never calls a service
69+that has not shipped. (Services in `core` bind to each other in cycles,
70+which is why they share a stage.)
71+
72+### What reads the manifest
73+
74+- `scripts/deploy.mjs`: everything below.
75+- `deploy/self-host/configs.mjs`: which Workers a self-hosted installation
76+ runs (`self_host: "run"`, the site first) and which are bound to the off
77+ Worker (`"off"`). Its output is identical to before, apart from the order
78+ of `workers.txt` after the site.
79+- Tests (`npm run test:deploy`) check that: every `wrangler.jsonc` in the
80+ repository has a unit; `worker`, `d1` and `image` match the configs;
81+ every KV id is named under `resources.kv`; stages follow bindings; the
82+ derived dependencies agree with Cargo's own resolved graph; every import
83+ that leaves a unit's folder is covered; `deploy/self-host/Dockerfile`
84+ builds exactly the Rust units self-hosting runs; and the service table in
85+ `docs/SELF_HOSTING.md` names every unit.
86+
87+## The tool
88+
89+```sh
90+node scripts/deploy.mjs plan # what would deploy, and why (read-only)
91+node scripts/deploy.mjs deploy # migrations, then every changed unit
92+node scripts/deploy.mjs deploy --only web,api # just these, if they changed
93+node scripts/deploy.mjs deploy --only web --force # just this, changed or not
94+node scripts/deploy.mjs deploy --all # everything
95+node scripts/deploy.mjs build --only events # build as a deploy would; upload nothing
96+node scripts/deploy.mjs migrate # pending migrations only
97+node scripts/deploy.mjs manifest [--check|--json]
98+node scripts/deploy.mjs doctor # secrets each unit lacks
99+scripts/deploy.sh [units...] # the old entry point: all, or those named, always
100+```
101+
102+| Flag | |
103+| --- | --- |
104+| `--only a,b` / `--skip a,b` | Units by short name, folder or Worker name. |
105+| `--all` | Every unit, changed or not. |
106+| `--force` | Deploy the selected units even if unchanged. |
107+| `--concurrency N` | Units at once inside a stage, and migrations at once (default 4). |
108+| `--stage core` | One stage only. |
109+| `--no-migrations` | Skip the migrations step (the workflow runs it as its own job). |
110+| `--allow-dirty` | Deploy with uncommitted changes in what deploys. The version records no commit, so the next plan deploys it again. |
111+| `--rebuild-image` | Build the runner's image even if nothing it is built from changed. |
112+| `--since REV` | Treat Workers with no recorded commit as running `REV`. Used once to adopt Workers deployed before this tool. |
113+| `--json`, `--out FILE`, `--github-output` | The plan as data, for the workflow. |
114+
115+### Where the deployed commit is kept
116+
117+On the Worker itself. Every deploy runs `wrangler deploy --message
118+"g1t-deploy <40-char sha> <subject>" --tag g1t-<12-char sha>`, which
119+Cloudflare keeps as the version's `workers/message` and `workers/tag`
120+annotations. `plan` reads them back with `wrangler deployments status`
121+(the live version) and `wrangler versions list` (its annotations): two
122+read-only calls per unit, in parallel; a plan of all 22 units takes about
123+10 seconds. No KV namespace or other infrastructure is needed.
124+
125+- A version made by `wrangler secret put` keeps the code of the one before
126+ it, so the tool looks through those to the deploy before.
127+- A version deployed any other way (by hand, from the dashboard) has no
128+ commit, and the unit is deployed again.
129+- During a gradual rollout the version with the most traffic counts.
130+- After `wrangler rollback`, the plan sees the older commit and deploys
131+ what changed since.
132+
133+### What a deploy does
134+
135+1. Plans: for each unit, the live commit; `git diff` from it to `HEAD`;
136+ whether the changed files touch the unit (its folder, the crates and
137+ packages it is built from, its inputs, lockfile changes that reach it).
138+2. Refuses if uncommitted changes touch what would deploy (`--allow-dirty`).
139+3. Applies every pending migration (`wrangler d1 migrations apply --remote`),
140+ in parallel. Any failure stops the deploy before code.
141+4. Installs worker-build once if any Rust unit is deploying.
142+5. Each stage in turn: units in parallel (`--concurrency`), each `npm run
143+ build` first for React Router and Astro, then `wrangler deploy` in the
144+ unit's folder with the annotation. If any unit fails, later stages are
145+ not started.
146+6. Prints a table: unit, stage, result, version id, time. Each unit's full
147+ output is kept in `$TMPDIR/g1t-deploy/<unit>.log`.
148+
149+On a laptop the tool uses your `wrangler login` (or `CLOUDFLARE_DEPLOY_TOKEN`
150+if set), as `scripts/deploy.sh` always did: a `CLOUDFLARE_API_TOKEN` or
151+global API key in your shell, or in the repository's `.env`, is ignored.
152+With `CI=true` it uses `CLOUDFLARE_API_TOKEN`.
153+
154+### The runner's image
155+
156+`services/runner` deploys a Containers image built from
157+`services/runner/Dockerfile`, which needs Docker. The tool rebuilds it only
158+when something the image is built from changed since the runner's live
159+commit (the Dockerfile, `crates/runner` and the crates it uses,
160+`Cargo.toml`, `Cargo.lock`), or with `--rebuild-image`. Otherwise it deploys
161+the Worker with `--containers-rollout none`, which leaves the running image
162+alone. g1t Actions sandboxes have no Docker, so a CI deploy that needs a new
163+image fails that unit with what to do, and later stages wait:
164+
165+```sh
166+node scripts/deploy.mjs deploy --only runner # on a machine with Docker
167+```
168+
169+then re-run the workflow; its plan now sees the runner up to date.
170+
171+## Build speed
172+
173+Measured on the development machine (Windows, 32 cores, warm Cargo cache),
174+building `events`, `search` and `repos` after a change to `crates/kit`, as a
175+deploy does but without uploading (`wrangler deploy --dry-run`):
176+
177+| | Time |
178+| --- | --- |
179+| Before: one after another, `cargo install worker-build` each time, wasm-opt `-O` | 81 s, 84 s |
180+| Concurrent builds, wasm-opt `-O` | 50 s, 68 s |
181+| Concurrent builds, wasm-opt `-O1` (now) | 28 s, 33 s |
182+
183+Where the time went, and what changed:
184+
185+- **wasm-opt** was most of it: `-O` took 38 s on `repos` and 19 s on `api`;
186+ `-O1` takes 1 to 3 s. With worker-build's flags (it keeps the names
187+ section) the `.wasm` is 24 to 28% larger raw but only 1 to 7% larger
188+ gzipped, and Workers' size limit is on the compressed upload. `-Os` and
189+ `-Oz` were no faster than `-O`. Set per crate in
190+ `[package.metadata.wasm-pack.profile.release]`; a test keeps every Rust
191+ unit on the same level.
192+- **worker-build** is installed only when missing or another version
193+ (`scripts/build-rust-worker.mjs`, which pins it). Locally `cargo install`
194+ on an installed version cost under a second; on a fresh CI sandbox it is
195+ a full compile, which the workflow caches instead.
196+- **One Cargo target**: every Rust unit is a member of the workspace, so
197+ they already share `target/`. Concurrent builds take turns on Cargo's
198+ lock for the compile, and their wasm-bindgen, wasm-opt and uploads
199+ overlap.
200+- **No joint `cargo build -p a -p b`**: tried, and it is slower. Cargo
201+ unifies features across the packages of one build (`serde_json`'s
202+ `preserve_order` from `api` and `actions`, `digest` features from
203+ `secrets`), so each worker-build afterwards compiled its own variant
204+ again.
205+- **Only what changed** is the largest saving: a change to one service
206+ deploys one service.
207+
208+## The workflow
209+
210+`.g1t/workflows/deploy.yml` runs on every push to `main`, and by hand
211+(**Actions → Deploy → Run workflow**) with `units` (deploy these, changed or
212+not), `all` and `dry_run` (plan only).
213+
214+| Job | Does | Needs |
215+| --- | --- | --- |
216+| `check` | `manifest --check` and `npm run test:deploy` | — |
217+| `plan` | `plan --github-output`: outputs per stage, the plan in the run's summary | `check` |
218+| `migrate` | `migrate --only <units with pending migrations>` | `plan`; skipped when none are pending |
219+| `core`, `edge`, `front` | `deploy --only <units> --force --no-migrations`, one job per build group | the stages before; skipped when empty |
220+
221+- **One at a time:** `concurrency: deploy-production`, never cancelled in
222+ progress; a second push waits.
223+- **Build groups:** a stage's units are split so each job shares a build:
224+ Rust workers at most four to a job (a sandbox has half a CPU), the
225+ TypeScript Workers together, each site alone, and a unit whose image must
226+ be rebuilt alone. `fail-fast: false`, so one failed job does not cut
227+ another off mid-upload; the next stage then does not start.
228+- **Tests:** there is no CI workflow on g1t yet; `main` is kept passing by
229+ the merge queue's checks. `check` runs the deploy tool's own tests. When a
230+ CI workflow is added, make `plan` wait for it (`workflow_run`, or a job in
231+ this file).
232+- **Caching** (`actions/cache`, kept per repository for 7 days, at most
233+ 60 MB an entry): the worker-build binary, worker-build's downloaded tools,
234+ and `~/.cargo/registry/cache`. Not cached: the Cargo target directory
235+ (about 500 MB for these crates) and npm's cache (over 150 MB), so every
236+ Rust job compiles its crates from scratch and every job runs `npm ci` of
237+ only what its units need (`deploy.mjs install`: Wrangler alone for Rust
238+ jobs).
239+- `crates/actions/tests/repository_workflows.rs` reads the workflow with
240+ g1t's own parser and expressions, and checks the jobs start, wait and
241+ stop as above (`cargo test -p g1t-actions --test repository_workflows`).
242+
243+### What the sandbox has
244+
245+The runner image (`services/runner/Dockerfile`) has Node 24, npm, git, and
246+Rust stable for the `node` user (rustfmt, clippy) but not the
247+`wasm32-unknown-unknown` target, worker-build or Docker. The workflow adds
248+the target (`rustup target add`, from `static.rust-lang.org`) and restores
249+worker-build from the cache, installing it on a miss. worker-build fetches
250+wasm-bindgen and wasm-opt from GitHub releases and esbuild from npm. All
251+of those hosts are on the list every workflow job may reach.
252+
253+Adding the wasm target and worker-build to the image instead would save
254+about a minute per Rust job, but every image change needs a Docker deploy of
255+the runner and replaces every sandbox, so it is left for when the image
256+next changes anyway.
257+
258+### Network
259+
260+A workflow job reaches only its project's allowed domains, g1t, and what
261+builds need (`services/runner/src/egress.ts`, `BUILD_HOSTS`). Cloudflare's
262+API is not among them for workflows (only for g1t.page deploy builds), and
263+guardrails have no per-workflow list. The least that works today: add
264+`api.cloudflare.com` to **flagon-io/g1t's allowed domains** (repository
265+**Settings → Guardrails**, Maintain role or higher).
266+
267+That opens the host to every sandbox of that project, agents included.
268+Agents never get the token (secrets go only to trusted workflow jobs), but
269+any code there could talk to Cloudflare's API with credentials of its own.
270+The better fix is a list of domains only workflow jobs may reach, set by a
271+maintainer, or hosts a job asks for honored only for trusted jobs; that is
272+a change to guardrails (`crates/contracts/src/guardrails.rs`, the work
273+service, the runner's `buildGuardFor`).
274+
275+### The API token
276+
277+Create it at **dash.cloudflare.com → My Profile → API Tokens → Create
278+Token → Custom token**, named `g1t deploys (CI)`:
279+
280+| Scope | Permission | Why |
281+| --- | --- | --- |
282+| Account | Workers Scripts: Edit | Upload, versions, deployments, crons, bindings, `secret list` (doctor) |
283+| Account | D1: Edit | `d1 migrations list` and `apply` |
284+| Account | Queues: Edit | Attaching each unit's queue consumers on deploy |
285+| Account | Workers R2 Storage: Read | Wrangler checks `og`'s bucket binding |
286+| Account | Account Settings: Read | Wrangler reads the account |
287+| Account | Containers: Read | The runner's deploy with `--containers-rollout none` reads its application (Edit only if CI ever builds images) |
288+| Zone (`g1t.sh`, `g1t.page`) | Workers Routes: Edit | `pages`' zone routes, and custom domains |
289+| Zone (`g1t.sh`, `g1t.page`) | DNS: Edit | Custom domains (`api`, `mcp`, `og`, `models`, `status`, `sudo`, `docs`, `g1t.sh`, `g1t.page`) keep their DNS records |
290+| Zone (`g1t.sh`, `g1t.page`) | Zone: Read | Finding the zone a route names |
291+
292+Restrict it to account `syntaqx` (`1e6f2cffa3f445920836e8ebe446bb58`) and
293+the two zones. Not needed for deploys: KV (bindings are by id; creating a
294+namespace is a one-time setup), Vectorize, Workers for Platforms beyond
295+Workers Scripts, Cloudflare for SaaS custom hostnames (the deployments
296+service does that at runtime with its own token), SSL and Certificates.
297+Workers KV Storage: Edit and Vectorize: Edit are only for first-time setup,
298+which stays a person's job.
299+
300+The list follows what our configs use; Cloudflare does not publish exactly
301+what `wrangler deploy` checks for each binding. Bindings with no listed
302+permission (Browser Rendering, Workers AI, Vectorize, Email Sending,
303+Artifacts, dispatch namespaces) are assumed to need none beyond Workers
304+Scripts. Verify on the first run with `workflow_dispatch` and `units:
305+pages` (small, no secrets), then `units: og` (R2) and `units: runner`
306+(Containers); a missing permission fails with `Authentication error [code:
307+10000]` and the route it was refused.
308+
309+Add it to the repository:
310+
311+1. On g1t.sh, open **flagon-io/g1t → Settings → Secrets and variables**.
312+2. **Add**: key `CLOUDFLARE_API_TOKEN`, type **Secret**, available to
313+ **Workflows**, environment **Production**. Only jobs with
314+ `environment: production` (the deploy jobs) can read it.
315+3. **Add**: key `CLOUDFLARE_ACCOUNT_ID`, type **Variable**, value
316+ `1e6f2cffa3f445920836e8ebe446bb58`, available to **Workflows**, all
317+ environments.
318+
319+Or through the API:
320+
321+```sh
322+curl -X PUT https://api.g1t.sh/repos/flagon-io/g1t/actions/secrets/CLOUDFLARE_API_TOKEN \
323+ -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
324+ -d '{"value":"<the token>","environments":["production"],"available_to":["workflows"]}'
325+
326+curl -X POST https://api.g1t.sh/repos/flagon-io/g1t/actions/variables \
327+ -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
328+ -d '{"name":"CLOUDFLARE_ACCOUNT_ID","value":"1e6f2cffa3f445920836e8ebe446bb58","available_to":["workflows"]}'
329+```
330+
331+## Turning it on
332+
333+1. Create the token and add the secret and variable (above).
334+2. Add `api.cloudflare.com` to flagon-io/g1t's allowed domains.
335+3. Adopt the live Workers once, from a laptop: deploy everything with the
336+ tool so each version records its commit (`scripts/deploy.sh`, or
337+ `node scripts/deploy.mjs deploy --all`). Until then every plan says "no
338+ known commit" and deploys every unit. To see what has changed since a
339+ commit you know production runs, without deploying:
340+ `node scripts/deploy.mjs plan --since <sha>`.
341+4. Run the workflow by hand with `dry_run`, then with `units: pages`.
342+
343+## First deploy of a new account
344+
345+What the configs refer to must exist first. `node scripts/deploy.mjs
346+manifest --json` lists, per unit, its queues, KV, R2, Vectorize and
347+dispatch namespaces; each unit's `setup` and `secrets` say the rest.
348+
349+- D1: `npx wrangler d1 create <database>`, then put its id in the unit's
350+ `wrangler.jsonc`.
351+- KV: `npx wrangler kv namespace create <name>` for each name under
352+ `resources.kv`, then the ids in the configs.
353+- Queues: `npx wrangler queues create <queue>` for each queue in the
354+ manifest: `g1t-events`, `g1t-events-<service>` for every subscriber,
355+ `g1t-search-jobs`, `g1t-context-jobs`.
356+- R2: `npx wrangler r2 bucket create g1t-screenshots`.
357+- Vectorize, dispatch namespace, DNS, Access, Email Sending, Artifacts: each
358+ unit's `setup`.
359+- Secrets: `npx wrangler secret put <NAME>` in the unit's folder;
360+ `node scripts/deploy.mjs doctor` lists what is missing.
361+
362+Then `node scripts/deploy.mjs deploy --all`. A Worker bound to a service
363+that does not exist yet may be refused; deploy that service first with
364+`--only`.
365+
366+## Rolling back
367+
368+- **One unit, at once:** `npx wrangler rollback` in its folder (or
369+ `npx wrangler rollback <version-id>`; `npx wrangler versions list` shows
370+ each version's commit in its message). Code only: D1 migrations are not
371+ undone. The next plan sees the older commit and deploys what changed since,
372+ so revert the commit on `main` too, or the next push brings it back.
373+- **To a commit:** check it out and `node scripts/deploy.mjs deploy --only
374+ <units> --force`. Migrations never run backwards: a migration that needs
375+ undoing is a new migration.
376+- **The runner's image:** a rollback of the Worker does not roll back the
377+ container image; redeploy the older commit with `--rebuild-image` on a
378+ machine with Docker.
379+
380+## Adding a unit
381+
382+1. Make its folder with a `wrangler.jsonc` (and its D1 migrations, if any).
383+ A Rust Worker is a workspace member in the root `Cargo.toml` with
384+ `"build": { "command": "node ../../scripts/build-rust-worker.mjs" }` and
385+ the `wasm-opt = ["-O1"]` metadata; a TypeScript one is an npm workspace.
386+2. Add it to `deploy/stack.jsonc`: path, kind, worker, `d1`, stage (the
387+ earliest stage after everything it binds to), secrets, setup, self_host.
388+ Name any new KV id under `resources.kv`.
389+3. If its sources import a file outside its folder that is not a workspace
390+ crate or package, list it under `inputs`.
391+4. Add a row to the service table in `docs/SELF_HOSTING.md`.
392+5. `npm run test:deploy` and `node scripts/deploy.mjs manifest --check`
393+ say what is missing. Then create its resources and secrets, and
394+ `node scripts/deploy.mjs deploy --only <unit>`.
+6−0
8888
8989 ### By service
9090
91+The deployable units, their stage and what each is built from are listed in
92+`deploy/stack.jsonc` (see `docs/DEPLOYING.md`); its `self_host` field is
93+what `deploy/self-host/configs.mjs` runs, turns off or leaves out. A test
94+checks this table names every unit.
95+
9196 | Service | Runs on | Cloudflare dependencies beyond Workers and D1 | Phase 1 self-hosted |
9297 | --- | --- | --- | --- |
9398 | `apps/web` | TS Worker plus assets | KV (`BLOBS`, `AVATARS`), Cache API, `cloudflare:workers` `env`, RPC to `RUNNER` | Runs unchanged |
9499 | `apps/api` | Rust Worker | KV `BLOBS`; hard-coded `api.g1t.sh`/`mcp.g1t.sh` issuer | Not started yet (phase 2) |
95100 | `apps/sudo` | TS Worker plus assets | Access JWT | Not run |
96101 | `apps/docs` | Static | — | Not run (docs.g1t.sh serves them) |
102+| `apps/status` | TS Worker | Email Sending, cron; bound only to billing | Runs in a process of its own (`status.sh`), so it stays up when the site does not |
97103 | `services/identity` | Rust | Email Sending, KV `AVATARS` | Runs unchanged; `EMAIL` goes to the mail shim |
98104 | `services/repos` | Rust | **Artifacts**, Cache API, optional KV `GIT_CACHE` with `REPOS_KEY` | Runs unchanged; `ARTIFACTS` goes to the git store. Without `GIT_CACHE` and `REPOS_KEY`, credentials and ref listings are kept per isolate only |
99105 | `services/work` | Rust | Queue consumer | Runs unchanged |
+2−1
99 ],
1010 "scripts": {
1111 "typecheck": "npm run typecheck --workspaces --if-present",
12− "deploy": "npm run deploy -w @g1t/web"
12+ "deploy": "npm run deploy -w @g1t/web",
13+ "test:deploy": "node --test \"scripts/deploy/*.test.mjs\""
1314 },
1415 "devDependencies": {
1516 "typescript": "^5.9.3",
+47−0
1+#!/usr/bin/env node
2+// Builds the Rust Worker in the current folder: the build command in each
3+// Rust unit's wrangler.jsonc, so `wrangler deploy`, `wrangler dev` and
4+// scripts/deploy.mjs all build the same way.
5+//
6+// worker-build is installed only when it is missing or another version:
7+// `cargo install` on every build cost a crates.io index check each time,
8+// and a full compile on a fresh machine. Every Rust Worker shares the
9+// workspace's Cargo target directory, so builds running side by side take
10+// turns on Cargo's lock while their wasm-bindgen and wasm-opt steps
11+// overlap. How hard wasm-opt works is each crate's
12+// [package.metadata.wasm-pack.profile.release] (docs/DEPLOYING.md).
13+//
14+// node ../../scripts/build-rust-worker.mjs [worker-build args]
15+// node scripts/build-rust-worker.mjs --ensure # install only
16+
17+import { spawnSync } from "node:child_process";
18+
19+export const WORKER_BUILD_VERSION = "0.8.7";
20+
21+const run = (command, args, options = {}) =>
22+ spawnSync(command, args, { stdio: "inherit", shell: process.platform === "win32", ...options });
23+
24+/** The installed worker-build's version, or null. */
25+export function installedVersion() {
26+ const found = spawnSync("worker-build", ["--version"], { encoding: "utf8", shell: process.platform === "win32" });
27+ return found.status === 0 ? found.stdout.trim() : null;
28+}
29+
30+/** Installs the pinned worker-build unless it is already there. */
31+export function ensureWorkerBuild() {
32+ const version = installedVersion();
33+ if (version === WORKER_BUILD_VERSION) return;
34+ console.error(`worker-build ${version ?? "is missing"}; installing ${WORKER_BUILD_VERSION}`);
35+ const installed = run("cargo", ["install", "-q", "--locked", `worker-build@${WORKER_BUILD_VERSION}`, "--force"]);
36+ if (installed.status !== 0) process.exit(installed.status ?? 1);
37+}
38+
39+// Run, not imported (scripts/deploy.mjs imports ensureWorkerBuild).
40+if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/build-rust-worker.mjs")) {
41+ const args = process.argv.slice(2);
42+ ensureWorkerBuild();
43+ if (!args.includes("--ensure")) {
44+ const built = run("worker-build", ["--release", ...args]);
45+ process.exit(built.status ?? 1);
46+ }
47+}
+423−0
1+#!/usr/bin/env node
2+// Deploys g1t to Cloudflare from deploy/stack.jsonc: only what changed since
3+// each Worker's live commit, migrations first, then stage by stage.
4+// docs/DEPLOYING.md is the guide.
5+//
6+// node scripts/deploy.mjs plan what would deploy, and why (read-only)
7+// node scripts/deploy.mjs deploy migrations, then every changed unit
8+// node scripts/deploy.mjs deploy --only web,api just these (if changed; --force: anyway)
9+// node scripts/deploy.mjs build --only events build as a deploy would, upload nothing
10+// node scripts/deploy.mjs migrate pending D1 migrations only
11+// node scripts/deploy.mjs manifest [--check] the resolved manifest, or its problems
12+// node scripts/deploy.mjs doctor which units lack their secrets
13+// node scripts/deploy.mjs install --only a,b npm ci of just what those units need (CI)
14+//
15+// Flags: --all (every unit, changed or not), --only a,b, --skip a,b,
16+// --force, --concurrency N (default 4), --stage core (one stage),
17+// --json (plan), --out FILE (plan), --no-migrations, --allow-dirty,
18+// --rebuild-image, --since REV (Workers with no recorded commit are taken
19+// to run REV).
20+
21+import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
22+import { tmpdir } from "node:os";
23+import { join } from "node:path";
24+
25+import { ensureWorkerBuild } from "./build-rust-worker.mjs";
26+import {
27+ annotation,
28+ applyMigrations,
29+ dockerAvailable,
30+ exec,
31+ jsonFrom,
32+ lastLines,
33+ pendingMigrations,
34+ readLive,
35+ versionFrom,
36+ wrangler,
37+ wranglerEnv,
38+} from "./deploy/cloudflare.mjs";
39+import { decide, git, planJson, pool, table } from "./deploy/plan.mjs";
40+import { ROOT, byStage, codeStages, findWranglerConfigs, npmCiArgs, npmWorkspace, pick, problems, resolvedStack } from "./deploy/stack.mjs";
41+
42+const USAGE = "usage: node scripts/deploy.mjs plan|deploy|build|migrate|manifest|doctor [--all] [--only a,b] [--skip a,b] [--force] [--concurrency N] [--stage S] [--json]";
43+
44+function parseArgs(argv) {
45+ const opts = { command: argv[0], only: [], skip: [], concurrency: 4, force: false, all: false, json: false };
46+ for (let i = 1; i < argv.length; i++) {
47+ const arg = argv[i];
48+ const [flag, inline] = arg.split(/=(.*)/s);
49+ const value = () => inline ?? argv[++i];
50+ if (flag === "--only") opts.only.push(value());
51+ else if (flag === "--skip") opts.skip.push(value());
52+ else if (flag === "--concurrency") opts.concurrency = Number(value());
53+ else if (flag === "--stage") opts.stage = value();
54+ else if (flag === "--all") opts.all = true;
55+ else if (flag === "--force") opts.force = true;
56+ else if (flag === "--json") opts.json = true;
57+ else if (flag === "--check") opts.check = true;
58+ else if (flag === "--no-migrations") opts.noMigrations = true;
59+ else if (flag === "--allow-dirty") opts.allowDirty = true;
60+ else if (flag === "--rebuild-image") opts.rebuildImage = true;
61+ else if (flag === "--out") opts.out = value();
62+ else if (flag === "--since") opts.since = value();
63+ else if (flag === "--github-output") opts.githubOutput = true;
64+ else throw new Error(`unknown flag ${arg}\n${USAGE}`);
65+ }
66+ if (!Number.isInteger(opts.concurrency) || opts.concurrency < 1) throw new Error("--concurrency is a whole number, 1 or more");
67+ return opts;
68+}
69+
70+/** The units a command works on: --only (or all), less --skip, in --stage. */
71+function selected(stack, opts) {
72+ let units = opts.only.length ? pick(stack, opts.only) : [...stack.units];
73+ const skipped = pick(stack, opts.skip);
74+ units = units.filter((u) => !skipped.includes(u));
75+ if (opts.stage) {
76+ if (!codeStages(stack).includes(opts.stage)) throw new Error(`--stage is one of ${codeStages(stack).join(", ")}`);
77+ units = units.filter((u) => u.stage === opts.stage);
78+ }
79+ // Manifest order, whatever order they were named in.
80+ return stack.units.filter((u) => units.includes(u));
81+}
82+
83+const log = (...args) => console.error(...args);
84+
85+/**
86+ * The plan for a workflow (.g1t/workflows/deploy.yml): job outputs in
87+ * $GITHUB_OUTPUT, and the plan as a table in $GITHUB_STEP_SUMMARY.
88+ */
89+function writeGithubOutputs(data, p) {
90+ const lines = [
91+ `commit=${data.commit}`,
92+ `migrate=${data.migrations.length > 0}`,
93+ `migrate_units=${data.migrations.map((m) => m.unit).join(",")}`,
94+ `deploying=${data.units.filter((u) => u.deploy).map((u) => u.unit).join(",")}`,
95+ ];
96+ for (const [stage, { jobs }] of Object.entries(data.stages)) {
97+ // A matrix needs one entry; an empty stage's job is skipped by its `if`.
98+ const include = jobs.length ? jobs : [{ group: "none", units: "" }];
99+ lines.push(`has_${stage}=${jobs.length > 0}`, `${stage}=${JSON.stringify({ include })}`);
100+ }
101+ if (data.migration_errors.length) throw new Error(`Could not read pending migrations: ${data.migration_errors.map((e) => e.unit).join(", ")}`);
102+ if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
103+ else console.log(lines.join("\n"));
104+ if (process.env.GITHUB_STEP_SUMMARY) {
105+ const rows = p.decisions.map((d) => `| ${d.unit.id} | ${d.unit.stage} | ${d.deploy ? "deploy" : ""} | ${d.since ? d.since.slice(0, 12) : "?"} | ${d.reason.replaceAll("|", "\\|")} |`);
106+ const pending = data.migrations.map((m) => `- ${m.database}: ${m.pending.join(", ")}`);
107+ appendFileSync(
108+ process.env.GITHUB_STEP_SUMMARY,
109+ [`## Deploy plan for ${data.commit.slice(0, 12)}`, "", "| unit | stage | action | live | why |", "| --- | --- | --- | --- | --- |", ...rows, "", pending.length ? "### Pending migrations" : "", ...pending, ""].join("\n"),
110+ );
111+ }
112+}
113+
114+const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
115+
116+/** Reads what each unit runs and what its database is waiting for. */
117+async function survey(units, opts) {
118+ const live = {};
119+ const migrations = {};
120+ const tasks = [
121+ ...(opts.noLive ? [] : units).map((unit) => async () => {
122+ live[unit.id] = await readLive(unit);
123+ }),
124+ ...(opts.noMigrations ? [] : units.filter((u) => u.d1)).map((unit) => async () => {
125+ migrations[unit.id] = await pendingMigrations(unit);
126+ }),
127+ ];
128+ await pool(tasks, Math.max(8, opts.concurrency * 2), (task) => task());
129+ return { live, migrations };
130+}
131+
132+async function plan(stack, opts) {
133+ const units = selected(stack, opts);
134+ const head = git.head();
135+ const { live, migrations } = await survey(units, opts);
136+ // --since: what a Worker with no recorded commit is taken to run (once,
137+ // to adopt Workers deployed before this tool), for example --since HEAD~3.
138+ if (opts.since) {
139+ const since = git.resolve(opts.since);
140+ for (const unit of units) {
141+ const found = live[unit.id];
142+ if (found && !found.sha && !found.missing && !found.error) live[unit.id] = { ...found, sha: since, assumed: true };
143+ }
144+ }
145+ const decisions = decide(units, { live, head, force: opts.force || opts.all });
146+ return { head, units, live, migrations, decisions };
147+}
148+
149+function buildPlan(stack, opts) {
150+ const units = selected(stack, opts);
151+ return {
152+ head: git.head(),
153+ units,
154+ live: {},
155+ migrations: {},
156+ decisions: units.map((unit) => ({ unit, deploy: true, reason: "build", since: null, files: [], image: false })),
157+ };
158+}
159+
160+function printPlan(stack, { head, decisions, migrations, live }) {
161+ console.log(`Deploying ${head.slice(0, 12)} (${git.subject()})\n`);
162+ const rows = decisions.map((d) => [
163+ d.unit.id,
164+ d.unit.stage,
165+ d.deploy ? "deploy" : "-",
166+ d.since ? d.since.slice(0, 12) + (live[d.unit.id]?.assumed ? "*" : "") : "?",
167+ d.unit.d1 ? (migrations[d.unit.id]?.error ? "error" : String(migrations[d.unit.id]?.pending?.length ?? "-")) : "",
168+ d.reason + (d.image ? "; image rebuilds" : ""),
169+ ]);
170+ console.log(table(rows, ["unit", "stage", "action", "live", "migrations", "why"]));
171+ if (decisions.some((d) => live[d.unit.id]?.assumed)) console.log("* taken from --since: no commit was recorded for it");
172+ const pending = Object.entries(migrations).filter(([, m]) => m.pending?.length);
173+ if (pending.length) {
174+ console.log("\nPending migrations:");
175+ for (const [id, m] of pending) console.log(` ${stack.units.find((u) => u.id === id).d1.database}: ${m.pending.join(", ")}`);
176+ }
177+ for (const [id, m] of Object.entries(migrations).filter(([, m]) => m.error)) {
178+ console.log(`\nCould not list ${id}'s migrations:\n${m.error}`);
179+ }
180+ const deploying = decisions.filter((d) => d.deploy).map((d) => d.unit);
181+ console.log(
182+ deploying.length
183+ ? `\n${deploying.length} to deploy: ${byStage(stack, deploying).map((g) => `${g.stage} (${g.units.map((u) => u.id).join(", ")})`).join(" -> ")}`
184+ : "\nNothing to deploy.",
185+ );
186+}
187+
188+/** Output of one unit, prefixed, to the terminal and a log file. */
189+function unitLogger(id) {
190+ const dir = join(tmpdir(), "g1t-deploy");
191+ mkdirSync(dir, { recursive: true });
192+ const file = join(dir, `${id}.log`);
193+ const lines = [];
194+ return {
195+ file,
196+ line: (text) => {
197+ lines.push(text);
198+ if (text.trim()) log(`[${id}] ${text}`);
199+ },
200+ save: () => writeFileSync(file, `${lines.join("\n")}\n`),
201+ };
202+}
203+
204+/** Builds (and unless `dryRun`, deploys) one unit. */
205+async function ship(unit, decision, { head, subject, dirty, dryRun, docker, rebuildImage }) {
206+ const started = Date.now();
207+ const out = unitLogger(unit.id);
208+ const cwd = join(ROOT, unit.path);
209+ const result = { unit: unit.id, stage: unit.stage, ok: false, version: null, ms: 0, note: "" };
210+ try {
211+ if (unit.kind === "react-router" || unit.kind === "astro") {
212+ const built = await exec("npm", ["run", "build"], { cwd, onLine: out.line, shell: true, env: wranglerEnv() });
213+ if (built.code !== 0) throw new Error(`npm run build failed:\n${lastLines(built.out)}`);
214+ }
215+ const args = ["deploy"];
216+ if (dryRun) {
217+ args.push("--dry-run", "--outdir", join(tmpdir(), "g1t-deploy", "dist", unit.id));
218+ } else {
219+ const { message, tag } = annotation(head, subject);
220+ args.push("--message", dirty ? message.replace(/^g1t-deploy/, "g1t-deploy-dirty") : message, "--tag", tag);
221+ }
222+ if (unit.image) {
223+ const build = rebuildImage || decision.image;
224+ if (!build) {
225+ args.push("--containers-rollout", "none");
226+ result.note = "image unchanged: not rebuilt";
227+ } else if (!docker) {
228+ throw new Error(
229+ "its Containers image changed, and Docker is not available here. Deploy it from a machine with Docker: node scripts/deploy.mjs deploy --only " +
230+ unit.id,
231+ );
232+ } else {
233+ result.note = "image rebuilt";
234+ }
235+ }
236+ const deployed = await wrangler(args, { cwd, onLine: out.line });
237+ if (deployed.code !== 0) throw new Error(`wrangler deploy failed:\n${lastLines(deployed.out)}`);
238+ result.version = dryRun ? "(dry run)" : versionFrom(deployed.out);
239+ result.ok = true;
240+ } catch (error) {
241+ result.note = String(error.message ?? error);
242+ }
243+ result.ms = Date.now() - started;
244+ out.save();
245+ if (!result.ok) result.note += `\n full log: ${out.file}`;
246+ return result;
247+}
248+
249+async function migrate(stack, units, migrations, opts) {
250+ const due = units.filter((u) => migrations[u.id]?.pending?.length);
251+ const broken = units.filter((u) => migrations[u.id]?.error);
252+ if (broken.length) {
253+ throw new Error(`Could not read pending migrations for ${broken.map((u) => u.id).join(", ")}; nothing was deployed.`);
254+ }
255+ if (!due.length) return [];
256+ log(`== migrations: ${due.map((u) => `${u.d1.database} (${migrations[u.id].pending.length})`).join(", ")}`);
257+ const results = await pool(due, opts.concurrency, async (unit) => {
258+ const started = Date.now();
259+ const out = unitLogger(`${unit.id}-migrations`);
260+ const applied = await applyMigrations(unit, out.line);
261+ out.save();
262+ return {
263+ unit: `${unit.id} (D1 ${unit.d1.database})`,
264+ stage: "migrations",
265+ ok: applied.code === 0,
266+ version: `${migrations[unit.id].pending.length} applied`,
267+ ms: Date.now() - started,
268+ note: applied.code === 0 ? "" : `${lastLines(applied.out)}\n full log: ${out.file}`,
269+ };
270+ });
271+ return results;
272+}
273+
274+function summary(results) {
275+ const rows = results.map((r) => [r.unit, r.stage, r.ok ? "ok" : r.skipped ? "not started" : "FAILED", r.version ?? "", r.ms ? seconds(r.ms) : "", r.note.split("\n")[0]]);
276+ console.log(`\n${table(rows, ["unit", "stage", "result", "version", "time", "note"])}`);
277+ for (const r of results.filter((r) => !r.ok && !r.skipped)) console.log(`\n${r.unit}: ${r.note}`);
278+}
279+
280+async function deploy(stack, opts, { dryRun = false } = {}) {
281+ const started = Date.now();
282+ // A build reads nothing from Cloudflare: it builds what it is given.
283+ const p = dryRun ? buildPlan(stack, opts) : await plan(stack, opts);
284+ if (!dryRun) printPlan(stack, p);
285+ const deploying = p.decisions.filter((d) => d.deploy);
286+ const touched = deploying.map((d) => d.unit);
287+ const head = p.head;
288+
289+ // A deploy names the commit it came from, so a dirty tree would be
290+ // recorded as something it is not.
291+ const dirtyFiles = git.dirty();
292+ const dirty = deploying.some((d) => dirtyFiles.some((file) => file.startsWith(`${d.unit.path}/`) || d.unit.dependsOn.some((dir) => file.startsWith(`${dir}/`)) || d.unit.inputs.includes(file)));
293+ if (dirty && !dryRun && !opts.allowDirty) {
294+ throw new Error("Uncommitted changes touch what would deploy. Commit them, or pass --allow-dirty (the deploy then records no commit, and the next plan deploys it again).");
295+ }
296+
297+ const results = [];
298+ if (!dryRun && !opts.noMigrations) {
299+ const migrated = await migrate(stack, p.units, p.migrations, opts);
300+ results.push(...migrated);
301+ if (migrated.some((r) => !r.ok)) {
302+ summary(results);
303+ return false;
304+ }
305+ }
306+ if (!touched.length) {
307+ if (results.length) summary(results);
308+ return true;
309+ }
310+
311+ if (touched.some((u) => u.kind === "rust-worker")) ensureWorkerBuild();
312+ const docker = touched.some((u) => u.image) ? await dockerAvailable() : false;
313+ const context = { head, subject: git.subject(), dirty, dryRun, docker, rebuildImage: opts.rebuildImage };
314+
315+ let failed = false;
316+ for (const { stage, units } of byStage(stack, touched)) {
317+ if (failed) {
318+ for (const unit of units) results.push({ unit: unit.id, stage, ok: false, skipped: true, ms: 0, note: "an earlier stage failed" });
319+ continue;
320+ }
321+ log(`== ${stage}: ${units.map((u) => u.id).join(", ")}`);
322+ const shipped = await pool(units, opts.concurrency, (unit) => ship(unit, deploying.find((d) => d.unit === unit), context));
323+ results.push(...shipped);
324+ failed = shipped.some((r) => !r.ok);
325+ }
326+ summary(results);
327+ console.log(`\n${failed ? "Failed" : dryRun ? "Built" : "Deployed"} in ${seconds(Date.now() - started)}.`);
328+ return !failed;
329+}
330+
331+async function doctor(stack, opts) {
332+ const units = selected(stack, opts);
333+ const rows = await pool(units, 8, async (unit) => {
334+ if (!unit.secrets.length) return [unit.id, "", "", ""];
335+ const found = await wrangler(["secret", "list", "--name", unit.worker, "--format", "json"], { cwd: join(ROOT, unit.path) });
336+ if (found.code !== 0) return [unit.id, unit.secrets.join(" "), "?", "could not read"];
337+ const have = new Set(jsonFrom(found.out).map((s) => s.name));
338+ const missing = unit.secrets.filter((name) => !have.has(name));
339+ return [unit.id, unit.secrets.join(" "), missing.join(" "), missing.length ? "missing" : "ok"];
340+ });
341+ console.log(table(rows, ["unit", "secrets", "missing", "result"]));
342+ return rows.every((r) => r[3] !== "missing");
343+}
344+
345+async function install(stack, opts) {
346+ const units = selected(stack, opts);
347+ const args = npmCiArgs(units, npmWorkspace());
348+ log(`npm ${args.join(" ")}`);
349+ const done = await exec("npm", args, { cwd: ROOT, shell: true, onLine: (line) => log(line) });
350+ return done.code === 0;
351+}
352+
353+function manifest(stack, opts) {
354+ const found = problems(stack, findWranglerConfigs());
355+ if (opts.check) {
356+ for (const problem of found) console.log(`- ${problem}`);
357+ console.log(found.length ? `\n${found.length} problems in deploy/stack.jsonc.` : "deploy/stack.jsonc is consistent with every wrangler.jsonc.");
358+ return !found.length;
359+ }
360+ const out = stack.units.map(({ config, ...unit }) => ({
361+ ...unit,
362+ queues: { produces: (config?.queues?.producers ?? []).map((q) => q.queue), consumes: (config?.queues?.consumers ?? []).map((q) => q.queue) },
363+ kv: (config?.kv_namespaces ?? []).map((kv) => stack.resources.kv?.[kv.id] ?? kv.id),
364+ r2: (config?.r2_buckets ?? []).map((b) => b.bucket_name),
365+ vectorize: (config?.vectorize ?? []).map((v) => v.index_name),
366+ dispatch_namespaces: (config?.dispatch_namespaces ?? []).map((d) => d.namespace),
367+ routes: (config?.routes ?? []).map((r) => r.pattern),
368+ }));
369+ if (opts.json) console.log(JSON.stringify({ stages: stack.stages, units: out }, null, 2));
370+ else
371+ console.log(
372+ table(
373+ out.map((u) => [u.id, u.stage, u.kind, u.worker, u.d1?.database ?? "", u.dependsOn.join(" ")]),
374+ ["unit", "stage", "kind", "worker", "d1", "built from (besides its folder)"],
375+ ),
376+ );
377+ return true;
378+}
379+
380+async function main() {
381+ const opts = parseArgs(process.argv.slice(2));
382+ const stack = resolvedStack();
383+ switch (opts.command) {
384+ case "plan": {
385+ const p = await plan(stack, opts);
386+ const data = planJson(stack, p.decisions, p.migrations, p.head);
387+ if (opts.out) writeFileSync(opts.out, `${JSON.stringify(data)}\n`);
388+ if (opts.githubOutput) writeGithubOutputs(data, p);
389+ if (opts.json) console.log(JSON.stringify(data, null, 2));
390+ else if (!opts.githubOutput || process.env.GITHUB_OUTPUT) printPlan(stack, p);
391+ return true;
392+ }
393+ case "deploy":
394+ return deploy(stack, opts);
395+ case "build":
396+ return deploy(stack, { ...opts, force: true }, { dryRun: true });
397+ case "migrate": {
398+ const units = selected(stack, opts);
399+ const { migrations } = await survey(units.filter((u) => u.d1), { ...opts, noMigrations: false, noLive: true });
400+ const results = await migrate(stack, units, migrations, opts);
401+ if (results.length) summary(results);
402+ else console.log("No migrations to apply.");
403+ return results.every((r) => r.ok);
404+ }
405+ case "manifest":
406+ return manifest(stack, opts);
407+ case "doctor":
408+ return doctor(stack, opts);
409+ case "install":
410+ return install(stack, opts);
411+ default:
412+ console.error(USAGE);
413+ return false;
414+ }
415+}
416+
417+main().then(
418+ (ok) => process.exit(ok ? 0 : 1),
419+ (error) => {
420+ console.error(`\n${error.message ?? error}`);
421+ process.exit(1);
422+ },
423+);
+11−57
11 #!/usr/bin/env bash
2−# Deploys g1t: every part, or the ones named, in the order they depend on
3−# each other. Each part's D1 migrations are applied before its code goes
4−# out, so new code never meets an old database.
2+# Deploys g1t: every part, or the ones named, migrations first and then
3+# stage by stage, as deploy/stack.jsonc orders them. A thin wrapper over
4+# scripts/deploy.mjs, which can also deploy only what changed:
55 #
66 # scripts/deploy.sh # everything
77 # scripts/deploy.sh billing web # just these, still in order
8+# node scripts/deploy.mjs plan # what changed since each part's live commit
9+# node scripts/deploy.mjs deploy # only that
810 #
911 # Uses your `wrangler login`. The repository's .env may hold a token for
1012 # other tools; it is ignored here unless you set CLOUDFLARE_DEPLOY_TOKEN.
13+# See docs/DEPLOYING.md.
1114 set -euo pipefail
1215
1316 ROOT="$(cd "$(dirname "$0")/.." && pwd)"
14−export CLOUDFLARE_API_TOKEN="${CLOUDFLARE_DEPLOY_TOKEN:-}"
15−
16−# Dependency order: what others bind to goes first.
17−ORDER=(
18− services/events
19− services/identity
20− services/repos
21− services/work
22− services/search
23− services/projects
24− services/billing
25− services/integrations
26− services/webhooks
27− services/actions
28− services/models
29− services/deployments
30− services/runner
31− services/security
32− services/context
33− apps/api
34− services/pages
35− services/og
36− # The status page: binds only to billing, checks the rest over the internet.
37− apps/status
38− apps/web
39− apps/sudo
40− apps/docs
41−)
42−
43−wanted() {
44− [ $# -eq 0 ] && return 0
45− local dir="$1"; shift
46− for name in "${PICK[@]}"; do
47− [ "$dir" = "$name" ] || [ "$(basename "$dir")" = "$name" ] && return 0
48− done
49− return 1
50−}
51−
52−PICK=("$@")
53−for dir in "${ORDER[@]}"; do
54− [ ${#PICK[@]} -eq 0 ] || wanted "$dir" || continue
55− echo "== $dir"
56− cd "$ROOT/$dir"
57− db=$(grep -o '"database_name": *"[^"]*"' wrangler.jsonc 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true)
58− if [ -n "$db" ] && [ -d migrations ]; then
59− npx wrangler d1 migrations apply "$db" --remote
60− fi
61− if grep -q '"deploy": "[^"]*build' package.json 2>/dev/null; then
62− npm run deploy
63− else
64− npx wrangler deploy
65− fi
66−done
67−echo "== Deployed."
17+if [ $# -eq 0 ]; then
18+ exec node "$ROOT/scripts/deploy.mjs" deploy --all
19+fi
20+names=$(IFS=,; echo "$*")
21+exec node "$ROOT/scripts/deploy.mjs" deploy --force --only "$names"
+171−0
1+// Wrangler, as the deploy tool uses it: reading which commit each Worker
2+// runs, D1 migrations, and deploying. Every call runs in the unit's own
3+// folder, so Wrangler reads that unit's config (and not a .env at the
4+// repository root, which may hold a token meant for something else).
5+
6+import { spawn } from "node:child_process";
7+import { join } from "node:path";
8+
9+import { ROOT } from "./stack.mjs";
10+
11+const WRANGLER = join(ROOT, "node_modules/wrangler/bin/wrangler.js");
12+export const ACCOUNT_ID = "1e6f2cffa3f445920836e8ebe446bb58";
13+
14+/** What a deploy's version message starts with, followed by the commit. */
15+export const MESSAGE_PREFIX = "g1t-deploy";
16+
17+/**
18+ * The environment Wrangler runs with. In CI (CI=true) it is the job's:
19+ * CLOUDFLARE_API_TOKEN from the repository's secret. On a laptop it is
20+ * your `wrangler login`, unless CLOUDFLARE_DEPLOY_TOKEN is set, as
21+ * scripts/deploy.sh always did: a CLOUDFLARE_API_TOKEN or global API key
22+ * in your shell is for other tools.
23+ */
24+export function wranglerEnv(base = process.env) {
25+ const env = { ...base, WRANGLER_SEND_METRICS: "false", NO_COLOR: "1", FORCE_COLOR: "0" };
26+ env.CLOUDFLARE_ACCOUNT_ID ||= ACCOUNT_ID;
27+ if (base.CLOUDFLARE_DEPLOY_TOKEN) {
28+ env.CLOUDFLARE_API_TOKEN = base.CLOUDFLARE_DEPLOY_TOKEN;
29+ } else if (base.CI !== "true") {
30+ env.CLOUDFLARE_API_TOKEN = "";
31+ delete env.CLOUDFLARE_API_KEY;
32+ delete env.CLOUDFLARE_EMAIL;
33+ }
34+ return env;
35+}
36+
37+/**
38+ * Runs a command; resolves with { code, out } (stdout and stderr together,
39+ * in order). `onLine` sees each line as it comes.
40+ */
41+export function exec(command, args, { cwd = ROOT, env = process.env, onLine, shell = false } = {}) {
42+ return new Promise((resolve) => {
43+ const child = spawn(command, args, { cwd, env, shell, windowsHide: true });
44+ let out = "";
45+ let partial = "";
46+ const take = (chunk) => {
47+ const text = chunk.toString();
48+ out += text;
49+ if (!onLine) return;
50+ const lines = (partial + text).split(/\r?\n/);
51+ partial = lines.pop();
52+ for (const line of lines) onLine(line);
53+ };
54+ child.stdout.on("data", take);
55+ child.stderr.on("data", take);
56+ child.on("error", (error) => resolve({ code: 127, out: `${out}${error.message}\n` }));
57+ child.on("close", (code) => {
58+ if (onLine && partial) onLine(partial);
59+ resolve({ code: code ?? 1, out });
60+ });
61+ });
62+}
63+
64+/** Runs the repository's own Wrangler in `cwd`. */
65+export function wrangler(args, { cwd, env = wranglerEnv(), onLine } = {}) {
66+ return exec(process.execPath, [WRANGLER, ...args], { cwd, env, onLine });
67+}
68+
69+/** The first JSON value in Wrangler's output (it may print notices first). */
70+export function jsonFrom(out) {
71+ const start = out.search(/^[[{]/m);
72+ if (start < 0) throw new Error(`no JSON in: ${out.slice(0, 300)}`);
73+ return JSON.parse(out.slice(start));
74+}
75+
76+/** The message and tag a deploy of `sha` is annotated with. */
77+export function annotation(sha, subject = "") {
78+ const message = `${MESSAGE_PREFIX} ${sha} ${subject}`.trim().slice(0, 100);
79+ return { message, tag: `g1t-${sha.slice(0, 12)}` };
80+}
81+
82+/** The commit a version message names, or null. Dirty deploys name none. */
83+export function commitFrom(message) {
84+ const match = new RegExp(`^${MESSAGE_PREFIX} ([0-9a-f]{40})(?:\\s|$)`).exec(message ?? "");
85+ return match ? match[1] : null;
86+}
87+
88+/**
89+ * Which commit a Worker's live version was deployed from, given Wrangler's
90+ * `deployments status --json` and `versions list --json`. A version made by
91+ * `wrangler secret put` keeps the code of the one before it, so those are
92+ * looked through. Anything else without our message (a deploy by hand, a
93+ * dashboard edit) leaves the commit unknown, and the unit is deployed again.
94+ */
95+export function liveCommit(status, versions) {
96+ const live = [...(status.versions ?? [])].sort((a, b) => b.percentage - a.percentage);
97+ if (!live.length) return { sha: null, why: "no live version" };
98+ const split = live.length > 1 && live[1].percentage > 0;
99+ const byNumber = [...versions].sort((a, b) => b.number - a.number);
100+ let index = byNumber.findIndex((v) => v.id === live[0].version_id);
101+ if (index < 0) return { sha: null, why: "its live version is not among the recent ones", version: live[0].version_id };
102+ const version = byNumber[index];
103+ while (index < byNumber.length) {
104+ const candidate = byNumber[index];
105+ const sha = commitFrom(candidate.annotations?.["workers/message"]);
106+ if (sha) {
107+ return {
108+ sha,
109+ version: version.id,
110+ at: candidate.metadata?.created_on ?? null,
111+ by: candidate.metadata?.author_email ?? null,
112+ split,
113+ why: split ? "a gradual deployment is in progress; its main version is used" : null,
114+ };
115+ }
116+ if (candidate.annotations?.["workers/triggered_by"] !== "secret") break;
117+ index++;
118+ }
119+ return { sha: null, version: version.id, why: "its live version was not deployed by scripts/deploy.mjs" };
120+}
121+
122+/** Reads the commit a unit's Worker runs. Never throws. */
123+export async function readLive(unit) {
124+ const cwd = join(ROOT, unit.path);
125+ const [status, versions] = await Promise.all([
126+ wrangler(["deployments", "status", "--name", unit.worker, "--json"], { cwd }),
127+ wrangler(["versions", "list", "--name", unit.worker, "--json"], { cwd }),
128+ ]);
129+ if (status.code !== 0) {
130+ if (/not found|does not exist|10007/i.test(status.out)) return { sha: null, missing: true, why: "never deployed" };
131+ return { sha: null, error: lastLines(status.out) };
132+ }
133+ try {
134+ return liveCommit(jsonFrom(status.out), versions.code === 0 ? jsonFrom(versions.out) : []);
135+ } catch (error) {
136+ return { sha: null, error: String(error.message ?? error) };
137+ }
138+}
139+
140+/** Migration files Wrangler lists as not yet applied. */
141+export function pendingFrom(out) {
142+ if (/No migrations to apply/i.test(out)) return [];
143+ const names = [...out.matchAll(/([\w.-]+\.sql)\b/g)].map((m) => m[1]);
144+ return [...new Set(names)];
145+}
146+
147+/** Pending migrations of a unit's database: { pending } or { error }. */
148+export async function pendingMigrations(unit) {
149+ const found = await wrangler(["d1", "migrations", "list", unit.d1.database, "--remote"], { cwd: join(ROOT, unit.path) });
150+ if (found.code !== 0) return { error: lastLines(found.out) };
151+ return { pending: pendingFrom(found.out) };
152+}
153+
154+export function applyMigrations(unit, onLine) {
155+ return wrangler(["d1", "migrations", "apply", unit.d1.database, "--remote"], { cwd: join(ROOT, unit.path), onLine });
156+}
157+
158+/** The version a deploy made, from Wrangler's output. */
159+export function versionFrom(out) {
160+ return /Current Version ID:\s*([0-9a-f-]{36})/i.exec(out)?.[1] ?? null;
161+}
162+
163+/** Whether Docker can build here (for a Containers image). */
164+export async function dockerAvailable() {
165+ const found = await exec("docker", ["info", "--format", "{{.ServerVersion}}"]);
166+ return found.code === 0;
167+}
168+
169+export function lastLines(text, count = 12) {
170+ return text.trim().split(/\r?\n/).slice(-count).join("\n");
171+}
+409−0
1+// node --test "scripts/deploy/*.test.mjs" (npm run test:deploy)
2+import assert from "node:assert/strict";
3+import { execFileSync } from "node:child_process";
4+import { readFileSync } from "node:fs";
5+import { join } from "node:path";
6+import { test } from "node:test";
7+
8+import { annotation, commitFrom, liveCommit, pendingFrom, versionFrom } from "./cloudflare.mjs";
9+import { changedNames, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs";
10+import { decide, planJson, pool } from "./plan.mjs";
11+import {
12+ ROOT,
13+ buildGroups,
14+ cargoWorkspace,
15+ findWranglerConfigs,
16+ loadStack,
17+ npmCiArgs,
18+ npmWorkspace,
19+ outsideImports,
20+ pick,
21+ problems,
22+ resolveStack,
23+ resolvedStack,
24+ touches,
25+ touchesImage,
26+} from "./stack.mjs";
27+
28+const stack = resolvedStack();
29+const unit = (id) => stack.units.find((u) => u.id === id);
30+
31+// ── The manifest ──────────────────────────────────────────────────────────
32+
33+test("the manifest matches every wrangler config in the repository", () => {
34+ assert.deepEqual(problems(stack, findWranglerConfigs()), []);
35+});
36+
37+test("every wrangler.jsonc is found, and only checked-in ones", () => {
38+ const configs = findWranglerConfigs();
39+ assert.ok(configs.includes("services/events/wrangler.jsonc"));
40+ assert.ok(configs.includes("apps/web/wrangler.jsonc"));
41+ assert.ok(!configs.some((c) => c.includes("/build/")), "build output is not a unit");
42+ assert.equal(configs.length, stack.units.length);
43+});
44+
45+test("problems are found: an unlisted config, a later-stage binding, a wrong database, an unnamed KV", () => {
46+ const broken = loadStack();
47+ const events = broken.units.find((u) => u.id === "events");
48+ events.stage = "front";
49+ const identity = broken.units.find((u) => u.id === "identity");
50+ identity.d1 = { database: "g1t-identity", migrations: "migrations" };
51+ broken.resources = { kv: {} };
52+ const found = problems(broken, [...findWranglerConfigs(), "services/new/wrangler.jsonc"]);
53+ assert.ok(found.some((p) => p.includes("services/new/wrangler.jsonc is not in")));
54+ assert.ok(found.some((p) => p.includes("binds to events (front), which ships after it")));
55+ assert.ok(found.some((p) => p.startsWith("Unit identity: d1 is")));
56+ assert.ok(found.some((p) => p.includes("has no name under resources.kv")));
57+});
58+
59+test("stages follow bindings: nothing binds to a unit that ships after it", () => {
60+ const order = stack.stages;
61+ for (const u of stack.units) {
62+ for (const target of u.bindsTo) {
63+ const other = stack.units.find((o) => o.worker === target);
64+ assert.ok(order.indexOf(other.stage) <= order.indexOf(u.stage), `${u.id} -> ${other.id}`);
65+ }
66+ }
67+});
68+
69+test("Rust workers build with the shared script and the same wasm-opt level", () => {
70+ for (const u of stack.units.filter((u) => u.kind === "rust-worker")) {
71+ assert.equal(u.config.build.command, "node ../../scripts/build-rust-worker.mjs", u.id);
72+ const toml = readFileSync(join(ROOT, u.path, "Cargo.toml"), "utf8");
73+ assert.match(toml, /\[package\.metadata\.wasm-pack\.profile\.release\]\s*\nwasm-opt = \["-O1"\]/, u.id);
74+ }
75+});
76+
77+// ── What each unit is built from ──────────────────────────────────────────
78+
79+test("shared crates and packages are read from workspace metadata", () => {
80+ assert.deepEqual(unit("events").dependsOn, ["crates/contracts", "crates/kit"]);
81+ assert.deepEqual(unit("repos").dependsOn, ["crates/contracts", "crates/kit", "crates/scan", "crates/secrets"]);
82+ assert.ok(unit("actions").dependsOn.includes("crates/actions"));
83+ assert.ok(!unit("events").dependsOn.includes("crates/scan"));
84+ assert.deepEqual(unit("web").dependsOn, ["packages/contracts", "packages/theme"]);
85+ assert.deepEqual(unit("projects").dependsOn, ["packages/contracts"]);
86+ assert.deepEqual(unit("pages").dependsOn, []);
87+ assert.equal(unit("events").crate, "g1t-events");
88+ assert.equal(unit("web").pkg, "@g1t/web");
89+});
90+
91+test("the runner's image is built from the runner crate and what it uses", () => {
92+ const runner = unit("runner");
93+ assert.deepEqual(runner.image.dirs, ["crates/actions", "crates/runner"]);
94+ assert.ok(runner.dependsOn.includes("crates/runner"));
95+ assert.ok(touchesImage(runner, ["crates/actions/src/expr.rs"]));
96+ assert.ok(touchesImage(runner, ["services/runner/Dockerfile"]));
97+ assert.ok(!touchesImage(runner, ["services/runner/src/index.ts"]));
98+});
99+
100+test("path dependencies agree with Cargo's own resolved graph", (t) => {
101+ let full;
102+ try {
103+ full = JSON.parse(execFileSync("cargo", ["metadata", "--format-version", "1", "--offline"], { cwd: ROOT, encoding: "utf8", maxBuffer: 256 << 20 }));
104+ } catch {
105+ t.skip("cargo metadata could not resolve offline");
106+ return;
107+ }
108+ const cargo = cargoWorkspace(ROOT);
109+ const dirOf = new Map(full.packages.filter((p) => p.source === null).map((p) => [p.id, p.manifest_path]));
110+ const nodes = new Map(full.resolve.nodes.map((n) => [n.id, n]));
111+ for (const u of stack.units.filter((u) => u.crate)) {
112+ const root = full.packages.find((p) => p.name === u.crate).id;
113+ const seen = new Set();
114+ const stackIds = [root];
115+ while (stackIds.length) {
116+ const id = stackIds.pop();
117+ for (const dep of nodes.get(id).deps) {
118+ if (!dep.dep_kinds.some((k) => k.kind !== "dev")) continue;
119+ if (dirOf.has(dep.pkg) && !seen.has(dep.pkg)) {
120+ seen.add(dep.pkg);
121+ stackIds.push(dep.pkg);
122+ }
123+ }
124+ }
125+ const names = [...seen].map((id) => full.packages.find((p) => p.id === id).name);
126+ const dirs = names.map((name) => cargo.get(name).dir).sort();
127+ assert.deepEqual(dirs, u.dependsOn.filter((d) => d.startsWith("crates/")).sort(), u.id);
128+ }
129+});
130+
131+test("every import that leaves a unit's folder is covered by its dependencies or inputs", () => {
132+ for (const u of stack.units) {
133+ for (const { file, target } of outsideImports(ROOT, u)) {
134+ const covered = u.dependsOn.some((dir) => target.startsWith(`${dir}/`)) || u.inputs.some((input) => target === input || target.startsWith(input.replace(/\.ts$/, "")));
135+ assert.ok(covered, `${file} imports ${target}, which ${u.id}'s manifest entry does not name`);
136+ }
137+ }
138+});
139+
140+// ── What a change touches ─────────────────────────────────────────────────
141+
142+const ids = (units, files) => units.filter((u) => touches(u, files)).map((u) => u.id);
143+
144+test("a shared crate's change reaches every unit built from it, and no other", () => {
145+ assert.deepEqual(ids(stack.units, ["crates/scan/src/lib.rs"]), ["repos", "security"]);
146+ assert.deepEqual(ids(stack.units, ["crates/secrets/src/lib.rs"]), ["identity", "repos", "integrations", "webhooks", "actions"]);
147+ const kit = ids(stack.units, ["crates/kit/src/lib.rs"]);
148+ assert.deepEqual(kit, stack.units.filter((u) => u.kind === "rust-worker").map((u) => u.id));
149+ assert.deepEqual(ids(stack.units, ["crates/runner/src/main.rs"]), ["runner"]);
150+});
151+
152+test("a shared package's change reaches what imports it", () => {
153+ assert.deepEqual(ids(stack.units, ["packages/theme/tokens.css"]), ["status", "web", "sudo", "docs"]);
154+ assert.ok(ids(stack.units, ["packages/contracts/src/index.ts"]).includes("og"));
155+ assert.ok(!ids(stack.units, ["packages/contracts/src/index.ts"]).includes("events"));
156+});
157+
158+test("own folders, declared inputs and root files", () => {
159+ assert.deepEqual(ids(stack.units, ["services/pages/src/index.ts"]), ["pages"]);
160+ assert.deepEqual(ids(stack.units, ["apps/web/app/lib/roadmap.ts"]), ["og", "web"]);
161+ assert.deepEqual(ids(stack.units, ["docs/PLAN.md", "README.md", "deploy/stack.jsonc"]), []);
162+ assert.deepEqual(ids(stack.units, ["scripts/build-rust-worker.mjs"]), stack.units.filter((u) => u.kind === "rust-worker").map((u) => u.id));
163+ // services/events is not a prefix of services/eventsx.
164+ assert.equal(touches(unit("events"), ["services/eventsx/a.rs"]), null);
165+});
166+
167+// ── Lockfiles ─────────────────────────────────────────────────────────────
168+
169+const lock = (sha2) => `version = 4
170+
171+[[package]]
172+name = "g1t-events"
173+version = "0.1.0"
174+dependencies = [
175+ "g1t-kit",
176+]
177+
178+[[package]]
179+name = "g1t-kit"
180+version = "0.1.0"
181+dependencies = [
182+ "serde",
183+]
184+
185+[[package]]
186+name = "g1t-webhooks"
187+version = "0.1.0"
188+dependencies = [
189+ "g1t-kit",
190+ "g1t-secrets",
191+]
192+
193+[[package]]
194+name = "g1t-secrets"
195+version = "0.1.0"
196+dependencies = [
197+ "sha2 ${sha2}",
198+]
199+
200+[[package]]
201+name = "serde"
202+version = "1.0.0"
203+source = "registry+https://github.com/rust-lang/crates.io-index"
204+
205+[[package]]
206+name = "sha2"
207+version = "${sha2}"
208+source = "registry+https://github.com/rust-lang/crates.io-index"
209+`;
210+
211+test("a Cargo.lock change reaches only crates that use what changed", () => {
212+ const before = parseCargoLock(lock("0.10.8"));
213+ const after = parseCargoLock(lock("0.10.9"));
214+ const names = changedNames(before, after);
215+ assert.deepEqual([...names], ["sha2"]);
216+ assert.ok(reaches(after, ["g1t-webhooks"], names));
217+ assert.ok(!reaches(after, ["g1t-events"], names));
218+});
219+
220+test("a package-lock change reaches through workspace links", () => {
221+ const make = (version) =>
222+ JSON.stringify({
223+ lockfileVersion: 3,
224+ packages: {
225+ "": { devDependencies: { wrangler: "^4" } },
226+ "apps/web": { dependencies: { "@g1t/theme": "*", react: "^19" } },
227+ "services/pages": { dependencies: {} },
228+ "packages/theme": { dependencies: { fontkit: "^1" } },
229+ "node_modules/@g1t/theme": { resolved: "packages/theme", link: true },
230+ "node_modules/react": { version: "19.0.0" },
231+ "node_modules/fontkit": { version },
232+ "node_modules/wrangler": { version: "4.1.0" },
233+ },
234+ });
235+ const before = parseNpmLock(make("1.0.0"));
236+ const after = parseNpmLock(make("1.0.1"));
237+ const names = changedNames(before, after);
238+ assert.deepEqual([...names], ["fontkit"]);
239+ assert.ok(reaches(after, ["apps/web", ""], names));
240+ assert.ok(!reaches(after, ["services/pages", ""], names));
241+});
242+
243+// ── Deciding ──────────────────────────────────────────────────────────────
244+
245+const HEAD = "a".repeat(40);
246+const OLD = "b".repeat(40);
247+const fakeGit = (files, { has = true, locks = {} } = {}) => ({
248+ has: () => has,
249+ changed: () => files,
250+ show: (sha, path) => locks[`${sha}:${path}`] ?? "",
251+});
252+
253+test("decide: changed units deploy, others wait, unknown ones deploy", () => {
254+ const units = ["events", "repos", "pages", "web"].map(unit);
255+ const live = { events: { sha: OLD }, repos: { sha: OLD }, pages: { sha: HEAD }, web: { sha: null, why: "never deployed" } };
256+ const decisions = decide(units, { live, head: HEAD, gitApi: fakeGit(["crates/scan/src/lib.rs"]) });
257+ const by = Object.fromEntries(decisions.map((d) => [d.unit.id, d]));
258+ assert.equal(by.events.deploy, false);
259+ assert.equal(by.events.reason, "nothing it is built from changed");
260+ assert.equal(by.repos.deploy, true);
261+ assert.match(by.repos.reason, /crates\/scan\/src\/lib.rs via crates\/scan/);
262+ assert.equal(by.pages.deploy, false);
263+ assert.equal(by.pages.reason, "up to date");
264+ assert.equal(by.web.deploy, true);
265+ assert.match(by.web.reason, /never deployed/);
266+});
267+
268+test("decide: --force deploys unchanged units; a commit missing from history deploys", () => {
269+ const forced = decide([unit("events")], { live: { events: { sha: HEAD } }, head: HEAD, force: true, gitApi: fakeGit([]) });
270+ assert.equal(forced[0].deploy, true);
271+ const shallow = decide([unit("events")], { live: { events: { sha: OLD } }, head: HEAD, gitApi: fakeGit([], { has: false }) });
272+ assert.equal(shallow[0].deploy, true);
273+ assert.match(shallow[0].reason, /does not have/);
274+});
275+
276+test("decide: Cargo.lock counts only where it reaches", () => {
277+ const locks = { [`${OLD}:Cargo.lock`]: lock("0.10.8"), [`${HEAD}:Cargo.lock`]: lock("0.10.9") };
278+ const units = [unit("events"), unit("webhooks")];
279+ const live = { events: { sha: OLD }, webhooks: { sha: OLD } };
280+ const decisions = decide(units, { live, head: HEAD, gitApi: fakeGit(["Cargo.lock"], { locks }) });
281+ assert.deepEqual(decisions.map((d) => [d.unit.id, d.deploy]), [["events", false], ["webhooks", true]]);
282+});
283+
284+test("decide: the runner's image rebuilds only when what it is built from changed", () => {
285+ const live = { runner: { sha: OLD } };
286+ const code = decide([unit("runner")], { live, head: HEAD, gitApi: fakeGit(["services/runner/src/index.ts"]) });
287+ assert.deepEqual([code[0].deploy, code[0].image], [true, false]);
288+ const image = decide([unit("runner")], { live, head: HEAD, gitApi: fakeGit(["crates/runner/src/main.rs"]) });
289+ assert.deepEqual([image[0].deploy, image[0].image], [true, true]);
290+});
291+
292+test("the plan as data: migrations, and each stage's units in jobs that share a build", () => {
293+ const units = ["events", "repos", "projects", "api", "web", "docs"].map(unit);
294+ const decisions = units.map((u) => ({ unit: u, deploy: true, reason: "x", since: null, image: false }));
295+ const data = planJson(stack, decisions, { events: { pending: ["0003_x.sql"] }, repos: { pending: [] } }, HEAD);
296+ assert.deepEqual(data.migrations, [{ unit: "events", database: "g1t-events", pending: ["0003_x.sql"] }]);
297+ assert.deepEqual(data.stages.core.jobs, [
298+ { group: "rust", units: "events,repos", rust: true },
299+ { group: "ts", units: "projects", rust: false },
300+ ]);
301+ assert.deepEqual(data.stages.edge.jobs, [{ group: "rust", units: "api", rust: true }]);
302+ assert.deepEqual(data.stages.front.jobs, [
303+ { group: "web", units: "web", rust: false },
304+ { group: "docs", units: "docs", rust: false },
305+ ]);
306+ assert.deepEqual(data.stage_order, ["core", "edge", "front"]);
307+ assert.deepEqual(buildGroups([]), []);
308+ // Ten Rust workers go to three jobs; a unit whose image rebuilds gets its own.
309+ const core = stack.units.filter((u) => u.stage === "core");
310+ const jobs = buildGroups(core, ["runner"]);
311+ assert.deepEqual(jobs.filter((j) => j.rust).map((j) => j.units.split(",").length), [4, 3, 3]);
312+ assert.ok(jobs.some((j) => j.group === "runner-image" && j.units === "runner"));
313+ assert.ok(!jobs.find((j) => j.group === "ts").units.includes("runner"));
314+});
315+
316+test("units are picked by short name, folder or Worker name", () => {
317+ assert.deepEqual(pick(stack, ["billing,services/web".replace("services/web", "apps/web"), "g1t-api"]).map((u) => u.id), ["billing", "web", "api"]);
318+ assert.throws(() => pick(stack, ["nope"]), /No unit called nope/);
319+});
320+
321+test("a CI job installs only what its units need", () => {
322+ const npm = npmWorkspace();
323+ assert.deepEqual(npmCiArgs([unit("events"), unit("repos")], npm), ["ci", "--no-audit", "--no-fund", "--workspaces=false"]);
324+ assert.deepEqual(npmCiArgs([unit("events"), unit("status")], npm), [
325+ "ci", "--no-audit", "--no-fund", "--include-workspace-root",
326+ "-w", "@g1t/contracts", "-w", "@g1t/status", "-w", "@g1t/theme",
327+ ]);
328+});
329+
330+// ── Cloudflare's answers ──────────────────────────────────────────────────
331+
332+const version = (id, number, message, triggered = "deployment") => ({
333+ id,
334+ number,
335+ metadata: { created_on: "2026-10-05T00:00:00Z", author_email: "a@b" },
336+ annotations: { "workers/triggered_by": triggered, ...(message ? { "workers/message": message } : {}) },
337+});
338+
339+test("a deploy is annotated with its commit, and read back", () => {
340+ const { message, tag } = annotation(HEAD, "A subject ".repeat(20));
341+ assert.ok(message.length <= 100);
342+ assert.equal(commitFrom(message), HEAD);
343+ assert.equal(tag, `g1t-${HEAD.slice(0, 12)}`);
344+ assert.equal(commitFrom(message.replace("g1t-deploy", "g1t-deploy-dirty")), null);
345+ assert.equal(commitFrom("deployed by hand"), null);
346+});
347+
348+test("the live commit: the live version's, looking through secret changes", () => {
349+ const versions = [version("v1", 1, annotation(OLD).message), version("v2", 2, null, "secret"), version("v3", 3, null, "version_upload")];
350+ const status = (id) => ({ versions: [{ version_id: id, percentage: 100 }] });
351+ assert.equal(liveCommit(status("v1"), versions).sha, OLD);
352+ assert.equal(liveCommit(status("v2"), versions).sha, OLD);
353+ assert.equal(liveCommit(status("v3"), versions).sha, null);
354+ assert.match(liveCommit(status("v9"), versions).why, /not among/);
355+ const split = liveCommit({ versions: [{ version_id: "v1", percentage: 10 }, { version_id: "v2", percentage: 90 }] }, versions);
356+ assert.equal(split.sha, OLD);
357+ assert.equal(split.split, true);
358+});
359+
360+test("Wrangler's output: pending migrations and the version a deploy made", () => {
361+ const pending = `
362+ ⛅️ wrangler 4.146.0
363+Resource location: remote
364+Migrations to be applied:
365+┌──────────────────────┐
366+│ Name │
367+├──────────────────────┤
368+│ 0021_confidence.sql │
369+├──────────────────────┤
370+│ 0022_more.sql │
371+└──────────────────────┘`;
372+ assert.deepEqual(pendingFrom(pending), ["0021_confidence.sql", "0022_more.sql"]);
373+ assert.deepEqual(pendingFrom("Resource location: remote\n\n✅ No migrations to apply!"), []);
374+ assert.equal(versionFrom("Deployed g1t-events triggers\nCurrent Version ID: 2c7fc93a-82d9-4850-8a77-ba887d157a4f\n"), "2c7fc93a-82d9-4850-8a77-ba887d157a4f");
375+});
376+
377+test("the pool runs everything, no more than its limit at once", async () => {
378+ let running = 0;
379+ let most = 0;
380+ const out = await pool([1, 2, 3, 4, 5, 6, 7], 3, async (n) => {
381+ running++;
382+ most = Math.max(most, running);
383+ await new Promise((resolve) => setTimeout(resolve, 5));
384+ running--;
385+ return n * 2;
386+ });
387+ assert.deepEqual(out, [2, 4, 6, 8, 10, 12, 14]);
388+ assert.equal(most, 3);
389+});
390+
391+// ── Everything else that reads the list ───────────────────────────────────
392+
393+test("self-hosting builds every Rust service the manifest says it runs", () => {
394+ const dockerfile = readFileSync(join(ROOT, "deploy/self-host/Dockerfile"), "utf8");
395+ const loops = [...dockerfile.matchAll(/for service in ([a-z ]+); do/g)].map((m) => m[1].trim().split(/\s+/).sort());
396+ const wanted = stack.units.filter((u) => u.self_host === "run" && u.kind === "rust-worker").map((u) => u.path.split("/").pop()).sort();
397+ assert.ok(loops.length >= 2);
398+ for (const loop of loops) assert.deepEqual(loop, wanted);
399+});
400+
401+test("docs/SELF_HOSTING.md's table names every unit", () => {
402+ const doc = readFileSync(join(ROOT, "docs/SELF_HOSTING.md"), "utf8");
403+ for (const u of stack.units) assert.ok(doc.includes(`| \`${u.path}\` |`), `${u.path} is missing from docs/SELF_HOSTING.md`);
404+});
405+
406+test("resolveStack works on a manifest with no workspace crates or packages", () => {
407+ const bare = resolveStack(loadStack(), { cargo: new Map(), npm: new Map() });
408+ assert.deepEqual(bare.units.find((u) => u.id === "events").dependsOn, []);
409+});
+76−0
1+// Which units a lockfile change reaches. A change to Cargo.lock or
2+// package-lock.json touches only the units whose dependency graph includes
3+// a package that changed, read from the lockfiles themselves (their graph
4+// is a superset of any one target's, so this errs toward deploying).
5+
6+/** Cargo.lock: name -> list of entries { version, source, checksum, deps: [names] }. */
7+export function parseCargoLock(text) {
8+ const packages = new Map();
9+ if (!text) return packages;
10+ for (const block of text.split(/^\[\[package\]\]\s*$/m).slice(1)) {
11+ const field = (key) => new RegExp(`^${key} = "([^"]*)"`, "m").exec(block)?.[1] ?? null;
12+ const depsBlock = /^dependencies = \[([\s\S]*?)\]/m.exec(block)?.[1] ?? "";
13+ const deps = [...depsBlock.matchAll(/"([^"\s]+)[^"]*"/g)].map((m) => m[1]);
14+ const entry = { version: field("version"), source: field("source"), checksum: field("checksum"), deps };
15+ const name = field("name");
16+ if (!packages.has(name)) packages.set(name, []);
17+ packages.get(name).push(entry);
18+ }
19+ return packages;
20+}
21+
22+/** package-lock.json (v2/v3): name -> list of entries; workspace folders keep their path as name. */
23+export function parseNpmLock(text) {
24+ const packages = new Map();
25+ if (!text) return packages;
26+ const lock = JSON.parse(text);
27+ for (const [key, entry] of Object.entries(lock.packages ?? {})) {
28+ const at = key.lastIndexOf("node_modules/");
29+ const name = at < 0 ? key : key.slice(at + "node_modules/".length);
30+ // A workspace package is a link to its folder's entry.
31+ const deps = entry.link
32+ ? [entry.resolved]
33+ : Object.keys({ ...entry.dependencies, ...entry.devDependencies, ...entry.optionalDependencies, ...entry.peerDependencies });
34+ const record = { key, version: entry.version ?? null, resolved: entry.resolved ?? null, integrity: entry.integrity ?? null, deps };
35+ if (!packages.has(name)) packages.set(name, []);
36+ packages.get(name).push(record);
37+ }
38+ return packages;
39+}
40+
41+const signature = (entries) => JSON.stringify((entries ?? []).map((e) => ({ ...e, deps: [...e.deps].sort() })).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))));
42+
43+/** Names whose entries differ between two parsed lockfiles. */
44+export function changedNames(before, after) {
45+ const names = new Set();
46+ for (const name of new Set([...before.keys(), ...after.keys()])) {
47+ if (signature(before.get(name)) !== signature(after.get(name))) names.add(name);
48+ }
49+ return names;
50+}
51+
52+/** Whether any of `names` is reachable from `roots` in a parsed lockfile (roots included). */
53+export function reaches(packages, roots, names) {
54+ if (!names.size) return false;
55+ const seen = new Set();
56+ const stack = [...roots];
57+ while (stack.length) {
58+ const name = stack.pop();
59+ if (seen.has(name)) continue;
60+ seen.add(name);
61+ if (names.has(name)) return true;
62+ for (const entry of packages.get(name) ?? []) stack.push(...entry.deps);
63+ }
64+ return false;
65+}
66+
67+/**
68+ * Where a unit starts in each lockfile: its crate (or its image's) in
69+ * Cargo.lock; its folder, and the root's tools (Wrangler bundles every
70+ * TypeScript Worker), in package-lock.json.
71+ */
72+export function lockRoots(unit) {
73+ const cargo = [unit.crate, unit.image?.crate].filter(Boolean);
74+ const npm = unit.kind === "rust-worker" ? [] : [unit.path, ""];
75+ return { cargo, npm };
76+}
+170−0
1+// What a deploy would do: for each unit, the commit it runs, what changed
2+// since, and its pending migrations. Git and Wrangler are passed in, so the
3+// tests can give their own.
4+
5+import { execFileSync } from "node:child_process";
6+
7+import { changedNames, lockRoots, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs";
8+import { ROOT, byStage, buildGroups, codeStages, touches, touchesImage } from "./stack.mjs";
9+
10+/** Git, read-only. */
11+export const git = {
12+ head: () => run(["rev-parse", "HEAD"]).trim(),
13+ resolve: (rev) => run(["rev-parse", "--verify", `${rev}^{commit}`]).trim(),
14+ subject: () => run(["log", "-1", "--format=%s"]).trim(),
15+ /** Whether a commit is in this checkout's history. */
16+ has: (sha) => {
17+ try {
18+ run(["cat-file", "-e", `${sha}^{commit}`]);
19+ return true;
20+ } catch {
21+ return false;
22+ }
23+ },
24+ /** Files changed between two commits. */
25+ /** A file's text at a commit, or "" if it is not there. */
26+ show: (sha, path) => {
27+ try {
28+ return run(["show", `${sha}:${path}`]);
29+ } catch {
30+ return "";
31+ }
32+ },
33+ changed: (from, to) => run(["diff", "--name-only", "--no-renames", from, to]).split("\n").filter(Boolean),
34+ /** Uncommitted and untracked files (not ignored ones). */
35+ dirty: () =>
36+ run(["status", "--porcelain", "--untracked-files=all"])
37+ .split("\n")
38+ .filter(Boolean)
39+ .map((line) => line.slice(3).replace(/^"|"$/g, "").split(" -> ").pop()),
40+};
41+
42+function run(args) {
43+ return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
44+}
45+
46+/**
47+ * Decides, for each unit in `units`, whether it deploys and why.
48+ *
49+ * live: unit id -> { sha, why? } (what readLive found)
50+ * head: the commit being deployed
51+ * force: deploy even what has not changed
52+ * gitApi: { has, changed }
53+ *
54+ * Each unit gets { deploy, reason, since, files, image }: `files` are the
55+ * changed files that touch it; `image` says whether its Containers image
56+ * must be built.
57+ */
58+export function decide(units, { live, head, force = false, gitApi = git }) {
59+ const diffs = new Map();
60+ const changedSince = (sha) => {
61+ if (!diffs.has(sha)) diffs.set(sha, gitApi.changed(sha, head));
62+ return diffs.get(sha);
63+ };
64+ // A lockfile change counts for a unit only if a package it uses changed.
65+ const locks = new Map();
66+ const lockChange = (sha, file) => {
67+ const key = `${sha}:${file}`;
68+ if (!locks.has(key)) {
69+ const parse = file === "Cargo.lock" ? parseCargoLock : parseNpmLock;
70+ const after = parse(gitApi.show(head, file));
71+ locks.set(key, { after, names: changedNames(parse(gitApi.show(sha, file)), after) });
72+ }
73+ return locks.get(key);
74+ };
75+ const relevant = (unit, sha, files) =>
76+ files.filter((file) => {
77+ if (file !== "Cargo.lock" && file !== "package-lock.json") return true;
78+ const { after, names } = lockChange(sha, file);
79+ const roots = lockRoots(unit)[file === "Cargo.lock" ? "cargo" : "npm"];
80+ return reaches(after, roots, names);
81+ });
82+ return units.map((unit) => {
83+ const found = live[unit.id] ?? { sha: null, why: "not read" };
84+ const decision = { unit, since: found.sha, deploy: false, reason: "", files: [], image: false };
85+ if (!found.sha) {
86+ decision.deploy = true;
87+ decision.reason = found.error ? `could not read what it runs: ${firstLine(found.error)}` : `no known commit (${found.why ?? "unknown"})`;
88+ decision.image = Boolean(unit.image);
89+ return decision;
90+ }
91+ if (found.sha === head) {
92+ decision.deploy = force;
93+ decision.reason = force ? "forced; already at this commit" : "up to date";
94+ return decision;
95+ }
96+ if (!gitApi.has(found.sha)) {
97+ decision.deploy = true;
98+ decision.reason = `runs ${found.sha.slice(0, 12)}, which this checkout does not have (fetch full history)`;
99+ decision.image = Boolean(unit.image);
100+ return decision;
101+ }
102+ const files = relevant(unit, found.sha, changedSince(found.sha));
103+ const hit = touches(unit, files);
104+ decision.files = hit ? files.filter((file) => touches(unit, [file])) : [];
105+ decision.image = touchesImage(unit, files);
106+ if (hit) {
107+ decision.deploy = true;
108+ decision.reason = `${decision.files.length} changed file${decision.files.length === 1 ? "" : "s"} (${hit.file}${hit.via === "its own folder" ? "" : ` via ${hit.via}`})`;
109+ } else {
110+ decision.deploy = force;
111+ decision.reason = force ? "forced; nothing it is built from changed" : "nothing it is built from changed";
112+ }
113+ return decision;
114+ });
115+}
116+
117+const firstLine = (text) => String(text).trim().split("\n").find((line) => /error|\[ERROR\]|X /i.test(line)) ?? String(text).trim().split("\n")[0];
118+
119+/**
120+ * The plan as data, for `plan --json` and the workflow: the migrations to
121+ * apply, and each stage's units split into the jobs that build them.
122+ */
123+export function planJson(stack, decisions, migrations, head) {
124+ const deploying = decisions.filter((d) => d.deploy).map((d) => d.unit);
125+ const stages = {};
126+ for (const stage of codeStages(stack)) {
127+ const units = deploying.filter((u) => u.stage === stage);
128+ stages[stage] = { units: units.map((u) => u.id), jobs: buildGroups(units, decisions.filter((d) => d.deploy && d.image).map((d) => d.unit.id)) };
129+ }
130+ return {
131+ commit: head,
132+ migrations: Object.entries(migrations)
133+ .filter(([, m]) => m.pending?.length)
134+ .map(([id, m]) => ({ unit: id, database: stack.units.find((u) => u.id === id).d1.database, pending: m.pending })),
135+ migration_errors: Object.entries(migrations)
136+ .filter(([, m]) => m.error)
137+ .map(([id, m]) => ({ unit: id, error: m.error })),
138+ stages,
139+ units: decisions.map((d) => ({
140+ unit: d.unit.id,
141+ stage: d.unit.stage,
142+ deploy: d.deploy,
143+ reason: d.reason,
144+ live_commit: d.since,
145+ image: d.image,
146+ })),
147+ stage_order: byStage(stack, deploying).map((g) => g.stage),
148+ };
149+}
150+
151+/** A plain table. */
152+export function table(rows, headers) {
153+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? "").length)));
154+ const line = (cells) => cells.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" ").trimEnd();
155+ return [line(headers), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)].join("\n");
156+}
157+
158+/** Runs `task` over `items`, at most `limit` at once, in order of start. */
159+export async function pool(items, limit, task) {
160+ const results = new Array(items.length);
161+ let next = 0;
162+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
163+ while (next < items.length) {
164+ const index = next++;
165+ results[index] = await task(items[index], index);
166+ }
167+ });
168+ await Promise.all(workers);
169+ return results;
170+}
+386−0
1+// The deploy manifest (deploy/stack.jsonc) and what it implies: each
2+// unit's Wrangler config, the shared crates and packages it is built from,
3+// and which units a set of changed files touches. Pure apart from reading
4+// files and `cargo metadata`, so the tests can drive it.
5+
6+import { execFileSync } from "node:child_process";
7+import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8+import { dirname, join, relative } from "node:path";
9+import { fileURLToPath } from "node:url";
10+
11+export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
12+export const STACK_FILE = "deploy/stack.jsonc";
13+export const KINDS = ["rust-worker", "ts-worker", "react-router", "astro"];
14+export const SELF_HOST = ["run", "off", "separate", "none"];
15+
16+/** Strips comments and trailing commas from JSONC. Strings are respected. */
17+export function parseJsonc(text) {
18+ let result = "";
19+ let inString = false;
20+ for (let i = 0; i < text.length; i++) {
21+ const char = text[i];
22+ if (inString) {
23+ result += char;
24+ if (char === "\\") result += text[++i];
25+ else if (char === '"') inString = false;
26+ } else if (char === '"') {
27+ inString = true;
28+ result += char;
29+ } else if (char === "/" && text[i + 1] === "/") {
30+ while (i < text.length && text[i] !== "\n") i++;
31+ result += "\n";
32+ } else if (char === "/" && text[i + 1] === "*") {
33+ i = text.indexOf("*/", i + 2) + 1;
34+ } else {
35+ result += char;
36+ }
37+ }
38+ return JSON.parse(result.replace(/,(\s*[}\]])/g, "$1"));
39+}
40+
41+const readJsonc = (path) => parseJsonc(readFileSync(path, "utf8"));
42+const posix = (path) => path.replaceAll("\\", "/");
43+
44+/**
45+ * The manifest, each unit with its Wrangler config beside it.
46+ * Units keep the manifest's order.
47+ */
48+export function loadStack(root = ROOT) {
49+ const raw = readJsonc(join(root, STACK_FILE));
50+ const units = Object.entries(raw.units).map(([id, unit]) => {
51+ const configPath = join(root, unit.path, "wrangler.jsonc");
52+ const config = existsSync(configPath) ? readJsonc(configPath) : null;
53+ return {
54+ id,
55+ secrets: [],
56+ setup: [],
57+ inputs: [],
58+ ...unit,
59+ config,
60+ bindsTo: (config?.services ?? []).map((binding) => binding.service),
61+ };
62+ });
63+ return { stages: raw.stages, resources: raw.resources ?? {}, units };
64+}
65+
66+/** The deployable stages (every stage but migrations), in order. */
67+export const codeStages = (stack) => stack.stages.filter((stage) => stage !== "migrations");
68+
69+/**
70+ * Workspace crates: name -> { dir, deps: [names of workspace crates it
71+ * depends on as a normal or build dependency] }. From `cargo metadata
72+ * --no-deps`, which reads only the workspace's manifests.
73+ */
74+export function cargoWorkspace(root = ROOT, metadata = null) {
75+ const meta =
76+ metadata ??
77+ JSON.parse(
78+ execFileSync("cargo", ["metadata", "--no-deps", "--format-version", "1", "--offline"], {
79+ cwd: root,
80+ encoding: "utf8",
81+ maxBuffer: 64 * 1024 * 1024,
82+ }),
83+ );
84+ const crates = new Map();
85+ for (const pkg of meta.packages) {
86+ crates.set(pkg.name, {
87+ dir: posix(relative(meta.workspace_root ?? root, dirname(pkg.manifest_path))),
88+ deps: [],
89+ raw: pkg,
90+ });
91+ }
92+ for (const crate of crates.values()) {
93+ crate.deps = crate.raw.dependencies
94+ // Dev-dependencies are not in what deploys.
95+ .filter((dep) => dep.kind !== "dev" && dep.path)
96+ .map((dep) => dep.name)
97+ .filter((name) => crates.has(name));
98+ delete crate.raw;
99+ }
100+ return crates;
101+}
102+
103+/**
104+ * npm workspace packages: name -> { dir, deps: [workspace package names] }.
105+ * dependencies and devDependencies both count: a build reads either.
106+ */
107+export function npmWorkspace(root = ROOT) {
108+ const rootPkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
109+ const dirs = [];
110+ for (const pattern of rootPkg.workspaces ?? []) {
111+ if (pattern.endsWith("/*")) {
112+ const parent = pattern.slice(0, -2);
113+ if (!existsSync(join(root, parent))) continue;
114+ for (const name of readdirSync(join(root, parent)).sort()) {
115+ if (existsSync(join(root, parent, name, "package.json"))) dirs.push(`${parent}/${name}`);
116+ }
117+ } else if (existsSync(join(root, pattern, "package.json"))) {
118+ dirs.push(pattern);
119+ }
120+ }
121+ const packages = new Map();
122+ const declared = new Map();
123+ for (const dir of dirs) {
124+ const pkg = JSON.parse(readFileSync(join(root, dir, "package.json"), "utf8"));
125+ packages.set(pkg.name, { dir, deps: [] });
126+ declared.set(pkg.name, Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }));
127+ }
128+ for (const [name, pkg] of packages) pkg.deps = declared.get(name).filter((dep) => packages.has(dep));
129+ return packages;
130+}
131+
132+/** Every name reachable from `start` through `graph` (start excluded). */
133+function closure(graph, start) {
134+ const seen = new Set();
135+ const stack = [...(graph.get(start)?.deps ?? [])];
136+ while (stack.length) {
137+ const name = stack.pop();
138+ if (seen.has(name)) continue;
139+ seen.add(name);
140+ stack.push(...(graph.get(name)?.deps ?? []));
141+ }
142+ return [...seen];
143+}
144+
145+/** Files at the root every unit of a kind is built with. */
146+export function globalInputs(kind) {
147+ const inputs = ["package.json"];
148+ if (kind === "rust-worker") inputs.push("Cargo.toml", "Cargo.lock", "scripts/build-rust-worker.mjs");
149+ // A Rust worker's JavaScript is a small shim worker-build bundles itself,
150+ // so the npm lockfile only changes what npm-built units ship.
151+ else inputs.push("package-lock.json", "tsconfig.base.json");
152+ return inputs;
153+}
154+
155+/**
156+ * Adds to each unit what it is built from:
157+ * crate / pkg: its own crate or package name, when it has one;
158+ * dependsOn: folders of the shared crates and packages it uses;
159+ * inputs: the manifest's own inputs plus the root files its kind uses;
160+ * image: for a Containers image, the folders and files it is built from.
161+ */
162+export function resolveStack(stack, { cargo, npm }) {
163+ const crateByDir = new Map([...cargo].map(([name, crate]) => [crate.dir, name]));
164+ const pkgByDir = new Map([...npm].map(([name, pkg]) => [pkg.dir, name]));
165+ for (const unit of stack.units) {
166+ const crate = crateByDir.get(unit.path) ?? null;
167+ const pkg = pkgByDir.get(unit.path) ?? null;
168+ const dirs = new Set();
169+ if (crate) for (const name of closure(cargo, crate)) dirs.add(cargo.get(name).dir);
170+ if (pkg) for (const name of closure(npm, pkg)) dirs.add(npm.get(name).dir);
171+ dirs.delete(unit.path);
172+ unit.crate = crate;
173+ unit.pkg = pkg;
174+ unit.dependsOn = [...dirs].sort();
175+ unit.inputs = [...new Set([...(unit.inputs ?? []), ...globalInputs(unit.kind)])].sort();
176+ if (unit.image) {
177+ const name = unit.image.crate;
178+ const crates = name && cargo.has(name) ? [name, ...closure(cargo, name)] : [];
179+ unit.image = {
180+ ...unit.image,
181+ dirs: crates.map((c) => cargo.get(c).dir).sort(),
182+ files: [unit.image.dockerfile, "Cargo.toml", "Cargo.lock"],
183+ };
184+ for (const dir of unit.image.dirs) if (dir !== unit.path) unit.dependsOn.push(dir);
185+ unit.dependsOn = [...new Set(unit.dependsOn)].sort();
186+ unit.inputs = [...new Set([...unit.inputs, "Cargo.toml", "Cargo.lock"])].sort();
187+ }
188+ }
189+ return stack;
190+}
191+
192+/** The manifest, resolved against this checkout. */
193+export function resolvedStack(root = ROOT) {
194+ return resolveStack(loadStack(root), { cargo: cargoWorkspace(root), npm: npmWorkspace(root) });
195+}
196+
197+const under = (file, dir) => file === dir || file.startsWith(`${dir}/`);
198+
199+/**
200+ * Why a set of changed files (repository-relative, `/`-separated) touches
201+ * a unit: the first file that does, and through what. Null if none does.
202+ */
203+export function touches(unit, files) {
204+ for (const file of files) {
205+ if (under(file, unit.path)) return { file, via: "its own folder" };
206+ }
207+ for (const file of files) {
208+ const dir = unit.dependsOn.find((d) => under(file, d));
209+ if (dir) return { file, via: dir };
210+ if (unit.inputs.includes(file)) return { file, via: file };
211+ }
212+ return null;
213+}
214+
215+/** Whether changed files touch a unit's Containers image. */
216+export function touchesImage(unit, files) {
217+ if (!unit.image) return false;
218+ return files.some((file) => unit.image.files.includes(file) || unit.image.dirs.some((dir) => under(file, dir)));
219+}
220+
221+/** Units named on the command line: short names, folders or Worker names. */
222+export function pick(stack, names) {
223+ const wanted = names.flatMap((name) => name.split(",")).map((name) => name.trim().replace(/\/$/, "")).filter(Boolean);
224+ const found = [];
225+ for (const name of wanted) {
226+ const unit = stack.units.find((u) => u.id === name || u.path === name || u.worker === name);
227+ if (!unit) throw new Error(`No unit called ${name}. Units: ${stack.units.map((u) => u.id).join(", ")}`);
228+ if (!found.includes(unit)) found.push(unit);
229+ }
230+ return found;
231+}
232+
233+/** Units grouped by stage, in stage order, keeping the manifest's order inside each. */
234+export function byStage(stack, units) {
235+ return codeStages(stack)
236+ .map((stage) => ({ stage, units: units.filter((u) => u.stage === stage) }))
237+ .filter((group) => group.units.length);
238+}
239+
240+/** The most Rust workers one CI job builds; more are split across jobs. */
241+export const RUST_PER_JOB = 4;
242+
243+/**
244+ * How a stage's units are split into CI jobs, so units that share a build
245+ * share a sandbox: Rust workers (one Cargo target, at most RUST_PER_JOB to
246+ * a job, since a sandbox has half a CPU), the TypeScript Workers (cheap),
247+ * each site that runs a framework build, and each unit whose Containers
248+ * image must be rebuilt (`images`: ids), which needs Docker.
249+ */
250+export function buildGroups(units, images = []) {
251+ const groups = new Map();
252+ const add = (key, id) => {
253+ if (!groups.has(key)) groups.set(key, []);
254+ groups.get(key).push(id);
255+ };
256+ const rust = units.filter((u) => u.kind === "rust-worker");
257+ const shards = Math.ceil(rust.length / RUST_PER_JOB);
258+ rust.forEach((unit, i) => add(shards > 1 ? `rust-${(i % shards) + 1}` : "rust", unit.id));
259+ for (const unit of units.filter((u) => u.kind !== "rust-worker")) {
260+ add(images.includes(unit.id) ? `${unit.id}-image` : unit.kind === "ts-worker" ? "ts" : unit.id, unit.id);
261+ }
262+ return [...groups].map(([group, ids]) => ({ group, units: ids.join(","), rust: group.startsWith("rust") }));
263+}
264+
265+/**
266+ * What is wrong with the manifest, as sentences. Empty when it is right.
267+ * `wranglerConfigs`: every wrangler.jsonc in the repository (relative paths).
268+ */
269+export function problems(stack, wranglerConfigs = findWranglerConfigs()) {
270+ const out = [];
271+ const stages = codeStages(stack);
272+ if (stack.stages[0] !== "migrations") out.push('The first stage is "migrations".');
273+ const byWorker = new Map();
274+ for (const unit of stack.units) {
275+ const where = `Unit ${unit.id}`;
276+ if (!KINDS.includes(unit.kind)) out.push(`${where}: kind is one of ${KINDS.join(", ")}.`);
277+ if (!stages.includes(unit.stage)) out.push(`${where}: stage is one of ${stages.join(", ")}.`);
278+ if (!SELF_HOST.includes(unit.self_host)) out.push(`${where}: self_host is one of ${SELF_HOST.join(", ")}.`);
279+ if (!unit.config) {
280+ out.push(`${where}: ${unit.path}/wrangler.jsonc does not exist.`);
281+ continue;
282+ }
283+ if (unit.config.name !== unit.worker) out.push(`${where}: worker is ${unit.config.name} in its wrangler.jsonc, not ${unit.worker}.`);
284+ byWorker.set(unit.worker, unit);
285+ const dbs = (unit.config.d1_databases ?? []).filter((db) => db.migrations_dir);
286+ const d1 = dbs[0] ? { database: dbs[0].database_name, migrations: dbs[0].migrations_dir } : null;
287+ if (dbs.length > 1) out.push(`${where}: more than one D1 database with migrations; the deploy tool applies one per unit.`);
288+ if (JSON.stringify(d1) !== JSON.stringify(unit.d1 ?? null)) {
289+ out.push(`${where}: d1 is ${JSON.stringify(d1)} in its wrangler.jsonc, not ${JSON.stringify(unit.d1 ?? null)}.`);
290+ }
291+ const building = unit.config.build?.command ?? "";
292+ if (unit.kind === "rust-worker" && !building.includes("scripts/build-rust-worker.mjs")) {
293+ out.push(`${where}: a Rust worker builds with node ../../scripts/build-rust-worker.mjs, not "${building}".`);
294+ }
295+ if (unit.kind !== "rust-worker" && building) out.push(`${where}: only Rust workers have a build command; ${unit.kind} builds in the deploy tool.`);
296+ for (const id of (unit.config.kv_namespaces ?? []).map((kv) => kv.id)) {
297+ if (!stack.resources.kv?.[id]) out.push(`${where}: KV namespace ${id} has no name under resources.kv.`);
298+ }
299+ const hasImage = (unit.config.containers ?? []).some((c) => !String(c.image).includes("registry"));
300+ if (hasImage !== Boolean(unit.image)) out.push(`${where}: image is set exactly when its wrangler.jsonc builds a Containers image.`);
301+ }
302+ const listed = new Set(stack.units.map((u) => posix(join(u.path, "wrangler.jsonc"))));
303+ for (const config of wranglerConfigs) {
304+ if (!listed.has(config)) out.push(`${config} is not in ${STACK_FILE}: add its unit.`);
305+ }
306+ // Stage order follows bindings: a unit binds only to units that ship in
307+ // its stage or before it.
308+ for (const unit of stack.units) {
309+ for (const target of unit.bindsTo) {
310+ const other = byWorker.get(target);
311+ if (!other) {
312+ out.push(`Unit ${unit.id} binds to ${target}, which no unit deploys.`);
313+ } else if (stages.indexOf(other.stage) > stages.indexOf(unit.stage)) {
314+ out.push(`Unit ${unit.id} (${unit.stage}) binds to ${other.id} (${other.stage}), which ships after it.`);
315+ }
316+ }
317+ }
318+ return out;
319+}
320+
321+const SKIP_DIRS = new Set(["node_modules", "target", ".git", "build", "dist", ".wrangler", ".generated", ".astro"]);
322+
323+/** Every wrangler.jsonc or wrangler.toml checked into the repository. */
324+export function findWranglerConfigs(root = ROOT) {
325+ const found = [];
326+ const walk = (dir) => {
327+ for (const name of readdirSync(join(root, dir))) {
328+ if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
329+ const path = dir ? `${dir}/${name}` : name;
330+ if (statSync(join(root, path)).isDirectory()) walk(path);
331+ else if (/^wrangler\.(jsonc?|toml)$/.test(name)) found.push(path);
332+ }
333+ };
334+ walk("");
335+ return found.sort();
336+}
337+
338+/**
339+ * Relative imports in a unit's non-test sources that leave its folder:
340+ * each { file, target }. The manifest must cover each one, through a
341+ * workspace dependency or `inputs`.
342+ */
343+export function outsideImports(root, unit) {
344+ const found = [];
345+ const pattern = /(?:from\s+|import\s*\(\s*|import\s+)["'](\.{1,2}\/[^"']+)["']/g;
346+ const walk = (dir) => {
347+ for (const name of readdirSync(join(root, dir))) {
348+ if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
349+ const path = `${dir}/${name}`;
350+ if (statSync(join(root, path)).isDirectory()) {
351+ walk(path);
352+ continue;
353+ }
354+ if (!/\.(m?[jt]sx?|astro)$/.test(name) || /\.test\.[jt]sx?$/.test(name) || name.endsWith(".d.ts")) continue;
355+ const text = readFileSync(join(root, path), "utf8");
356+ for (const match of text.matchAll(pattern)) {
357+ const target = posix(join(dirname(path), match[1]));
358+ if (!under(target, unit.path)) found.push({ file: path, target });
359+ }
360+ }
361+ };
362+ walk(unit.path);
363+ return found;
364+}
365+
366+/**
367+ * npm ci of only what the units need: Wrangler alone for Rust workers,
368+ * and each npm-built unit's workspace with the packages it uses. A CI job
369+ * that deploys three Rust workers does not install the site's toolchain.
370+ */
371+export function npmCiArgs(units, npm) {
372+ const workspaces = new Set();
373+ for (const unit of units) {
374+ if (!unit.pkg) continue;
375+ const stack = [unit.pkg];
376+ while (stack.length) {
377+ const name = stack.pop();
378+ if (workspaces.has(name)) continue;
379+ workspaces.add(name);
380+ stack.push(...(npm.get(name)?.deps ?? []));
381+ }
382+ }
383+ const base = ["ci", "--no-audit", "--no-fund"];
384+ if (!workspaces.size) return [...base, "--workspaces=false"];
385+ return [...base, "--include-workspace-root", ...[...workspaces].sort().flatMap((name) => ["-w", name])];
386+}
+5−0
1616 serde.workspace = true
1717 serde_json = { workspace = true, features = ["preserve_order"] }
1818 worker.workspace = true
19+
20+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
21+# time (docs/DEPLOYING.md, "Build speed").
22+[package.metadata.wasm-pack.profile.release]
23+wasm-opt = ["-O1"]
+1−1
66 // Runs next to its database: moving a run along makes many queries in turn.
77 "placement": { "mode": "smart" },
88 "main": "build/index.js",
9− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
9+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1010 // Reached only through service bindings.
1111 "workers_dev": false,
1212 "d1_databases": [
+5−0
1818 hex = "0.4"
1919 sha2 = "0.10"
2020 hmac = "0.12"
21+
22+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
23+# time (docs/DEPLOYING.md, "Build speed").
24+[package.metadata.wasm-pack.profile.release]
25+wasm-opt = ["-O1"]
+1−1
77 // and each would otherwise cross the distance to it.
88 "placement": { "mode": "smart" },
99 "main": "build/index.js",
10− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
10+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1111 // Reached only through service bindings.
1212 "workers_dev": false,
1313 "d1_databases": [
+5−0
1414 serde.workspace = true
1515 serde_json.workspace = true
1616 worker.workspace = true
17+
18+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
19+# time (docs/DEPLOYING.md, "Build speed").
20+[package.metadata.wasm-pack.profile.release]
21+wasm-opt = ["-O1"]
+1−1
77 // and each would otherwise cross the distance to it.
88 "placement": { "mode": "smart" },
99 "main": "build/index.js",
10− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
10+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1111 "workers_dev": false,
1212 "d1_databases": [
1313 {
+5−0
2020 hex = "0.4"
2121 pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
2222 sha2 = "0.10"
23+
24+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
25+# time (docs/DEPLOYING.md, "Build speed").
26+[package.metadata.wasm-pack.profile.release]
27+wasm-opt = ["-O1"]
+1−1
99 "main": "build/index.js",
1010 // Staff waitlist summaries (src/lib.rs `scheduled`).
1111 "triggers": { "crons": ["*/15 * * * *"] },
12− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
12+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1313 // Reached only through service bindings.
1414 "workers_dev": false,
1515 "d1_databases": [
+5−0
1919 getrandom = { version = "0.2", features = ["js"] }
2020 hex = "0.4"
2121 sha2 = "0.10"
22+
23+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
24+# time (docs/DEPLOYING.md, "Build speed").
25+[package.metadata.wasm-pack.profile.release]
26+wasm-opt = ["-O1"]
+1−1
77 // and each would otherwise cross the distance to it.
88 "placement": { "mode": "smart" },
99 "main": "build/index.js",
10− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
10+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1111 // Reached only through service bindings. Outside systems reach it
1212 // through the API, at https://api.g1t.sh/hooks/<connection>.
1313 "workers_dev": false,
+5−0
1818 worker.workspace = true
1919 futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
2020 similar = "2"
21+
22+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
23+# time (docs/DEPLOYING.md, "Build speed").
24+[package.metadata.wasm-pack.profile.release]
25+wasm-opt = ["-O1"]
+1−1
77 // and each would otherwise cross the distance to it.
88 "placement": { "mode": "smart" },
99 "main": "build/index.js",
10− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
10+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1111 "workers_dev": false,
1212 "d1_databases": [
1313 {
+5−0
1414 serde.workspace = true
1515 serde_json.workspace = true
1616 worker.workspace = true
17+
18+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
19+# time (docs/DEPLOYING.md, "Build speed").
20+[package.metadata.wasm-pack.profile.release]
21+wasm-opt = ["-O1"]
+1−1
77 // and each would otherwise cross the distance to it.
88 "placement": { "mode": "smart" },
99 "main": "build/index.js",
10− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
10+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1111 // Reached only through service bindings.
1212 "workers_dev": false,
1313 "d1_databases": [
+5−0
1717 worker.workspace = true
1818 futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
1919 getrandom = { version = "0.2", features = ["js"] }
20+
21+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
22+# time (docs/DEPLOYING.md, "Build speed").
23+[package.metadata.wasm-pack.profile.release]
24+wasm-opt = ["-O1"]
+1−1
77 // and each would otherwise cross the distance to it.
88 "placement": { "mode": "smart" },
99 "main": "build/index.js",
10− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
10+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1111 // Reached only through service bindings.
1212 "workers_dev": false,
1313 "d1_databases": [
+5−0
1616 serde_json.workspace = true
1717 worker.workspace = true
1818 futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
19+
20+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
21+# time (docs/DEPLOYING.md, "Build speed").
22+[package.metadata.wasm-pack.profile.release]
23+wasm-opt = ["-O1"]
+1−1
44 "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
55 "compatibility_date": "2026-09-26",
66 "main": "build/index.js",
7− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
7+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
88 // Reached only through service bindings; it reaches out to the
99 // addresses webhooks are registered at.
1010 "workers_dev": false,
+5−0
1818 getrandom = { version = "0.2", features = ["js"] }
1919 hex = "0.4"
2020 sha2 = "0.10"
21+
22+# wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
23+# time (docs/DEPLOYING.md, "Build speed").
24+[package.metadata.wasm-pack.profile.release]
25+wasm-opt = ["-O1"]
+1−1
77 // and each would otherwise cross the distance to it.
88 "placement": { "mode": "smart" },
99 "main": "build/index.js",
10− "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
10+ "build": { "command": "node ../../scripts/build-rust-worker.mjs" },
1111 "workers_dev": false,
1212 "d1_databases": [
1313 {