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.
38 files+2781−860/38 viewed
| 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 |
| 52 | 52 | in `packages/contracts`). | |
| 53 | 53 | - Look at what you changed in a browser. Screenshots catch what type | |
| 54 | 54 | 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. |
| 16 | 16 | worker.workspace = true | |
| 17 | 17 | base64 = "0.22" | |
| 18 | 18 | 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"] |
| 4 | 4 | "account_id": "1e6f2cffa3f445920836e8ebe446bb58", | |
| 5 | 5 | "compatibility_date": "2026-09-26", | |
| 6 | 6 | "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" }, | |
| 8 | 8 | "workers_dev": false, | |
| 9 | 9 | // One Worker, two hostnames: REST on api, MCP on mcp. Both are thin | |
| 10 | 10 | // adapters over the same operations. |
| 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 | + | } |
| 34 | 34 | ||
| 35 | 35 | const PUBLIC_URL = (process.env.PUBLIC_URL ?? "http://localhost:8787").replace(/\/$/, ""); | |
| 36 | 36 | ||
| 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 | + | ||
| 37 | 41 | /** 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)); | |
| 53 | 45 | ||
| 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 = { | |
| 56 | 48 | "g1t-runner": "Agents", | |
| 57 | 49 | "g1t-context": "Context search and memory", | |
| 58 | 50 | }; | |
| 51 | + | const OFF = Object.fromEntries( | |
| 52 | + | STACK.filter((unit) => unit.self_host === "off").map((unit) => [unit.worker, OFF_NAMES[unit.worker] ?? unit.worker]), | |
| 53 | + | ); | |
| 59 | 54 | ||
| 60 | 55 | /** Sealing keys, by the service that holds each (hosted: Wrangler secrets). */ | |
| 61 | 56 | const SECRETS = { |
| 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 | + | } |
| 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>`. |
| 88 | 88 | ||
| 89 | 89 | ### By service | |
| 90 | 90 | ||
| 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 | + | ||
| 91 | 96 | | Service | Runs on | Cloudflare dependencies beyond Workers and D1 | Phase 1 self-hosted | | |
| 92 | 97 | | --- | --- | --- | --- | | |
| 93 | 98 | | `apps/web` | TS Worker plus assets | KV (`BLOBS`, `AVATARS`), Cache API, `cloudflare:workers` `env`, RPC to `RUNNER` | Runs unchanged | | |
| 94 | 99 | | `apps/api` | Rust Worker | KV `BLOBS`; hard-coded `api.g1t.sh`/`mcp.g1t.sh` issuer | Not started yet (phase 2) | | |
| 95 | 100 | | `apps/sudo` | TS Worker plus assets | Access JWT | Not run | | |
| 96 | 101 | | `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 | | |
| 97 | 103 | | `services/identity` | Rust | Email Sending, KV `AVATARS` | Runs unchanged; `EMAIL` goes to the mail shim | | |
| 98 | 104 | | `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 | | |
| 99 | 105 | | `services/work` | Rust | Queue consumer | Runs unchanged | |
| 9 | 9 | ], | |
| 10 | 10 | "scripts": { | |
| 11 | 11 | "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\"" | |
| 13 | 14 | }, | |
| 14 | 15 | "devDependencies": { | |
| 15 | 16 | "typescript": "^5.9.3", |
| 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 | + | } |
| 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 | + | ); |
| 1 | 1 | #!/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: | |
| 5 | 5 | # | |
| 6 | 6 | # scripts/deploy.sh # everything | |
| 7 | 7 | # 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 | |
| 8 | 10 | # | |
| 9 | 11 | # Uses your `wrangler login`. The repository's .env may hold a token for | |
| 10 | 12 | # other tools; it is ignored here unless you set CLOUDFLARE_DEPLOY_TOKEN. | |
| 13 | + | # See docs/DEPLOYING.md. | |
| 11 | 14 | set -euo pipefail | |
| 12 | 15 | ||
| 13 | 16 | 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" |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 16 | 16 | serde.workspace = true | |
| 17 | 17 | serde_json = { workspace = true, features = ["preserve_order"] } | |
| 18 | 18 | 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"] |
| 6 | 6 | // Runs next to its database: moving a run along makes many queries in turn. | |
| 7 | 7 | "placement": { "mode": "smart" }, | |
| 8 | 8 | "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" }, | |
| 10 | 10 | // Reached only through service bindings. | |
| 11 | 11 | "workers_dev": false, | |
| 12 | 12 | "d1_databases": [ |
| 18 | 18 | hex = "0.4" | |
| 19 | 19 | sha2 = "0.10" | |
| 20 | 20 | 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"] |
| 7 | 7 | // and each would otherwise cross the distance to it. | |
| 8 | 8 | "placement": { "mode": "smart" }, | |
| 9 | 9 | "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" }, | |
| 11 | 11 | // Reached only through service bindings. | |
| 12 | 12 | "workers_dev": false, | |
| 13 | 13 | "d1_databases": [ |
| 14 | 14 | serde.workspace = true | |
| 15 | 15 | serde_json.workspace = true | |
| 16 | 16 | 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"] |
| 7 | 7 | // and each would otherwise cross the distance to it. | |
| 8 | 8 | "placement": { "mode": "smart" }, | |
| 9 | 9 | "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" }, | |
| 11 | 11 | "workers_dev": false, | |
| 12 | 12 | "d1_databases": [ | |
| 13 | 13 | { |
| 20 | 20 | hex = "0.4" | |
| 21 | 21 | pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } | |
| 22 | 22 | 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"] |
| 9 | 9 | "main": "build/index.js", | |
| 10 | 10 | // Staff waitlist summaries (src/lib.rs `scheduled`). | |
| 11 | 11 | "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" }, | |
| 13 | 13 | // Reached only through service bindings. | |
| 14 | 14 | "workers_dev": false, | |
| 15 | 15 | "d1_databases": [ |
| 19 | 19 | getrandom = { version = "0.2", features = ["js"] } | |
| 20 | 20 | hex = "0.4" | |
| 21 | 21 | 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"] |
| 7 | 7 | // and each would otherwise cross the distance to it. | |
| 8 | 8 | "placement": { "mode": "smart" }, | |
| 9 | 9 | "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" }, | |
| 11 | 11 | // Reached only through service bindings. Outside systems reach it | |
| 12 | 12 | // through the API, at https://api.g1t.sh/hooks/<connection>. | |
| 13 | 13 | "workers_dev": false, |
| 18 | 18 | worker.workspace = true | |
| 19 | 19 | futures-util = { version = "0.3", default-features = false, features = ["alloc"] } | |
| 20 | 20 | 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"] |
| 7 | 7 | // and each would otherwise cross the distance to it. | |
| 8 | 8 | "placement": { "mode": "smart" }, | |
| 9 | 9 | "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" }, | |
| 11 | 11 | "workers_dev": false, | |
| 12 | 12 | "d1_databases": [ | |
| 13 | 13 | { |
| 14 | 14 | serde.workspace = true | |
| 15 | 15 | serde_json.workspace = true | |
| 16 | 16 | 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"] |
| 7 | 7 | // and each would otherwise cross the distance to it. | |
| 8 | 8 | "placement": { "mode": "smart" }, | |
| 9 | 9 | "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" }, | |
| 11 | 11 | // Reached only through service bindings. | |
| 12 | 12 | "workers_dev": false, | |
| 13 | 13 | "d1_databases": [ |
| 17 | 17 | worker.workspace = true | |
| 18 | 18 | futures-util = { version = "0.3", default-features = false, features = ["alloc"] } | |
| 19 | 19 | 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"] |
| 7 | 7 | // and each would otherwise cross the distance to it. | |
| 8 | 8 | "placement": { "mode": "smart" }, | |
| 9 | 9 | "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" }, | |
| 11 | 11 | // Reached only through service bindings. | |
| 12 | 12 | "workers_dev": false, | |
| 13 | 13 | "d1_databases": [ |
| 16 | 16 | serde_json.workspace = true | |
| 17 | 17 | worker.workspace = true | |
| 18 | 18 | 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"] |
| 4 | 4 | "account_id": "1e6f2cffa3f445920836e8ebe446bb58", | |
| 5 | 5 | "compatibility_date": "2026-09-26", | |
| 6 | 6 | "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" }, | |
| 8 | 8 | // Reached only through service bindings; it reaches out to the | |
| 9 | 9 | // addresses webhooks are registered at. | |
| 10 | 10 | "workers_dev": false, |
| 18 | 18 | getrandom = { version = "0.2", features = ["js"] } | |
| 19 | 19 | hex = "0.4" | |
| 20 | 20 | 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"] |
| 7 | 7 | // and each would otherwise cross the distance to it. | |
| 8 | 8 | "placement": { "mode": "smart" }, | |
| 9 | 9 | "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" }, | |
| 11 | 11 | "workers_dev": false, | |
| 12 | 12 | "d1_databases": [ | |
| 13 | 13 | { |