Skip to content
769 linesCodeBlameRaw
1# Deploying g1t
2
3How g1t.sh gets to Cloudflare: one manifest that lists every deployable
4unit, one tool that deploys only what changed, and a g1t Actions workflow
5that runs that tool on every push to `main`. Internal: the public
6self-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 runner's images | `services/runner/base/Dockerfile`, `services/runner/Dockerfile`, `services/runner/base.json`, `scripts/build-runner.mjs`, `scripts/deploy/image.mjs` |
14| The workflows | `.g1t/workflows/deploy.yml`, `.g1t/workflows/runner-base.yml` |
15| The old entry point | `scripts/deploy.sh`, now a wrapper |
16| Spend guardrails (platform pause, hourly usage watch) | [SPEND-GUARDRAILS.md](SPEND-GUARDRAILS.md) |
17
18## The manifest
19
20`deploy/stack.jsonc` names every unit: each folder with a `wrangler.jsonc`.
21It is JSONC rather than TOML so that Node reads it with no dependency,
22with the same parser as the Wrangler configs, and comments stay possible.
23
24| Field | |
25| --- | --- |
26| `path` | The unit's folder. |
27| `kind` | `rust-worker` (built by worker-build), `ts-worker` (Wrangler bundles it), `react-router` (`vite build` first), `astro` (`astro build` first). |
28| `worker` | The Worker's name. Must match its `wrangler.jsonc`. |
29| `d1` | `{ database, migrations }`, when it has a database. Must match its `wrangler.jsonc`. |
30| `stage` | `core`, `edge` or `front` (below). |
31| `secrets` | The Wrangler secrets it needs, by name. `node scripts/deploy.mjs doctor` checks they are set. |
32| `setup` | One-time steps no config can say, for a first deploy. |
33| `inputs` | Files outside its folder it is built from that no workspace metadata names. A test finds such imports. |
34| `image` | A Containers image: `dockerfile` (the image deployed), `crate` (the binary it adds), `base` (`{ context, lock }`: the base image's folder and the file recording the base that was pushed) and `repository` (where both are pushed). See [the runner's images](#the-runners-images). |
35| `self_host` | `run`, `off`, `separate` or `none`: what `deploy/self-host/configs.mjs` does with it. |
36
37What a unit is **built from** is never listed by hand. The tool reads it:
38Rust path dependencies from `cargo metadata`, workspace packages from each
39`package.json`, closed transitively. `node scripts/deploy.mjs manifest`
40prints the result:
41
42```
43unit stage kind worker d1 built from (besides its folder)
44events core rust-worker g1t-events g1t-events crates/contracts crates/kit
45repos core rust-worker g1t-repos g1t-repos crates/contracts crates/kit crates/scan crates/secrets
46runner core ts-worker g1t-runner crates/actions crates/runner packages/contracts
47og core ts-worker g1t-og packages/contracts
48web front react-router g1t packages/contracts packages/theme
49...
50```
51
52Root files count too: `Cargo.toml`, `Cargo.lock` and
53`scripts/build-rust-worker.mjs` for Rust units; `package-lock.json` and
54`tsconfig.base.json` for the others; `package.json` for all. A lockfile
55change counts for a unit only if a package in that unit's graph changed,
56read from the lockfile itself (`scripts/deploy/lockfiles.mjs`), so bumping
57`sharp` for the docs does not redeploy the Rust services.
58
59### Stages
60
61| Stage | What | Units |
62| --- | --- | --- |
63| `migrations` | Every pending D1 migration, in parallel, before any code | each unit's `d1` |
64| `core` | Services reached through bindings | the services, `og` |
65| `edge` | Public endpoints other than the site | `api`, `models`, `pages`, `status` |
66| `front` | The site, sudo, the docs | `web`, `sudo`, `docs` |
67
68A stage starts only when the one before it succeeded. Inside a stage units
69deploy in parallel. The rule the tests enforce: **a unit binds only to units
70in its own stage or an earlier one**, so new code never calls a service
71that has not shipped. (Services in `core` bind to each other in cycles,
72which is why they share a stage.)
73
74### What reads the manifest
75
76- `scripts/deploy.mjs`: everything below.
77- `deploy/self-host/configs.mjs`: which Workers a self-hosted installation
78 runs (`self_host: "run"`, the site first) and which are bound to the off
79 Worker (`"off"`). Its output is identical to before, apart from the order
80 of `workers.txt` after the site.
81- Tests (`npm run test:deploy`) check that: every `wrangler.jsonc` in the
82 repository has a unit; `worker`, `d1` and `image` match the configs;
83 every KV id is named under `resources.kv`; stages follow bindings; the
84 derived dependencies agree with Cargo's own resolved graph; every import
85 that leaves a unit's folder is covered; `deploy/self-host/Dockerfile`
86 builds exactly the Rust units self-hosting runs; and the service table in
87 `docs/SELF_HOSTING.md` names every unit.
88
89## The tool
90
91```sh
92node scripts/deploy.mjs plan # what would deploy, and why (read-only)
93node scripts/deploy.mjs deploy # migrations, then every changed unit
94node scripts/deploy.mjs deploy --only web,api # just these, if they changed
95node scripts/deploy.mjs deploy --only web --force # just this, changed or not
96node scripts/deploy.mjs deploy --all # everything
97node scripts/deploy.mjs build --only events # build as a deploy would; upload nothing
98node scripts/deploy.mjs migrate # pending migrations only
99node scripts/deploy.mjs manifest [--check|--json]
100node scripts/deploy.mjs doctor # secrets each unit lacks
101node scripts/deploy.mjs build-base # build and push the runner's base image (Docker)
102node scripts/deploy.mjs image # build and push the runner's image for this checkout (Docker)
103scripts/deploy.sh [units...] # the old entry point: all, or those named, always
104```
105
106| Flag | |
107| --- | --- |
108| `--only a,b` / `--skip a,b` | Units by short name, folder or Worker name. |
109| `--all` | Every unit, changed or not. |
110| `--force` | Deploy the selected units even if unchanged. |
111| `--concurrency N` | Units at once inside a stage, and migrations at once (default 4). |
112| `--stage core` | One stage only. |
113| `--no-migrations` | Skip the migrations step (the workflow runs it as its own job). |
114| `--allow-dirty` | Deploy with uncommitted changes in what deploys. The version records no commit, so the next plan deploys it again. |
115| `--rebuild-image` | Build the runner's image even if one for this source is already in the registry. |
116| `--rebuild-base` | Build and push a new base first (needs Docker), then the runner's image on it. Writes `services/runner/base.json`: commit it. |
117| `--no-push` | `build-base` and `image`: build locally, push nothing. |
118| `--no-cache` | `build-base`: build every layer again. |
119| `--since REV` | Treat Workers with no recorded commit as running `REV`. Used once to adopt Workers deployed before this tool. |
120| `--json`, `--out FILE`, `--github-output` | The plan as data, for the workflow. |
121
122### Where the deployed commit is kept
123
124On the Worker itself. Every deploy runs `wrangler deploy --message
125"g1t-deploy <40-char sha> <subject>" --tag g1t-<12-char sha>`, which
126Cloudflare keeps as the version's `workers/message` and `workers/tag`
127annotations. `plan` reads them back with `wrangler deployments status`
128(the live version) and `wrangler versions list` (its annotations): two
129read-only calls per unit, in parallel; a plan of all 22 units takes about
13010 seconds. No KV namespace or other infrastructure is needed.
131
132- A version made by `wrangler secret put` keeps the code of the one before
133 it, so the tool looks through those to the deploy before.
134- A version deployed any other way (by hand, from the dashboard) has no
135 commit, and the unit is deployed again.
136- During a gradual rollout the version with the most traffic counts.
137- After `wrangler rollback`, the plan sees the older commit and deploys
138 what changed since.
139
140### What a deploy does
141
1421. Plans: for each unit, the live commit; `git diff` from it to `HEAD`;
143 whether the changed files touch the unit (its folder, the crates and
144 packages it is built from, its inputs, lockfile changes that reach it).
1452. Refuses if uncommitted changes touch what would deploy (`--allow-dirty`).
1463. Applies every pending migration (`wrangler d1 migrations apply --remote`),
147 in parallel. Any failure stops the deploy before code.
1484. Installs worker-build once if any Rust unit is deploying.
1495. Each stage in turn: units in parallel (`--concurrency`), each `npm run
150 build` first for React Router and Astro, then `wrangler deploy` in the
151 unit's folder with the annotation. If any unit fails, later stages are
152 not started.
1536. Prints a table: unit, stage, result, version id, time. Each unit's full
154 output is kept in `$TMPDIR/g1t-deploy/<unit>.log`.
155
156#### Migrations run while the old code is still live
157
158Migrations apply before any code, and a stage takes a minute or more, so
159for that long the code in production is the old code reading the new
160schema. A migration must keep the old code right:
161
162- **Add, never change meaning.** New tables and columns (with defaults) are
163 safe. Rewriting what existing rows mean is not: on 2026-10-08,
164 `billing/0039` turned comped accounts into `custom` terms at 100%, and
165 for about 40 seconds the old billing code, which knew only `comped`, saw
166 flagon-io as a free workspace and refused its workflows.
167- **Change meaning in two deploys.** First ship code that reads both the old
168 and the new form (and keeps writing the old one); then, in a later
169 deploy, the migration that rewrites the rows; then, if you like, code
170 that drops the old form.
171- **Never drop or rename** a column or table the live code reads in the same
172 deploy that stops reading it.
173
174#### Telling the status page about a deploy
175
176Restarts during a deploy can make a part slow for a minute, which the
177status page's checks would otherwise draft as an incident. So
178`scripts/deploy.mjs deploy` says when it starts its stages and when they
179end (`withDeployWindow` in `scripts/deploy/status-window.mjs`; never in a
180dry run, and only once there is something to ship), by hand and in g1t
181Actions alike. It posts to `POST https://status.g1t.sh/deploys` with
182`Authorization: Bearer $STATUS_DEPLOY_TOKEN`, the same value as the status
183Worker's `STATUS_DEPLOY_TOKEN` secret, and `{"phase": "started" |
184"finished", "id": "<commit>"}`. During the deploy and for 3 minutes after
185it, detection keeps counting failed and slow checks but makes no new
186draft; trouble that outlasts that is drafted with its true start. Deploys
187that overlap (the jobs of one stage run at once, each announcing itself)
188are one window: the status Worker counts the starts, and the window closes
189when the last one finishes. A start with no finish stops counting 30
190minutes after the latest start. Without the token nothing is sent, and an
191announcement never fails a deploy: a refusal or network error is one
192warning line. By hand: `node scripts/deploy/status-window.mjs
193started|finished [id]`.
194
195To turn it on (once; until then deploys are not announced):
196
1971. Make a token and set it as the status Worker's secret:
198 `npx wrangler secret put STATUS_DEPLOY_TOKEN` in `apps/status` (as of
199 2026-10-08 the Worker has only `STATUS_SECRET`). Without it,
200 `POST /deploys` answers 404.
2012. Set the same value as the **`STATUS_DEPLOY_TOKEN`** Actions secret on
202 flagon-io/g1t (Settings, Secrets and variables, or
203 `PUT /repos/flagon-io/g1t/actions/secrets/STATUS_DEPLOY_TOKEN`);
204 `.g1t/workflows/deploy.yml` passes it to every deploy job.
2053. Add `status.g1t.sh | deploy.yml | production` to the project's
206 **Workflow-only domains** (see Network below), or the job's request is
207 refused by the guardrails (the deploy still goes on, with a warning).
2084. For deploys by hand, set `STATUS_DEPLOY_TOKEN` in your shell's
209 environment (the tool does not read `.env`).
210
211On a laptop the tool uses your `wrangler login` (or `CLOUDFLARE_DEPLOY_TOKEN`
212if set), as `scripts/deploy.sh` always did: a `CLOUDFLARE_API_TOKEN` or
213global API key in your shell, or in the repository's `.env`, is ignored.
214With `CI=true` it uses `CLOUDFLARE_API_TOKEN`.
215
216### The runner's images
217
218`services/runner` runs every sandbox (agents, checks, the merge queue,
219workflow jobs, g1t.page builds) from one Containers image, made in two
220parts:
221
222| Image | Built from | Holds | Rebuilt |
223| --- | --- | --- | --- |
224| **Base**, `g1t-runner:base-<date>-<inputs>` | `services/runner/base/Dockerfile` | Debian bookworm, Node 24, Python 3.11, Docker (Engine, Buildx, Compose, from Docker's apt repository), Go (from go.dev), Rust stable for the `node` user with rustfmt, clippy and the `wasm32-unknown-unknown` target, build-essential, musl-tools, git, ripgrep, jq, zstd, sudo, and the pinned Claude Code CLI on top | When its folder changes, weekly, or by hand (`build-base`) |
225| **Runner**, `g1t-runner:<content hash>` | `services/runner/Dockerfile`: `FROM` the base, plus one file | The g1t runner, a static binary | When the binary or the base changes |
226
227Both are pushed to one repository of Cloudflare's registry,
228`registry.cloudflare.com/<account>/g1t-runner`, so pushing the runner's
229image uploads only its own layer (about 5 MB): the base's layers are
230already there.
231
232Both are built as Wrangler builds images (`--platform linux/amd64
233--provenance=false --sbom=false`): one manifest, not an OCI index with a
234BuildKit attestation beside it. A push is tried up to three times and counts
235only when Docker reports the digest; the first push of the base once failed
236with `blob unknown to registry` and went through when run again (see
237`docs/CLOUDFLARE_FEEDBACK.md`, C3). `build-base` and `image` exit non-zero
238when a push fails, and `base.json` is written only after the push.
239
240**The base** is recorded in `services/runner/base.json`: its reference, its
241digest, a hash of its folder (`inputs`), when it was built, its size and
242each toolchain's version. `node scripts/deploy.mjs build-base` builds it,
243pushes it and rewrites the file; commit the file, and the next deploy
244builds the runner's image on it. `npm run test:deploy` fails while the
245folder and the file disagree, so a change to the base's Dockerfile cannot
246merge without the base it describes. Layers go from what changes least to
247most (system packages, Go, Rust, Java, .NET and Ruby, the Claude Code
248CLI), and the apt and npm caches stay in BuildKit's cache, out of the
249image. Ruby is compiled from source in a single step (a few minutes on a
250cold cache) that removes its source tree before the layer is written.
251
252The base's build cache is the base itself: it is built with
253`BUILDKIT_INLINE_CACHE`, which records in the image how each layer was
254made, and `build-base` builds `--cache-from` the base it replaces. A
255machine with an empty cache, or one just pruned, pulls the unchanged
256layers from the registry instead of building them. (BuildKit's other
257registry cache, `--cache-to type=registry`, pushes a separate cache
258manifest that not every registry takes, and for a one-stage image adds
259nothing the inline cache lacks.)
260
261**The runner binary** (`crates/runner`) is built outside Docker by
262`scripts/build-runner.mjs` as one static binary for
263`x86_64-unknown-linux-musl`, so it runs on the base whatever its libc, and
264on a self-hosted runner's machine too. Where it is built:
265
266- on x86-64 Linux with the musl target and `musl-gcc`
267 (`rustup target add x86_64-unknown-linux-musl`, `apt-get install musl-tools`),
268 with the machine's own Cargo;
269- anywhere else (Windows, macOS) in a small builder container, Rust on
270 Alpine (whose own target is musl), with Docker volumes keeping Cargo's
271 registry and target directory between builds. Windows has no musl
272 cross-linker, and `ring` (under ureq's TLS) needs a C compiler for the
273 target, so a container is the dependable route.
274
275**The runner's image tag** is a hash of everything it is built from: its
276Dockerfile, `base.json`, the crates the binary is built from, the
277workspace's Cargo files and the build script. The same source always names
278the same image, so:
279
2801. A deploy computes the tag and asks the registry whether it is there
281 (a `HEAD` of its manifest, with credentials from Wrangler; no Docker).
2822. If it is, nothing is built: the deploy uses it.
2833. If not, it builds the binary and the image (seconds on a warm machine)
284 and pushes it. In `deploy.yml` that is the `runner-image` job, on
285 `g1t-4core`, with the job's own Docker Engine (see
286 [Docker in workflow jobs](#docker-in-workflow-jobs)): it adds the musl
287 target, builds the binary natively (the base has `musl-gcc`), pulls the
288 base from Cloudflare's registry, builds, and pushes one layer.
2894. If Docker does not answer (a machine without it, or jobs with Docker
290 turned off), the unit fails saying to run `node scripts/deploy.mjs
291 image` on a machine with Docker; then re-run the workflow.
292
293Then `wrangler deploy` is given the image by reference, from a generated
294config (`services/runner/wrangler.deploy.json`, deleted after, ignored by
295git), so Wrangler builds nothing. When nothing the image is built from
296changed since the runner's live commit, the deploy also passes
297`--containers-rollout none`, which leaves running sandboxes alone.
298
299To get a new base out:
300
301```sh
302node scripts/deploy.mjs build-base # build, push, write base.json (needs Docker)
303git commit services/runner/base.json -m "A new base image for g1t's sandboxes"
304node scripts/deploy.mjs image # optional: push the runner's image now, so CI finds it
305```
306
307`.g1t/workflows/runner-base.yml` does the same weekly (and when the
308base's folder changes on `main`), and opens a pull request with
309`base.json`. It runs on a self-hosted runner with the `docker` label
310(`runs-on: [self-hosted, docker]`): g1t's own machines have Docker now,
311but the base's build downloads from Docker's apt repository over HTTPS,
312which does not trust a guarded job's egress certificate, so it stays on an
313open network. Its job is skipped until the repository variable
314`RUNNER_BASE_SELF_HOSTED` is `true` (set it once a runner is registered;
315without one, runs waited in the queue for a day and a half and then
316failed); until then run `build-base` by hand.
317
318**Sandboxes start from the image.** Cloudflare pulls an image to a
319machine the first time a sandbox lands there, and keeps it. A smaller base
320pulls sooner, and a change to the runner alone sends machines one 5 MB
321layer instead of the whole image.
322
323#### Larger machines
324
325The same image runs on three instance types, each a Durable Object class
326of its own in `services/runner/wrangler.jsonc`: `AttemptSandbox`
327(`standard-1`), `Sandbox2Core` (`standard-3`) and `Sandbox4Core`
328(`standard-4`). Workflow jobs choose with `runs-on: g1t-2core` or
329`g1t-4core` (`g1t_contracts::actions::INSTANCE_TYPES`); the actions
330service passes the label to the runner, which starts the job in that
331class. Billing prices the larger ones from their memory and disk, and
332their CPU (see the public billing guide). The account's Containers limits
333must allow `standard-4`; Wrangler refuses the deploy otherwise.
334
335#### Docker in workflow jobs
336
337Workflow jobs on g1t's machines have a Docker Engine of their own
338(`crates/runner/src/docker/`; the public guide is
339`apps/docs/src/content/docs/guides/actions.md`, "Docker"). What Cloudflare
340Containers allow decides how it runs (findings in `docs/PLAN.md`,
341"Docker in workflow jobs"):
342
343| Piece | What it does |
344| --- | --- |
345| `dockerd` | Started as root with `sudo`, only when the job first uses Docker or has `services:` or `container:`. Flags: `--iptables=false --ip6tables=false --ip-forward=false` (Containers allow neither), the containerd image store, Docker Hub through `mirror.gcr.io`. Its config, socket and log are in `/run/g1t-docker` (`dockerd.log` is the place to look); its data in `/var/lib/docker`, on overlays when the disk takes them, plain copies (`native`) when not. |
346| `/var/run/docker.sock` | The runner's API proxy (`docker/api.rs`), which starts the Engine on the first connection. Containers that ask for a bridge network get the job's own (`host`), the names they would have had resolve to 127.0.0.1, and ports published under another number are forwarded. |
347| `runc` | The Engine finds the runner binary first on its `PATH` as `runc` (`docker/oci.rs`): BuildKit's `RUN` steps join the job's network, and in a guarded job every container gets the egress certificate at `/dev/g1t-egress`. Then the real `/usr/bin/runc` runs. |
348| cgroups | Before the Engine starts, the sandbox's processes move to a cgroup of their own and every controller is handed down, as Docker's own Docker-in-Docker image does, so `--cpus` and `--memory` work. |
349
350- **Turning it off:** set the runner Worker's `DOCKER` var to `off`
351 (`services/runner/wrangler.jsonc`) and deploy the runner: new jobs get
352 no Engine (`G1T_DOCKER=off`), and jobs that need one fail saying Docker
353 does not answer. Anything else is on.
354- **Network:** containers share the job's network, so the guardrails, the
355 workflow-only domains and the egress Worker apply to them unchanged. The
356 public registries are in `BUILD_HOSTS` (`services/runner/src/egress.ts`).
357- **Isolation:** one Engine per job, inside the job's sandbox (its own
358 VM), gone with it. The job already had root through `sudo`; Docker adds
359 no reach beyond the sandbox, and no host socket is ever mounted into one.
360- **Checked locally** (2026-10-08, Docker Desktop, the base and runner
361 image built from this tree, a privileged container standing in for a
362 sandbox, a pretend API): services with health checks, `localhost` and
363 names, port forwarding, `docker build` with a networked `RUN`, Compose
364 with a healthy dependency, `docker://` steps, a Dockerfile action, a
365 `container:` job with a JavaScript action, an Alpine job container, the
366 egress certificate in `run`, `exec` and build steps and in no layer,
367 plain-copy storage, and the deploy's own build and push to a registry.
368 Not yet seen on Cloudflare itself: watch the first runs' logs for the
369 `Docker: started` line, and `dockerd.log` if it does not come.
370
371## Build speed
372
373Measured on the development machine (Windows, 32 cores, warm Cargo cache),
374building `events`, `search` and `repos` after a change to `crates/kit`, as a
375deploy does but without uploading (`wrangler deploy --dry-run`):
376
377| | Time |
378| --- | --- |
379| Before: one after another, `cargo install worker-build` each time, wasm-opt `-O` | 81 s, 84 s |
380| Concurrent builds, wasm-opt `-O` | 50 s, 68 s |
381| Concurrent builds, wasm-opt `-O1` (now) | 28 s, 33 s |
382
383Where the time went, and what changed:
384
385- **wasm-opt** was most of it: `-O` took 38 s on `repos` and 19 s on `api`;
386 `-O1` takes 1 to 3 s. With worker-build's flags (it keeps the names
387 section) the `.wasm` is 24 to 28% larger raw but only 1 to 7% larger
388 gzipped, and Workers' size limit is on the compressed upload. `-Os` and
389 `-Oz` were no faster than `-O`. Set per crate in
390 `[package.metadata.wasm-pack.profile.release]`; a test keeps every Rust
391 unit on the same level.
392- **worker-build** is installed only when missing or another version
393 (`scripts/build-rust-worker.mjs`, which pins it). Locally `cargo install`
394 on an installed version cost under a second; on a fresh CI sandbox it is
395 a full compile, which the workflow caches instead.
396- **One Cargo target**: every Rust unit is a member of the workspace, so
397 they already share `target/`. Concurrent builds take turns on Cargo's
398 lock for the compile, and their wasm-bindgen, wasm-opt and uploads
399 overlap.
400- **No joint `cargo build -p a -p b`**: tried, and it is slower. Cargo
401 unifies features across the packages of one build (`serde_json`'s
402 `preserve_order` from `api` and `actions`, `digest` features from
403 `secrets`), so each worker-build afterwards compiled its own variant
404 again.
405- **The runner's images**, measured on the same machine on 2026-10-06
406 (Docker Desktop, 8 vCPUs; its disk was busy with other containers, so
407 the cold figures are slow and noisy):
408
409 | | Before (one image) | Now |
410 | --- | --- | --- |
411 | Size, unpacked / compressed (what a machine pulls) | 3.08 GB / 819 MB | 2.68 GB / 686 MB |
412 | A change to the runner | Docker rebuilds the image's Rust stage and pushes the image (1198 s in the first deploy after a prune) | binary 46–53 s (6 s unchanged), image 7 s, push one 5 MB layer (1.4 s to a local registry) |
413 | The base from nothing | 431 s (whole image, cold) | 758 s cold, rarely: weekly or when its folder changes |
414 | The base after `docker builder prune` | as from nothing | 68 s, its layers pulled from the registry it was pushed to |
415 | The runner binary, cold (builder container) | | 99 s |
416 | A Rust CI job's build (events, search, repos; 4 vCPUs) | | 51 s cold, 13 s with the Cargo target restored (107 MB zstd entry) |
417
418- **Only what changed** is the largest saving: a change to one service
419 deploys one service.
420
421## The workflow
422
423`.g1t/workflows/deploy.yml` runs on every push to `main`, and by hand
424(**Actions → Deploy → Run workflow**) with `units` (deploy these, changed or
425not), `all` and `dry_run` (plan only).
426
427| Job | Does | Needs |
428| --- | --- | --- |
429| `check` | `manifest --check` and `npm run test:deploy` | — |
430| `plan` | `plan --github-output`: outputs per stage, the plan in the run's summary | `check` |
431| `migrate` | `migrate --only <units with pending migrations>` | `plan`; skipped when none are pending |
432| `core`, `edge`, `front` | `deploy --only <units> --force --no-migrations`, one job per build group | the stages before; skipped when empty |
433| `smoke` | `node scripts/ops/smoke.mjs`: the landing page, sign-in, sign-up and pricing load, and the waitlist form reaches identity (sent an address identity refuses before keeping or counting anything, so the real waitlist is never touched) | every stage; skipped when nothing deployed |
434
435- **One at a time:** `concurrency: deploy-production`, never cancelled in
436 progress; a second push waits.
437- **Build groups:** a stage's units are split so each job shares a build:
438 Rust workers at most four to a job (each a 4-vCPU `g1t-4core` machine), the
439 TypeScript Workers together, each site alone, and a unit whose image must
440 be rebuilt alone (`image: true` in the matrix, also on `g1t-4core`, where
441 it builds and pushes the image with the job's own Docker Engine). `fail-fast: false`, so one failed job does not cut
442 another off mid-upload; the next stage then does not start.
443- **Tests:** there is no CI workflow on g1t yet; `main` is kept passing by
444 the merge queue's checks. `check` runs the deploy tool's own tests. When a
445 CI workflow is added, make `plan` wait for it (`workflow_run`, or a job in
446 this file).
447- **Machines:** Rust jobs and the runner's image run on `g1t-4core` (4 vCPUs,
448 12 GiB, 20 GB), the others on the standard machine
449 (`runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}`).
450 The image job needs the room: the base it builds on is about 3.2 GB
451 unpacked.
452- **Caching** (`actions/cache`: up to 2 GB an entry, 10 GB a repository,
453 kept until unused for 7 days): the worker-build binary, worker-build's
454 downloaded tools, `~/.cargo/registry/cache`, and the Cargo target's
455 release dependencies (`target/release` and
456 `target/wasm32-unknown-unknown/release`, without `incremental` or
457 `.wasm`), keyed by the build group, `Cargo.lock` and `base.json`. The
458 workspace's own crates are compiled again on every run (a checkout's
459 sources are newer than any cache); the crates.io dependencies are not.
460 npm's cache is not kept: every job runs `npm ci` of only what its units
461 need (`deploy.mjs install`: Wrangler alone for Rust jobs).
462- **Conditions:** each stage runs with `!failure() && !cancelled()`, which
463 on g1t (as on GitHub) is true when no job before it failed, however far
464 back: a `migrate` job skipped for having nothing to apply does not stop
465 the stages after it, and a failed `check` stops all of them.
466- `crates/actions/tests/repository_workflows.rs` reads the workflow with
467 g1t's own parser and expressions, and checks the jobs start, wait and
468 stop as above (`cargo test -p g1t-actions --test repository_workflows`).
469
470### What the sandbox has
471
472The base image (`services/runner/base/Dockerfile`) has Node 24, npm, git,
473Go, zstd, Docker, musl-tools, Rust stable for the `node` user with
474rustfmt, clippy and the `wasm32-unknown-unknown` target, Java (Temurin 21),
475the .NET 8 SDK and Ruby 3.3, but not worker-build or `gh`. Java and Ruby
476sit in the tool cache (`/home/runner/_tool/Java_Temurin-Hotspot_jdk/<semver
477with + as ->/x64` and `/home/runner/_tool/Ruby/<version>/x64`, each with
478an `x64.complete` marker), which is where `actions/setup-java` and
479`ruby/setup-ruby` look; .NET is in `/usr/share/dotnet`, owned by `node`,
480which is where `actions/setup-dotnet` installs. Bumping one means changing
481its version and checksum `ARG`s together (Java's tool cache name is
482Adoptium's `version_data.semver`; .NET's SHA-512 is in its release
483metadata; Ruby's SHA-256 is in `cache.ruby-lang.org/pub/ruby/index.txt`).
484The user docs list what jobs get in
485`apps/docs/src/content/docs/guides/actions.md` (The runner). The workflow's `rustup target add wasm32-unknown-unknown` is
486then a no-op, and worker-build is restored from the cache, installed on a
487miss. The image job adds `x86_64-unknown-linux-musl` (about 30 MB from
488`static.rust-lang.org`) and keeps its Cargo target in the cache. worker-build
489fetches wasm-bindgen and wasm-opt from GitHub releases and esbuild from
490npm. All of those hosts are on the list every workflow job may reach.
491
492### Network
493
494A workflow job reaches its project's allowed domains, g1t, what builds
495need (`services/runner/src/egress.ts`, `BUILD_HOSTS`), and the project's
496**workflow-only domains** that name its workflow and environment. Those are
497never reached by agents, checks, the merge queue, deploy builds or runs of
498pull requests from forks (`Guardrails::workflow_hosts`, the runner's
499`jobHosts`). Under flagon-io/g1t's **Settings → Guardrails**
500(Maintain role or higher), **Workflow-only domains**:
501
502```text
503api.cloudflare.com | deploy.yml | production
504registry.cloudflare.com | deploy.yml, runner-base.yml | production
505status.g1t.sh | deploy.yml | production
506```
507
508`api.cloudflare.com` is Wrangler's API; `registry.cloudflare.com` is where
509the deploy asks whether the runner's image is already built, and where the
510`runner-image` job pulls the base from and pushes the runner's image to
511(as `runner-base.yml` pushes the base); `status.g1t.sh` hears the deploy
512start and finish (see "Telling the status page about a deploy"). The job's Docker Engine shares the
513job's network, so these lines are what let it reach the registry. If a pull
514is refused with `g1t guardrails: <host> is not on this project's allowed
515domains`, the registry sent the layers from another host: add that host on
516the same line. Only `deploy.yml`'s and `runner-base.yml`'s
517jobs with `environment: production` reach them, which are also the only
518jobs that can read `CLOUDFLARE_API_TOKEN`. Each change to the list is in
519the workspace's audit log as `update_guardrails`.
520
521### The API token
522
523Create it at **dash.cloudflare.com → My Profile → API Tokens → Create
524Token → Custom token**, named `g1t deploys (CI)`:
525
526| Scope | Permission | Why |
527| --- | --- | --- |
528| Account | Workers Scripts: Edit | Upload, versions, deployments, crons, bindings, `secret list` (doctor) |
529| Account | D1: Edit | `d1 migrations list` and `apply` |
530| Account | Queues: Edit | Attaching each unit's queue consumers on deploy |
531| Account | Workers R2 Storage: Read | Wrangler checks `og`'s bucket binding |
532| Account | Account Settings: Read | Wrangler reads the account |
533| Account | Containers: Edit | The runner's deploy updates its applications (the image reference, the three classes), and gets registry credentials (`wrangler containers registries credentials --push`, one hour) to look for, pull and push its image. `runner-base.yml` pushes images with it. |
534| Zone (`g1t.sh`, `g1t.page`) | Workers Routes: Edit | `pages`' zone routes, and custom domains |
535| Zone (`g1t.sh`, `g1t.page`) | DNS: Edit | Custom domains (`api`, `mcp`, `og`, `models`, `status`, `sudo`, `docs`, `g1t.sh`, `g1t.page`) keep their DNS records |
536| Zone (`g1t.sh`, `g1t.page`) | Zone: Read | Finding the zone a route names |
537
538Restrict it to account `syntaqx` (`1e6f2cffa3f445920836e8ebe446bb58`) and
539the two zones. Not needed for deploys: KV (bindings are by id; creating a
540namespace is a one-time setup), Vectorize, Workers for Platforms beyond
541Workers Scripts, Cloudflare for SaaS custom hostnames (the deployments
542service does that at runtime with its own token), SSL and Certificates.
543Workers KV Storage: Edit and Vectorize: Edit are only for first-time setup,
544which stays a person's job.
545
546The list follows what our configs use; Cloudflare does not publish exactly
547what `wrangler deploy` checks for each binding. Bindings with no listed
548permission (Browser Rendering, Workers AI, Vectorize, Email Sending,
549Artifacts, dispatch namespaces) are assumed to need none beyond Workers
550Scripts. Verify on the first run with `workflow_dispatch` and `units:
551pages` (small, no secrets), then `units: og` (R2) and `units: runner`
552(Containers); a missing permission fails with `Authentication error [code:
55310000]` and the route it was refused.
554
555Add it to the repository:
556
5571. On g1t.sh, open **flagon-io/g1t → Settings → Secrets and variables**.
5582. **Add**: key `CLOUDFLARE_API_TOKEN`, type **Secret**, available to
559 **Workflows**, environment **Production**. Only jobs with
560 `environment: production` (the deploy jobs) can read it.
5613. **Add**: key `CLOUDFLARE_ACCOUNT_ID`, type **Variable**, value
562 `1e6f2cffa3f445920836e8ebe446bb58`, available to **Workflows**, all
563 environments.
564
565Or through the API:
566
567```sh
568curl -X PUT https://api.g1t.sh/repos/flagon-io/g1t/actions/secrets/CLOUDFLARE_API_TOKEN \
569 -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
570 -d '{"value":"<the token>","environments":["production"],"available_to":["workflows"]}'
571
572curl -X POST https://api.g1t.sh/repos/flagon-io/g1t/actions/variables \
573 -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \
574 -d '{"name":"CLOUDFLARE_ACCOUNT_ID","value":"1e6f2cffa3f445920836e8ebe446bb58","available_to":["workflows"]}'
575```
576
577## Turning it on
578
5791. Create the token and add the secret and variable (above).
5802. Add `api.cloudflare.com` and `registry.cloudflare.com` to flagon-io/g1t's
581 workflow-only domains, for `deploy.yml` in `production` (above).
5823. Build and push the base once, and commit `services/runner/base.json`:
583 `node scripts/deploy.mjs build-base`. Create the cache bucket:
584 `npx wrangler r2 bucket create g1t-actions-cache`, with lifecycle rules
585 deleting cache entries (`c/`) 30 days after upload and artifacts (`a/`)
586 after 91, a day past the longest they are kept, and unfinished uploads
587 after a day (the actions service deletes both sooner; the rules catch
588 what it misses):
589 `npx wrangler r2 bucket lifecycle add g1t-actions-cache expire-cache c/ --expire-days 30 --abort-multipart-days 1`
590 and `npx wrangler r2 bucket lifecycle add g1t-actions-cache expire-artifacts a/ --expire-days 91 --abort-multipart-days 1`.
591 A bucket made before artifacts moved there has one rule for everything,
592 `expire`, which would delete artifacts kept longer than 30 days: remove
593 it (`npx wrangler r2 bucket lifecycle remove g1t-actions-cache --id expire`)
594 and add the two above.
5954. Adopt the live Workers once, from a laptop: deploy everything with the
596 tool so each version records its commit (`scripts/deploy.sh`, or
597 `node scripts/deploy.mjs deploy --all`). Until then every plan says "no
598 known commit" and deploys every unit. To see what has changed since a
599 commit you know production runs, without deploying:
600 `node scripts/deploy.mjs plan --since <sha>`.
6015. Run the workflow by hand with `dry_run`, then with `units: pages`.
602
603## First deploy of a new account
604
605What the configs refer to must exist first. `node scripts/deploy.mjs
606manifest --json` lists, per unit, its queues, KV, R2, Vectorize and
607dispatch namespaces; each unit's `setup` and `secrets` say the rest.
608
609- D1: `npx wrangler d1 create <database>`, then put its id in the unit's
610 `wrangler.jsonc`.
611- KV: `npx wrangler kv namespace create <name>` for each name under
612 `resources.kv`, then the ids in the configs.
613- Queues: `npx wrangler queues create <queue>` for each queue in the
614 manifest: `g1t-events`, `g1t-events-<service>` for every subscriber,
615 `g1t-search-jobs`, `g1t-context-jobs`.
616- R2: `npx wrangler r2 bucket create g1t-screenshots`,
617 `npx wrangler r2 bucket create g1t-actions-cache` (with its two
618 lifecycle rules, above), and `npx wrangler r2 bucket create g1t-git-packs`,
619 the clone pack cache (`services/repos/src/pack_cache.rs`), with a rule
620 deleting packs 7 days after they were written and unfinished uploads
621 after a day:
622 `npx wrangler r2 bucket lifecycle add g1t-git-packs expire-packs packs/ --expire-days 7 --abort-multipart-days 1`.
623 A Worker bound to a bucket that does not exist fails to deploy, so make
624 it before the first deploy of `g1t-repos` that binds it.
625- The runner's base image: `node scripts/deploy.mjs build-base`.
626- Vectorize, dispatch namespace, DNS, Access, Email Sending, Artifacts: each
627 unit's `setup`.
628- Secrets: `npx wrangler secret put <NAME>` in the unit's folder;
629 `node scripts/deploy.mjs doctor` lists what is missing.
630
631Then `node scripts/deploy.mjs deploy --all`. A Worker bound to a service
632that does not exist yet may be refused; deploy that service first with
633`--only`.
634
635## OIDC tokens for workflow jobs
636
637The API is the issuer of workflow jobs' OIDC tokens,
638`https://api.g1t.sh/actions/oidc` (`apps/api/src/oidc.rs`): no host or DNS
639of its own. It signs with an RSA key kept as the API's secret
640`ACTIONS_OIDC_KEY`. Without it, the issuer's addresses answer 404 and jobs
641are not told where to ask for a token.
642
643To turn it on, make a key on a trusted machine and store it, then delete
644the file:
645
646```sh
647openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out oidc.pem
648cd apps/api && npx wrangler secret put ACTIONS_OIDC_KEY < ../../oidc.pem
649rm ../../oidc.pem
650```
651
652Check `https://api.g1t.sh/actions/oidc/.well-known/jwks` lists one key.
653Its `kid` is the key's RFC 7638 thumbprint.
654
655To rotate it, keep the old key published while tokens it signed can still
656be presented (they last 5 minutes; relying parties cache keys for longer):
657
6581. Store the current key as `ACTIONS_OIDC_KEY_PREVIOUS` (the same PEM).
6592. Make a new key and store it as `ACTIONS_OIDC_KEY`. The JWKS now lists
660 both; new tokens are signed with the new one.
6613. A day later, `npx wrangler secret delete ACTIONS_OIDC_KEY_PREVIOUS`.
662
663A key that may have leaked is rotated the same way, skipping the first
664step, so that tokens it signed stop verifying at once.
665
666## Deployments on g1t
667
668Every deploy shows on the repository's Deployments page, so its production
669card says which commit runs.
670
671- **From the workflow**, the deploy jobs name `environment: {name:
672 production, url: https://g1t.sh}`, and g1t Actions records one production
673 deployment per run.
674- **By hand**, `deploy` reports one itself: in progress once migrations are
675 in, then success or failure. It needs a g1t token with `deployments:write`
676 in `G1T_DEPLOY_TOKEN` or the file `.credentials/g1t-deploy-token`; without
677 one, or from a dirty tree or a dry run, it sends nothing. It finds the
678 repository from the git remote on g1t.sh. A report that fails is one line
679 in the log and never fails the deploy.
680
681When CI cannot finish a deploy, for example a runner image that will not
682build there, deploy from a machine with Docker.
683The report records it, and production shows the commit that really runs.
684
685## Rolling back
686
687- **One unit, at once:** `npx wrangler rollback` in its folder (or
688 `npx wrangler rollback <version-id>`; `npx wrangler versions list` shows
689 each version's commit in its message). Code only: D1 migrations are not
690 undone. The next plan sees the older commit and deploys what changed since,
691 so revert the commit on `main` too, or the next push brings it back.
692- **To a commit:** check it out and `node scripts/deploy.mjs deploy --only
693 <units> --rollback`. Without `--rollback` the tool refuses any unit whose
694 live commit is newer than the one checked out, even with `--force`, so a
695 re-run of an old workflow run (or an old checkout) never rolls production
696 back by accident. Migrations never run backwards: a migration that needs
697 undoing is a new migration.
698- **The runner's image:** a rollback of the Worker does not roll back the
699 container image. Redeploy the older commit (`--only runner --rollback`): its
700 image's tag is the hash of that commit's source, which is still in the
701 registry, so nothing is built. A bad base is undone by reverting the
702 commit that changed `services/runner/base.json`.
703
704## Adding a unit
705
7061. Make its folder with a `wrangler.jsonc` (and its D1 migrations, if any).
707 A Rust Worker is a workspace member in the root `Cargo.toml` with
708 `"build": { "command": "node ../../scripts/build-rust-worker.mjs" }` and
709 the `wasm-opt = ["-O1"]` metadata; a TypeScript one is an npm workspace.
7102. Add it to `deploy/stack.jsonc`: path, kind, worker, `d1`, stage (the
711 earliest stage after everything it binds to), secrets, setup, self_host.
712 Name any new KV id under `resources.kv`.
7133. If its sources import a file outside its folder that is not a workspace
714 crate or package, list it under `inputs`.
7154. Add a row to the service table in `docs/SELF_HOSTING.md`.
7165. `npm run test:deploy` and `node scripts/deploy.mjs manifest --check`
717 say what is missing. Then create its resources and secrets, and
718 `node scripts/deploy.mjs deploy --only <unit>`.
719
720## The self-hosted runner
721
722`g1t-runner` (crates/runner) is also what customers run on their own
723machines (guide: `apps/docs/src/content/docs/guides/self-hosted-runners.md`).
724It is not deployed with the stack: it is released, and runners already out
725there update themselves to each release.
726
727| Piece | Where |
728| --- | --- |
729| The tool | `scripts/runner-release.mjs` (`keygen`, `build`, `sign`, `verify`, `publish`) |
730| The workflow | `.g1t/workflows/runner-release.yml`, on a tag `runner-v<version>` or by hand |
731| Where it is published | The R2 bucket `g1t-downloads`, served by the site at `g1t.sh/downloads/runner/<version>/<file>` and `/latest/<file>` (`apps/web/app/routes/downloads-runner.ts`) |
732| Its image | `deploy/runner/Dockerfile`, pushed to `g1t.sh/flagon-io/g1t-runner` (public) for amd64 and arm64 |
733
734A release is five binaries (Linux x64 and arm64, both static musl; macOS
735x64 and arm64; Windows x64), `SHA256SUMS`, and `manifest.json`;
736`latest.json` and its Ed25519 signature `latest.json.sig` name the newest.
737A runner updates only to a release whose signature checks out against the
738public key built into it and whose download matches the manifest's SHA-256.
739
740The first time:
741
7421. `node scripts/runner-release.mjs keygen`. Put `RUNNER_RELEASE_KEY` in the
743 repository's secrets (production environment) and keep a copy offline;
744 put the public key in its variables as `RUNNER_RELEASE_PUBLIC_KEY` (names
745 starting `G1T_` are reserved; the workflow hands it to the build as
746 `G1T_RUNNER_RELEASE_KEY`). A build made without the public key never
747 updates itself.
7482. `npx wrangler r2 bucket create g1t-downloads`, and deploy the site so it
749 has the `DOWNLOADS` binding.
7503. The image job pushes to g1t's own registry with the run's `G1T_TOKEN`;
751 the `g1t-runner` package in flagon-io is public. Set the variable
752 `RUNNER_AGENT_IMAGE` (a public copy of `g1t-runner-base`, the image agent
753 work runs in on customers' runners) once there is one.
7544. Register a self-hosted runner with the `docker` label for the image job.
755
756Each release:
757
7581. Bump `version` in `crates/runner/Cargo.toml` and merge it.
7592. Tag the commit `runner-v<version>` and push the tag. The workflow builds
760 every platform with cargo-zigbuild, signs, verifies, publishes the files
761 (the version's first, `latest.json` last), and pushes the image.
7623. By hand, the same is `node scripts/runner-release.mjs build`, then `sign`,
763 `verify` and `publish`, with the keys in the environment.
764
765Rolling back a release: copy the older version's `manifest.json` over
766`runner/latest.json` and sign it again (`sign` after checking the older
767version out). Runners never move to an older version on their own; a
768runner on a bad release is fixed by the next good one, or by downloading
769the older binary over it.