Skip to content

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

g1t.sh
876 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged1// node --test "scripts/deploy/*.test.mjs" (npm run test:deploy)
2import assert from "node:assert/strict";
3import { execFileSync } from "node:child_process";
4import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5import { tmpdir } from "node:os";
6import { join } from "node:path";
7import { test } from "node:test";
8
9import { isStaticElf } from "../build-runner.mjs";
10import {
11 DEPLOY_CONFIG,
12 baseInputs,
13 baseState,
14 baseTag,
15 contentHash,
16 lockFor,
17 pushImage,
18 pushedDigest,
19 readBaseLock,
20 registryHas,
21 removeDeployConfig,
22 runnerRef,
23 runnerTag,
24 writeDeployConfig,
25} from "./image.mjs";
26
Merge a faster deploy plan: Cloudflare's API read directly and in parallel (2 s against 4 min), and the plan runs beside the check27import {
28 annotation,
29 apiAuth,
30 commitFrom,
31 databaseOf,
32 liveCommit,
33 liveFromApi,
34 migrationFiles,
35 pendingAgainst,
36 pendingFrom,
37 pendingMigrations,
38 readLive,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.39 retryableDeploy,
Merge a faster deploy plan: Cloudflare's API read directly and in parallel (2 s against 4 min), and the plan runs beside the check40 versionFrom,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.41 wranglerError,
Merge a faster deploy plan: Cloudflare's API read directly and in parallel (2 s against 4 min), and the plan runs beside the check42} from "./cloudflare.mjs";
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged43import { changedNames, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs";
Merge remote-tracking branch 'origin/main' into workspace-chat44import { decide, git, planJson, pool } from "./plan.mjs";
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged45import {
46 ROOT,
47 buildGroups,
48 cargoWorkspace,
49 findWranglerConfigs,
50 loadStack,
51 npmCiArgs,
A deploy that changes the runner's image or its container settings rolls every idle sandbox over at once (--containers-rollout=immediate), where Cloudflare's default rollout went in steps over wall-clock time and, with deploys often half an hour apart, never reached every sandbox: jobs kept starting on old images and old machine sizes. A change to the container settings alone, such as the instance type, now rolls out too; before, an unchanged image meant --containers-rollout none and the new settings were never applied. Busy sandboxes still get rollout_active_grace_period to finish.52 containersRollout,
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged53 npmWorkspace,
54 outsideImports,
architecture and such55 parseJsonc,
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged56 pick,
57 problems,
58 resolveStack,
59 resolvedStack,
Merge remote-tracking branch 'origin/main' into workspace-chat60 testOnlySource,
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged61 touches,
62 touchesBase,
63 touchesImage,
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.64 waves,
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged65} from "./stack.mjs";
66
67const stack = resolvedStack();
68const unit = (id) => stack.units.find((u) => u.id === id);
69
70// ── The manifest ──────────────────────────────────────────────────────────
71
72test("the manifest matches every wrangler config in the repository", () => {
73 assert.deepEqual(problems(stack, findWranglerConfigs()), []);
74});
75
76test("every wrangler.jsonc is found, and only checked-in ones", () => {
77 const configs = findWranglerConfigs();
78 assert.ok(configs.includes("services/events/wrangler.jsonc"));
79 assert.ok(configs.includes("apps/web/wrangler.jsonc"));
80 assert.ok(!configs.some((c) => c.includes("/build/")), "build output is not a unit");
81 assert.equal(configs.length, stack.units.length);
82});
83
84test("problems are found: an unlisted config, a later-stage binding, a wrong database, an unnamed KV", () => {
85 const broken = loadStack();
86 const events = broken.units.find((u) => u.id === "events");
87 events.stage = "front";
88 const identity = broken.units.find((u) => u.id === "identity");
89 identity.d1 = { database: "g1t-identity", migrations: "migrations" };
90 broken.resources = { kv: {} };
91 const found = problems(broken, [...findWranglerConfigs(), "services/new/wrangler.jsonc"]);
92 assert.ok(found.some((p) => p.includes("services/new/wrangler.jsonc is not in")));
93 assert.ok(found.some((p) => p.includes("binds to events (front), which ships after it")));
94 assert.ok(found.some((p) => p.startsWith("Unit identity: d1 is")));
95 assert.ok(found.some((p) => p.includes("has no name under resources.kv")));
96});
97
98test("stages follow bindings: nothing binds to a unit that ships after it", () => {
99 const order = stack.stages;
100 for (const u of stack.units) {
101 for (const target of u.bindsTo) {
102 const other = stack.units.find((o) => o.worker === target);
103 assert.ok(order.indexOf(other.stage) <= order.indexOf(u.stage), `${u.id} -> ${other.id}`);
104 }
105 }
106});
107
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.108test("inside a stage, a Worker that does not exist yet goes before those bound to it", () => {
109 const u = (id, worker, bindsTo = []) => ({ id, worker, bindsTo });
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.110 const artifacts = u("artifacts", "g1t-artifacts", ["g1t-notify", "g1t-identity"]);
111 const notify = u("notify", "g1t-notify", ["g1t-artifacts", "g1t-identity"]);
112 const chat = u("chat", "g1t-chat", ["g1t-notify"]);
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.113 const ids = (list) => list.map((wave) => wave.map((x) => x.id));
114 // Nothing new: one wave, as before.
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.115 assert.deepEqual(ids(waves([artifacts, notify, chat], () => false)), [["artifacts", "notify", "chat"]]);
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.116 // A renamed Worker: it first, then what binds to it.
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.117 assert.deepEqual(ids(waves([notify, chat, artifacts], (x) => x.id === "artifacts")), [["chat", "artifacts"], ["notify"]]);
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.118 // New Workers bound to each other: no order helps, so together.
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.119 assert.deepEqual(ids(waves([artifacts, notify], () => true)), [["artifacts", "notify"]]);
120 // This repository's chat unit binds to the notify Worker, in the same stage.
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.121 const real = (id) => stack.units.find((x) => x.id === id);
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.122 assert.equal(real("chat").stage, real("notify").stage);
123 assert.deepEqual(ids(waves([real("chat"), real("notify")], (x) => x.id === "notify")), [["notify"], ["chat"]]);
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.124});
125
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged126test("Rust workers build with the shared script and the same wasm-opt level", () => {
127 for (const u of stack.units.filter((u) => u.kind === "rust-worker")) {
128 assert.equal(u.config.build.command, "node ../../scripts/build-rust-worker.mjs", u.id);
129 const toml = readFileSync(join(ROOT, u.path, "Cargo.toml"), "utf8");
130 assert.match(toml, /\[package\.metadata\.wasm-pack\.profile\.release\]\s*\nwasm-opt = \["-O1"\]/, u.id);
131 }
132});
133
134// ── What each unit is built from ──────────────────────────────────────────
135
136test("shared crates and packages are read from workspace metadata", () => {
137 assert.deepEqual(unit("events").dependsOn, ["crates/contracts", "crates/kit"]);
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge138 assert.deepEqual(unit("repos").dependsOn, ["crates/blobstore", "crates/contracts", "crates/kit", "crates/rules", "crates/scan", "crates/secrets"]);
139 assert.ok(unit("work").dependsOn.includes("crates/rules"));
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged140 assert.ok(unit("actions").dependsOn.includes("crates/actions"));
141 assert.ok(!unit("events").dependsOn.includes("crates/scan"));
142 assert.deepEqual(unit("web").dependsOn, ["packages/contracts", "packages/theme"]);
143 assert.deepEqual(unit("projects").dependsOn, ["packages/contracts"]);
144 assert.deepEqual(unit("pages").dependsOn, []);
145 assert.equal(unit("events").crate, "g1t-events");
146 assert.equal(unit("web").pkg, "@g1t/web");
147});
148
149test("the runner's image is built from the runner crate and what it uses, on the base its lock names", () => {
150 const runner = unit("runner");
151 // The runner bumps lockfiles with the scanner's lockfile parser (security updates).
152 assert.deepEqual(runner.image.dirs, ["crates/actions", "crates/runner", "crates/scan"]);
153 assert.deepEqual(runner.image.baseDirs, ["services/runner/base"]);
154 assert.ok(runner.dependsOn.includes("crates/runner"));
155 assert.ok(touchesImage(runner, ["crates/actions/src/expr.rs"]));
156 assert.ok(touchesImage(runner, ["services/runner/Dockerfile"]));
157 assert.ok(touchesImage(runner, ["services/runner/base.json"]));
158 assert.ok(touchesImage(runner, ["scripts/build-runner.mjs"]));
159 assert.ok(!touchesImage(runner, ["services/runner/src/index.ts"]));
160 // The base's Dockerfile reaches the image only through a new lock.
161 assert.ok(!touchesImage(runner, ["services/runner/base/Dockerfile"]));
162 assert.ok(touchesBase(runner, ["services/runner/base/Dockerfile"]));
163 assert.ok(!touchesBase(unit("events"), ["services/runner/base/Dockerfile"]));
164});
165
166// ── The runner's images ───────────────────────────────────────────────────
167
168test("services/runner/base.json records the base its folder builds", () => {
169 const runner = unit("runner");
170 const lock = readBaseLock(runner);
171 assert.ok(lock, "services/runner/base.json is missing: node scripts/deploy.mjs build-base");
172 assert.equal(
173 lock.inputs,
174 baseInputs(runner),
175 "services/runner/base changed since base.json was written: node scripts/deploy.mjs build-base, and commit base.json",
176 );
177 assert.match(lock.image, /^registry\.cloudflare\.com\/[0-9a-f]{32}\/g1t-runner:base-\d{8}-[0-9a-f]{12}$/);
178 assert.ok(lock.image.endsWith(lock.inputs.slice(0, 12)));
179 assert.equal(baseState(runner).current, true);
180});
181
182test("an image's hash ignores line endings, and its tags say what they are", () => {
183 const files = { "a.txt": "one\r\ntwo\r\n", "b.txt": "x" };
184 const lf = { "a.txt": "one\ntwo\n", "b.txt": "x" };
185 const hash = (set) => contentHash(Object.keys(set), "/", (file) => Buffer.from(set[file]));
186 assert.equal(hash(files), hash(lf));
187 assert.notEqual(hash(lf), hash({ ...lf, "b.txt": "y" }));
188 assert.equal(baseTag("0123456789abcdef".repeat(4), new Date("2026-10-06T12:00:00Z")), "base-20261006-0123456789ab");
189 const runner = unit("runner");
190 assert.match(runnerTag(runner), /^[0-9a-f]{16}$/);
191 assert.equal(runnerTag(runner), runnerTag(runner));
192 assert.equal(runnerRef(runner, ROOT, "a".repeat(32)), `registry.cloudflare.com/${"a".repeat(32)}/g1t-runner:${runnerTag(runner)}`);
193});
194
195test("only the lockfile entries the runner is built from name its image", () => {
196 const lock = (wasm) => `[[package]]
197name = "g1t-runner"
198version = "0.1.0"
199dependencies = [
200 "ureq",
201]
202
203[[package]]
204name = "ureq"
205version = "2.12.1"
206
207[[package]]
208name = "wasm-bindgen"
209version = "${wasm}"
210`;
211 assert.equal(lockFor(lock("0.2.100"), "g1t-runner"), lockFor(lock("0.2.101"), "g1t-runner"));
212 assert.ok(lockFor(lock("0.2.100"), "g1t-runner").includes("ureq"));
213 assert.notEqual(lockFor(lock("0.2.100"), "g1t-runner"), lockFor(lock("0.2.100").replace("2.12.1", "2.12.2"), "g1t-runner"));
214});
215
216test("the runner's deploy config names its image by reference, beside its wrangler.jsonc", () => {
217 const runner = unit("runner");
218 const ref = "registry.cloudflare.com/acct/g1t-runner:0123456789abcdef";
219 const name = writeDeployConfig(runner, ref);
220 try {
221 assert.equal(name, DEPLOY_CONFIG);
222 const config = JSON.parse(readFileSync(join(ROOT, runner.path, DEPLOY_CONFIG), "utf8"));
223 assert.equal(config.name, "g1t-runner");
224 assert.equal(config.main, "./src/index.ts");
225 assert.ok(config.containers.length >= 1);
226 for (const container of config.containers) {
227 assert.equal(container.image, ref);
228 assert.equal(container.image_build_context, undefined);
229 }
230 // Every sandbox class shares the image; each has a Durable Object binding.
231 const classes = config.containers.map((c) => c.class_name).sort();
232 assert.deepEqual(classes, config.durable_objects.bindings.map((b) => b.class_name).sort());
233 // Generated, never committed, and not taken for a unit of its own.
234 assert.ok(readFileSync(join(ROOT, ".gitignore"), "utf8").includes("wrangler.deploy.json"));
235 assert.ok(!findWranglerConfigs().some((c) => c.endsWith(DEPLOY_CONFIG)));
236 } finally {
237 removeDeployConfig(runner);
238 }
239 assert.ok(!existsSync(join(ROOT, runner.path, DEPLOY_CONFIG)));
240});
241
242test("the registry is asked for an image's manifest, without Docker", async () => {
243 const asked = [];
244 const fetchImpl = async (url, init) => {
245 asked.push([url, init.method, init.headers.authorization]);
246 return { status: url.endsWith(":nope") || url.endsWith("/nope") ? 404 : 200, ok: !url.endsWith("/nope") };
247 };
248 const credentials = async () => ({ username: "v1", password: "secret" });
249 assert.equal(await registryHas("registry.cloudflare.com/acct/g1t-runner:abc", { fetchImpl, credentials }), true);
250 assert.equal(await registryHas("registry.cloudflare.com/acct/g1t-runner:nope", { fetchImpl, credentials }), false);
251 assert.deepEqual(asked[0], [
252 "https://registry.cloudflare.com/v2/acct/g1t-runner/manifests/abc",
253 "HEAD",
254 `Basic ${Buffer.from("v1:secret").toString("base64")}`,
255 ]);
256 await assert.rejects(registryHas("not-a-reference", { fetchImpl, credentials }));
257});
258
259test("a push counts only when it ends with a digest, and is tried again", async () => {
260 const digest = "sha256:" + "a".repeat(64);
261 assert.equal(pushedDigest({ code: 0, out: `base: digest: ${digest} size: 856` }), digest);
262 assert.equal(pushedDigest({ code: 0, out: "error from registry: blob unknown to registry" }), null);
263 assert.equal(pushedDigest({ code: 1, out: `digest: ${digest}` }), null);
264 assert.equal(pushedDigest({ code: 0, out: "Pushed" }), null);
265 const answers = [{ code: 1, out: "error from registry: blob unknown to registry" }, { code: 0, out: `x: digest: ${digest} size: 1` }];
266 assert.equal(await pushImage("r/x:y", { run: async () => answers.shift() }), digest);
267 await assert.rejects(pushImage("r/x:y", { attempts: 2, run: async () => ({ code: 0, out: "blob unknown" }) }), /docker push r\/x:y failed/);
268});
269
270test("a static Linux binary is told from a dynamic one", () => {
271 const dir = mkdtempSync(join(tmpdir(), "g1t-elf-"));
272 try {
273 const elf = (interp) => {
274 // 64-bit little-endian x86-64 ELF with one program header.
275 const bytes = Buffer.alloc(64 + 56);
276 bytes.writeUInt32BE(0x7f454c46, 0);
277 bytes[4] = 2;
278 bytes[5] = 1;
279 bytes.writeUInt16LE(0x3e, 18);
280 bytes.writeBigUInt64LE(64n, 32);
281 bytes.writeUInt16LE(56, 54);
282 bytes.writeUInt16LE(1, 56);
283 bytes.writeUInt32LE(interp ? 3 : 1, 64);
284 return bytes;
285 };
286 writeFileSync(join(dir, "static"), elf(false));
287 writeFileSync(join(dir, "dynamic"), elf(true));
288 writeFileSync(join(dir, "text"), "#!/bin/sh\n");
289 assert.equal(isStaticElf(join(dir, "static")), true);
290 assert.equal(isStaticElf(join(dir, "dynamic")), false);
291 assert.equal(isStaticElf(join(dir, "text")), false);
292 } finally {
293 rmSync(dir, { recursive: true, force: true });
294 }
295});
296
297test("path dependencies agree with Cargo's own resolved graph", (t) => {
298 let full;
299 try {
300 full = JSON.parse(execFileSync("cargo", ["metadata", "--format-version", "1", "--offline"], { cwd: ROOT, encoding: "utf8", maxBuffer: 256 << 20 }));
301 } catch {
302 t.skip("cargo metadata could not resolve offline");
303 return;
304 }
305 const cargo = cargoWorkspace(ROOT);
306 const dirOf = new Map(full.packages.filter((p) => p.source === null).map((p) => [p.id, p.manifest_path]));
307 const nodes = new Map(full.resolve.nodes.map((n) => [n.id, n]));
308 for (const u of stack.units.filter((u) => u.crate)) {
309 const root = full.packages.find((p) => p.name === u.crate).id;
310 const seen = new Set();
311 const stackIds = [root];
312 while (stackIds.length) {
313 const id = stackIds.pop();
314 for (const dep of nodes.get(id).deps) {
315 if (!dep.dep_kinds.some((k) => k.kind !== "dev")) continue;
316 if (dirOf.has(dep.pkg) && !seen.has(dep.pkg)) {
317 seen.add(dep.pkg);
318 stackIds.push(dep.pkg);
319 }
320 }
321 }
322 const names = [...seen].map((id) => full.packages.find((p) => p.id === id).name);
323 const dirs = names.map((name) => cargo.get(name).dir).sort();
324 assert.deepEqual(dirs, u.dependsOn.filter((d) => d.startsWith("crates/")).sort(), u.id);
325 }
326});
327
328test("every import that leaves a unit's folder is covered by its dependencies or inputs", () => {
329 for (const u of stack.units) {
330 for (const { file, target } of outsideImports(ROOT, u)) {
331 const covered = u.dependsOn.some((dir) => target.startsWith(`${dir}/`)) || u.inputs.some((input) => target === input || target.startsWith(input.replace(/\.ts$/, "")));
332 assert.ok(covered, `${file} imports ${target}, which ${u.id}'s manifest entry does not name`);
333 }
334 }
335});
336
337// ── What a change touches ─────────────────────────────────────────────────
338
339const ids = (units, files) => units.filter((u) => touches(u, files)).map((u) => u.id);
340
341test("a shared crate's change reaches every unit built from it, and no other", () => {
342 assert.deepEqual(ids(stack.units, ["crates/scan/src/lib.rs"]), ["repos", "security", "runner"]);
343 assert.deepEqual(ids(stack.units, ["crates/secrets/src/lib.rs"]), ["identity", "repos", "integrations", "webhooks", "actions"]);
344 const kit = ids(stack.units, ["crates/kit/src/lib.rs"]);
345 assert.deepEqual(kit, stack.units.filter((u) => u.kind === "rust-worker").map((u) => u.id));
346 assert.deepEqual(ids(stack.units, ["crates/runner/src/main.rs"]), ["runner"]);
347});
348
349test("a shared package's change reaches what imports it", () => {
350 assert.deepEqual(ids(stack.units, ["packages/theme/tokens.css"]), ["status", "web", "sudo", "docs"]);
351 assert.ok(ids(stack.units, ["packages/contracts/src/index.ts"]).includes("og"));
352 assert.ok(!ids(stack.units, ["packages/contracts/src/index.ts"]).includes("events"));
353});
354
Merge remote-tracking branch 'origin/main' into workspace-chat355test("a crate's tests, benches and examples deploy nothing", () => {
356 // 0cdd221 changed only this file, and the plan sent three Rust Workers.
357 assert.deepEqual(ids(stack.units, ["crates/actions/tests/repository_workflows.rs"]), []);
358 assert.ok(!touchesImage(unit("runner"), ["crates/actions/tests/repository_workflows.rs"]));
359 assert.deepEqual(ids(stack.units, ["crates/kit/benches/wire.rs", "crates/scan/examples/scan.rs", "services/repos/tests/git.rs"]), []);
360 // Their sources still do, and a folder merely named like one.
361 assert.deepEqual(ids(stack.units, ["crates/actions/src/expr.rs"]), ["actions", "security", "runner"]);
362 assert.ok(touchesImage(unit("runner"), ["crates/actions/src/expr.rs"]));
363 assert.deepEqual(ids(stack.units, ["services/repos/src/tests/helpers.rs"]), ["repos"]);
364 assert.deepEqual(ids(stack.units, ["crates/kit/testsuite/a.rs"]), ids(stack.units, ["crates/kit/src/lib.rs"]));
365 // A unit that is not a crate keeps its whole folder.
366 assert.deepEqual(unit("web").crateDirs, []);
367 assert.deepEqual(unit("events").crateDirs, ["crates/contracts", "crates/kit", "services/events"]);
368 assert.ok(unit("runner").crateDirs.includes("crates/runner"));
369});
370
371/** Rust sources by path, as `testOnlySource` reads them. */
372const sources = (files) => ({
373 read: (path) => files[path] ?? "",
374 list: (dir) => Object.keys(files).filter((path) => path.slice(0, path.lastIndexOf("/")) === dir),
375});
376
377test("a Rust file compiled only for tests is found from what declares it", () => {
378 const crate = ["crates/x"];
379 const tree = sources({
380 "crates/x/src/lib.rs": "pub mod api;\nmod store;\n#[cfg(test)]\nmod tests;\n#[cfg(test)] pub(crate) mod fixtures;\n#[cfg(any(test, feature = \"x\"))]\nmod both;\n",
381 "crates/x/src/api.rs": "mod wire;\n#[cfg(test)]\n#[path = \"api_tests.rs\"]\nmod tests;\n",
382 "crates/x/src/api/wire.rs": "",
383 "crates/x/src/api_tests.rs": "use super::*;",
384 "crates/x/src/store/mod.rs": "#[cfg(test)]\nmod memory;\n",
385 "crates/x/src/store/memory.rs": "mod deep;",
386 "crates/x/src/store/memory/deep.rs": "",
387 "crates/x/src/tests.rs": "",
388 "crates/x/src/fixtures/mod.rs": "",
389 "crates/x/src/both.rs": "",
390 "crates/x/src/bin/tool.rs": "",
391 });
392 const only = (file) => testOnlySource(file, crate, tree);
393 for (const file of ["crates/x/src/tests.rs", "crates/x/src/fixtures/mod.rs", "crates/x/src/api_tests.rs", "crates/x/src/store/memory.rs", "crates/x/src/store/memory/deep.rs"]) {
394 assert.ok(only(file), file);
395 }
396 // Built: roots, ordinary modules, a cfg that is not only test, a file
397 // nothing declares (new, or deleted), and files outside src or the crate.
398 for (const file of [
399 "crates/x/src/lib.rs",
400 "crates/x/src/api.rs",
401 "crates/x/src/api/wire.rs",
402 "crates/x/src/store/mod.rs",
403 "crates/x/src/both.rs",
404 "crates/x/src/bin/tool.rs",
405 "crates/x/src/new.rs",
406 "crates/x/build.rs",
407 "crates/y/src/tests.rs",
408 "crates/x/src/notes.md",
409 ]) {
410 assert.ok(!only(file), file);
411 }
412});
413
414test("this repository's test-only sources are read from git", () => {
415 const head = git.head();
416 const atHead = { read: (path) => git.show(head, path), list: (dir) => git.list(head, dir) };
417 const only = (u, file) => testOnlySource(file, unit(u).crateDirs, atHead);
418 assert.ok(only("api", "apps/api/src/responses.rs"));
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.419 assert.ok(!only("billing", "services/billing/src/compute.rs"));
420 assert.ok(!only("billing", "services/billing/src/limits.rs"));
Merge remote-tracking branch 'origin/main' into workspace-chat421 assert.ok(!only("api", "apps/api/src/toolkit.rs"));
422});
423
424test("decide: a change only to tests deploys nothing", () => {
425 const units = ["actions", "billing", "runner"].map(unit);
426 const live = { actions: { sha: OLD }, billing: { sha: OLD }, runner: { sha: OLD } };
427 const files = {
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.428 [`${HEAD}:services/billing/src/compute.rs`]: "#[cfg(test)]\n#[path = \"compute_tests.rs\"]\nmod tests;\n",
429 [`${HEAD}:services/billing/src/lib.rs`]: "mod compute;\n",
Merge remote-tracking branch 'origin/main' into workspace-chat430 };
431 const gitApi = {
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.432 ...fakeGit(["crates/actions/tests/repository_workflows.rs", "services/billing/src/compute_tests.rs"]),
Merge remote-tracking branch 'origin/main' into workspace-chat433 show: (sha, path) => files[`${sha}:${path}`] ?? "",
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.434 list: (sha, dir) => Object.keys(files).map((k) => k.slice(sha.length + 1)).filter((p) => p.startsWith(`${dir}/`)).concat(dir === "services/billing/src" ? ["services/billing/src/compute_tests.rs"] : []),
Merge remote-tracking branch 'origin/main' into workspace-chat435 };
436 const decisions = decide(units, { live, head: HEAD, gitApi });
437 assert.deepEqual(decisions.map((d) => [d.unit.id, d.deploy, d.image]), [["actions", false, false], ["billing", false, false], ["runner", false, false]]);
438 // With a source that ships beside them, only that is listed.
The agents, models and context services are deleted. Nothing calls them any more: workspace agents, the hosted model proxy at models.g1t.sh with its AI Gateway, and the context hub with its search index are gone with the features they served. The deploy stack no longer builds or deploys them, so it no longer needs CONNECTORS_KEY, AI_GATEWAY_TOKEN, ANTHROPIC_API_KEY or WORKERS_AI_TOKEN, and a self-hosted install no longer generates CONNECTORS_KEY or stubs them out. Their databases, queues, index, gateway and domain stay in Cloudflare until they are deleted by hand after this is deployed.439 const mixed = decide([unit("billing")], { live, head: HEAD, gitApi: { ...gitApi, changed: () => ["services/billing/src/compute_tests.rs", "services/billing/src/compute.rs"] } });
440 assert.deepEqual([mixed[0].deploy, mixed[0].files], [true, ["services/billing/src/compute.rs"]]);
Merge remote-tracking branch 'origin/main' into workspace-chat441});
442
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged443test("own folders, declared inputs and root files", () => {
444 assert.deepEqual(ids(stack.units, ["services/pages/src/index.ts"]), ["pages"]);
445 assert.deepEqual(ids(stack.units, ["apps/web/app/lib/roadmap.ts"]), ["og", "web"]);
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.446 assert.deepEqual(ids(stack.units, ["LICENSE", "README.md", "deploy/stack.jsonc"]), []);
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged447 assert.deepEqual(ids(stack.units, ["scripts/build-rust-worker.mjs"]), stack.units.filter((u) => u.kind === "rust-worker").map((u) => u.id));
448 // services/events is not a prefix of services/eventsx.
449 assert.equal(touches(unit("events"), ["services/eventsx/a.rs"]), null);
450});
451
452// ── Lockfiles ─────────────────────────────────────────────────────────────
453
454const lock = (sha2) => `version = 4
455
456[[package]]
457name = "g1t-events"
458version = "0.1.0"
459dependencies = [
460 "g1t-kit",
461]
462
463[[package]]
464name = "g1t-kit"
465version = "0.1.0"
466dependencies = [
467 "serde",
468]
469
470[[package]]
471name = "g1t-webhooks"
472version = "0.1.0"
473dependencies = [
474 "g1t-kit",
475 "g1t-secrets",
476]
477
478[[package]]
479name = "g1t-secrets"
480version = "0.1.0"
481dependencies = [
482 "sha2 ${sha2}",
483]
484
485[[package]]
486name = "serde"
487version = "1.0.0"
488source = "registry+https://github.com/rust-lang/crates.io-index"
489
490[[package]]
491name = "sha2"
492version = "${sha2}"
493source = "registry+https://github.com/rust-lang/crates.io-index"
494`;
495
496test("a Cargo.lock change reaches only crates that use what changed", () => {
497 const before = parseCargoLock(lock("0.10.8"));
498 const after = parseCargoLock(lock("0.10.9"));
499 const names = changedNames(before, after);
500 assert.deepEqual([...names], ["sha2"]);
501 assert.ok(reaches(after, ["g1t-webhooks"], names));
502 assert.ok(!reaches(after, ["g1t-events"], names));
503});
504
505test("a package-lock change reaches through workspace links", () => {
506 const make = (version) =>
507 JSON.stringify({
508 lockfileVersion: 3,
509 packages: {
510 "": { devDependencies: { wrangler: "^4" } },
511 "apps/web": { dependencies: { "@g1t/theme": "*", react: "^19" } },
512 "services/pages": { dependencies: {} },
513 "packages/theme": { dependencies: { fontkit: "^1" } },
514 "node_modules/@g1t/theme": { resolved: "packages/theme", link: true },
515 "node_modules/react": { version: "19.0.0" },
516 "node_modules/fontkit": { version },
517 "node_modules/wrangler": { version: "4.1.0" },
518 },
519 });
520 const before = parseNpmLock(make("1.0.0"));
521 const after = parseNpmLock(make("1.0.1"));
522 const names = changedNames(before, after);
523 assert.deepEqual([...names], ["fontkit"]);
524 assert.ok(reaches(after, ["apps/web", ""], names));
525 assert.ok(!reaches(after, ["services/pages", ""], names));
526});
527
528// ── Deciding ──────────────────────────────────────────────────────────────
529
530const HEAD = "a".repeat(40);
531const OLD = "b".repeat(40);
532const fakeGit = (files, { has = true, locks = {} } = {}) => ({
533 has: () => has,
534 changed: () => files,
535 show: (sha, path) => locks[`${sha}:${path}`] ?? "",
536});
537
538test("decide: changed units deploy, others wait, unknown ones deploy", () => {
539 const units = ["events", "repos", "pages", "web"].map(unit);
540 const live = { events: { sha: OLD }, repos: { sha: OLD }, pages: { sha: HEAD }, web: { sha: null, why: "never deployed" } };
541 const decisions = decide(units, { live, head: HEAD, gitApi: fakeGit(["crates/scan/src/lib.rs"]) });
542 const by = Object.fromEntries(decisions.map((d) => [d.unit.id, d]));
543 assert.equal(by.events.deploy, false);
544 assert.equal(by.events.reason, "nothing it is built from changed");
545 assert.equal(by.repos.deploy, true);
546 assert.match(by.repos.reason, /crates\/scan\/src\/lib.rs via crates\/scan/);
547 assert.equal(by.pages.deploy, false);
548 assert.equal(by.pages.reason, "up to date");
549 assert.equal(by.web.deploy, true);
550 assert.match(by.web.reason, /never deployed/);
551});
552
553test("decide: --force deploys unchanged units; a commit missing from history deploys", () => {
554 const forced = decide([unit("events")], { live: { events: { sha: HEAD } }, head: HEAD, force: true, gitApi: fakeGit([]) });
555 assert.equal(forced[0].deploy, true);
556 const shallow = decide([unit("events")], { live: { events: { sha: OLD } }, head: HEAD, gitApi: fakeGit([], { has: false }) });
557 assert.equal(shallow[0].deploy, true);
558 assert.match(shallow[0].reason, /does not have/);
559});
560
561test("decide: Cargo.lock counts only where it reaches", () => {
562 const locks = { [`${OLD}:Cargo.lock`]: lock("0.10.8"), [`${HEAD}:Cargo.lock`]: lock("0.10.9") };
563 const units = [unit("events"), unit("webhooks")];
564 const live = { events: { sha: OLD }, webhooks: { sha: OLD } };
565 const decisions = decide(units, { live, head: HEAD, gitApi: fakeGit(["Cargo.lock"], { locks }) });
566 assert.deepEqual(decisions.map((d) => [d.unit.id, d.deploy]), [["events", false], ["webhooks", true]]);
567});
568
569test("decide: the runner's image rebuilds only when what it is built from changed", () => {
570 const live = { runner: { sha: OLD } };
571 const code = decide([unit("runner")], { live, head: HEAD, gitApi: fakeGit(["services/runner/src/index.ts"]) });
572 assert.deepEqual([code[0].deploy, code[0].image], [true, false]);
573 const image = decide([unit("runner")], { live, head: HEAD, gitApi: fakeGit(["crates/runner/src/main.rs"]) });
574 assert.deepEqual([image[0].deploy, image[0].image], [true, true]);
575});
576
577test("the plan as data: migrations, and each stage's units in jobs that share a build", () => {
578 const units = ["events", "repos", "projects", "api", "web", "docs"].map(unit);
579 const decisions = units.map((u) => ({ unit: u, deploy: true, reason: "x", since: null, image: false }));
580 const data = planJson(stack, decisions, { events: { pending: ["0003_x.sql"] }, repos: { pending: [] } }, HEAD);
581 assert.deepEqual(data.migrations, [{ unit: "events", database: "g1t-events", pending: ["0003_x.sql"] }]);
582 assert.deepEqual(data.stages.core.jobs, [
Merge branch 'main' into actions-toolkit-oidc-artifacts583 { group: "rust", units: "events,repos", rust: true, image: false },
584 { group: "ts", units: "projects", rust: false, image: false },
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged585 ]);
Merge branch 'main' into actions-toolkit-oidc-artifacts586 assert.deepEqual(data.stages.edge.jobs, [{ group: "rust", units: "api", rust: true, image: false }]);
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged587 assert.deepEqual(data.stages.front.jobs, [
Merge branch 'main' into actions-toolkit-oidc-artifacts588 { group: "web", units: "web", rust: false, image: false },
589 { group: "docs", units: "docs", rust: false, image: false },
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged590 ]);
591 assert.deepEqual(data.stage_order, ["core", "edge", "front"]);
592 assert.deepEqual(buildGroups([]), []);
593 // Eleven Rust workers go to three jobs; a unit whose image rebuilds gets its own.
594 const core = stack.units.filter((u) => u.stage === "core");
595 const jobs = buildGroups(core, ["runner"]);
596 assert.deepEqual(jobs.filter((j) => j.rust).map((j) => j.units.split(",").length), [4, 4, 3]);
Merge branch 'main' into actions-toolkit-oidc-artifacts597 assert.ok(jobs.some((j) => j.group === "runner-image" && j.units === "runner" && j.image && !j.rust));
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged598 assert.ok(!jobs.find((j) => j.group === "ts").units.includes("runner"));
599});
600
601test("units are picked by short name, folder or Worker name", () => {
602 assert.deepEqual(pick(stack, ["billing,services/web".replace("services/web", "apps/web"), "g1t-api"]).map((u) => u.id), ["billing", "web", "api"]);
603 assert.throws(() => pick(stack, ["nope"]), /No unit called nope/);
604});
605
606test("a CI job installs only what its units need", () => {
607 const npm = npmWorkspace();
The deploy's stages ride out a dropped npm connection as CI does. Each core, edge and front job keeps npm's downloads between runs (actions/cache on ~/.npm, keyed on the lockfile), and the deploy tool's install reads them first (npm ci --prefer-offline), so a registry connection reset mid-install no longer fails a stage, as it failed the web stage of Deploy 201.608 assert.deepEqual(npmCiArgs([unit("events"), unit("repos")], npm), ["ci", "--prefer-offline", "--no-audit", "--no-fund", "--workspaces=false"]);
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged609 assert.deepEqual(npmCiArgs([unit("events"), unit("status")], npm), [
The deploy's stages ride out a dropped npm connection as CI does. Each core, edge and front job keeps npm's downloads between runs (actions/cache on ~/.npm, keyed on the lockfile), and the deploy tool's install reads them first (npm ci --prefer-offline), so a registry connection reset mid-install no longer fails a stage, as it failed the web stage of Deploy 201.610 "ci", "--prefer-offline", "--no-audit", "--no-fund", "--include-workspace-root",
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged611 "-w", "@g1t/contracts", "-w", "@g1t/status", "-w", "@g1t/theme",
612 ]);
613});
614
615// ── Cloudflare's answers ──────────────────────────────────────────────────
616
617const version = (id, number, message, triggered = "deployment") => ({
618 id,
619 number,
620 metadata: { created_on: "2026-10-05T00:00:00Z", author_email: "a@b" },
621 annotations: { "workers/triggered_by": triggered, ...(message ? { "workers/message": message } : {}) },
622});
623
624test("a deploy is annotated with its commit, and read back", () => {
625 const { message, tag } = annotation(HEAD, "A subject ".repeat(20));
626 assert.ok(message.length <= 100);
627 assert.equal(commitFrom(message), HEAD);
628 assert.equal(tag, `g1t-${HEAD.slice(0, 12)}`);
629 assert.equal(commitFrom(message.replace("g1t-deploy", "g1t-deploy-dirty")), null);
630 assert.equal(commitFrom("deployed by hand"), null);
631});
632
633test("the live commit: the live version's, looking through secret changes", () => {
634 const versions = [version("v1", 1, annotation(OLD).message), version("v2", 2, null, "secret"), version("v3", 3, null, "version_upload")];
635 const status = (id) => ({ versions: [{ version_id: id, percentage: 100 }] });
636 assert.equal(liveCommit(status("v1"), versions).sha, OLD);
637 assert.equal(liveCommit(status("v2"), versions).sha, OLD);
638 assert.equal(liveCommit(status("v3"), versions).sha, null);
639 assert.match(liveCommit(status("v9"), versions).why, /not among/);
640 const split = liveCommit({ versions: [{ version_id: "v1", percentage: 10 }, { version_id: "v2", percentage: 90 }] }, versions);
641 assert.equal(split.sha, OLD);
642 assert.equal(split.split, true);
643});
644
645test("Wrangler's output: pending migrations and the version a deploy made", () => {
646 const pending = `
647 ⛅️ wrangler 4.146.0
648Resource location: remote
649Migrations to be applied:
650┌──────────────────────┐
651│ Name │
652├──────────────────────┤
653│ 0021_confidence.sql │
654├──────────────────────┤
655│ 0022_more.sql │
656└──────────────────────┘`;
657 assert.deepEqual(pendingFrom(pending), ["0021_confidence.sql", "0022_more.sql"]);
658 assert.deepEqual(pendingFrom("Resource location: remote\n\n✅ No migrations to apply!"), []);
659 assert.equal(versionFrom("Deployed g1t-events triggers\nCurrent Version ID: 2c7fc93a-82d9-4850-8a77-ba887d157a4f\n"), "2c7fc93a-82d9-4850-8a77-ba887d157a4f");
660});
661
Merge a faster deploy plan: Cloudflare's API read directly and in parallel (2 s against 4 min), and the plan runs beside the check662// Cloudflare's REST answers, as the API sends them (Wrangler's --json
663// prints the first deployment, and the versions' items).
664const AUTH = { token: "t", account: "acct" };
665const ok = (result) => ({ success: true, errors: [], messages: [], result });
666const refused = (code, message) => ({ success: false, errors: [{ code, message }], messages: [], result: null });
667/** A fetch that answers by path: { "GET /path": [status, body] }. */
668function fakeFetch(answers, calls = []) {
669 return async (url, init) => {
670 const path = url.replace("https://api.cloudflare.com/client/v4", "");
671 calls.push({ path, init });
672 const found = answers[`${init.method} ${path}`];
673 if (!found) throw new Error(`unexpected ${init.method} ${path}`);
674 const [status, body] = found;
675 return { status, text: async () => (typeof body === "string" ? body : JSON.stringify(body)) };
676 };
677}
678const SCRIPT = "/accounts/acct/workers/scripts/g1t-events";
679const deploymentsAnswer = (id) =>
680 ok({
681 deployments: [
682 { id: "d2", source: "wrangler", strategy: "percentage", versions: [{ version_id: id, percentage: 100 }], annotations: { "workers/triggered_by": "deployment" } },
683 { id: "d1", source: "wrangler", strategy: "percentage", versions: [{ version_id: "v1", percentage: 100 }] },
684 ],
685 });
686const versionsAnswer = ok({ items: [version("v1", 1, annotation(OLD).message), version("v2", 2, null, "secret")] });
687
688test("the API's answers: the live commit as Wrangler's would give it", async () => {
689 const calls = [];
690 const fetchImpl = fakeFetch({ [`GET ${SCRIPT}/deployments`]: [200, deploymentsAnswer("v2")], [`GET ${SCRIPT}/versions?deployable=true`]: [200, versionsAnswer] }, calls);
691 const found = await readLive(unit("events"), { auth: AUTH, fetchImpl });
692 assert.equal(found.sha, OLD);
693 assert.equal(found.version, "v2");
694 assert.equal(found.at, "2026-10-05T00:00:00Z");
695 assert.equal(calls[0].init.headers.authorization, "Bearer t");
696 // The same as liveCommit over what Wrangler printed.
697 assert.deepEqual(liveFromApi("w", deploymentsAnswer("v2").result, versionsAnswer.result), liveCommit(deploymentsAnswer("v2").result.deployments[0], versionsAnswer.result.items));
698 // Versions that cannot be read: the live one is not among them.
699 assert.match(liveFromApi("w", deploymentsAnswer("v2").result, null).why, /not among/);
700 // No deployment at all is an error, as `deployments status` made it.
701 assert.match(liveFromApi("w", { deployments: [] }, versionsAnswer.result).error, /no deployments/);
702});
703
704test("the API's answers: a Worker never deployed, and a refusal, never throw", async () => {
705 const missing = fakeFetch({
706 [`GET ${SCRIPT}/deployments`]: [404, refused(10007, "workers.api.error.script_not_found")],
707 [`GET ${SCRIPT}/versions?deployable=true`]: [404, refused(10007, "workers.api.error.script_not_found")],
708 });
709 assert.deepEqual(await readLive(unit("events"), { auth: AUTH, fetchImpl: missing }), { sha: null, missing: true, why: "never deployed" });
710 const denied = fakeFetch({
711 [`GET ${SCRIPT}/deployments`]: [400, refused(10000, "Authentication error")],
712 [`GET ${SCRIPT}/versions?deployable=true`]: [400, refused(10000, "Authentication error")],
713 });
714 const found = await readLive(unit("events"), { auth: AUTH, fetchImpl: denied });
715 assert.equal(found.sha, null);
716 assert.match(found.error, /Authentication error \(10000\)/);
717 const broken = async () => {
718 throw new Error("connect ECONNREFUSED");
719 };
720 const offline = await readLive(unit("events"), { auth: AUTH, fetchImpl: broken });
721 assert.match(offline.error, /ECONNREFUSED/);
722 const garbled = fakeFetch({ [`GET ${SCRIPT}/deployments`]: [200, "<html>"], [`GET ${SCRIPT}/versions?deployable=true`]: [200, versionsAnswer] });
723 assert.match((await readLive(unit("events"), { auth: AUTH, fetchImpl: garbled })).error, /<html>/);
724});
725
726test("the API is used only with the token Wrangler would be given", () => {
727 assert.equal(apiAuth({ CLOUDFLARE_API_TOKEN: "shell" }), null);
728 assert.deepEqual(apiAuth({ CI: "true", CLOUDFLARE_API_TOKEN: "ci", CLOUDFLARE_ACCOUNT_ID: "a" }), { token: "ci", account: "a" });
729 assert.equal(apiAuth({ CLOUDFLARE_DEPLOY_TOKEN: "mine" }).token, "mine");
730 assert.equal(apiAuth({ CLOUDFLARE_DEPLOY_TOKEN: "mine" }).account, "1e6f2cffa3f445920836e8ebe446bb58");
731});
732
733test("pending migrations: the folder's files less the names in d1_migrations", async () => {
734 const events = unit("events");
735 const db = databaseOf(events);
736 assert.equal(db.id, events.config.d1_databases[0].database_id);
737 assert.equal(db.table, "d1_migrations");
738 const files = migrationFiles(join(ROOT, db.dir));
739 assert.ok(files.length > 2 && files.every((f) => f.endsWith(".sql")));
740 assert.deepEqual(files, [...files].sort());
741
742 assert.deepEqual(pendingAgainst(["0001_a.sql", "0002_b.sql", "0003_c.sql"], [{ results: [{ name: "0001_a.sql" }, { name: "0002_b.sql" }], success: true, meta: {} }]), ["0003_c.sql"]);
743 assert.deepEqual(pendingAgainst(["0001_a.sql"], [{ results: [], success: true }]), ["0001_a.sql"]);
744
745 const query = `POST /accounts/acct/d1/database/${db.id}/query`;
746 const calls = [];
747 const applied = files.slice(0, -1).map((name, i) => ({ name, id: i + 1 }));
748 const found = await pendingMigrations(events, { auth: AUTH, fetchImpl: fakeFetch({ [query]: [200, ok([{ results: applied, success: true, meta: {} }])] }, calls) });
749 assert.deepEqual(found, { pending: files.slice(-1) });
750 assert.match(JSON.parse(calls[0].init.body).sql, /^SELECT name FROM "d1_migrations"/);
751 const upToDate = await pendingMigrations(events, { auth: AUTH, fetchImpl: fakeFetch({ [query]: [200, ok([{ results: files.map((name) => ({ name })) }])] }) });
752 assert.deepEqual(upToDate, { pending: [] });
753 // A database never migrated has no table: everything is pending.
754 const fresh = await pendingMigrations(events, { auth: AUTH, fetchImpl: fakeFetch({ [query]: [400, refused(7500, "no such table: d1_migrations: SQLITE_ERROR")] }) });
755 assert.deepEqual(fresh, { pending: files });
756 const denied = await pendingMigrations(events, { auth: AUTH, fetchImpl: fakeFetch({ [query]: [400, refused(10000, "Authentication error")] }) });
757 assert.match(denied.error, /Authentication error/);
758});
759
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged760test("the pool runs everything, no more than its limit at once", async () => {
761 let running = 0;
762 let most = 0;
763 const out = await pool([1, 2, 3, 4, 5, 6, 7], 3, async (n) => {
764 running++;
765 most = Math.max(most, running);
766 await new Promise((resolve) => setTimeout(resolve, 5));
767 running--;
768 return n * 2;
769 });
770 assert.deepEqual(out, [2, 4, 6, 8, 10, 12, 14]);
771 assert.equal(most, 3);
772});
773
774// ── Everything else that reads the list ───────────────────────────────────
775
776test("self-hosting builds every Rust service the manifest says it runs", () => {
777 const dockerfile = readFileSync(join(ROOT, "deploy/self-host/Dockerfile"), "utf8");
778 const loops = [...dockerfile.matchAll(/for service in ([a-z ]+); do/g)].map((m) => m[1].trim().split(/\s+/).sort());
779 const wanted = stack.units.filter((u) => u.self_host === "run" && u.kind === "rust-worker").map((u) => u.path.split("/").pop()).sort();
780 assert.ok(loops.length >= 2);
781 for (const loop of loops) assert.deepEqual(loop, wanted);
782});
783
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.784test("the self-hosting architecture page names every unit", () => {
785 const page = "apps/docs/src/content/docs/guides/self-hosting-architecture.md";
786 const doc = readFileSync(join(ROOT, page), "utf8");
787 for (const u of stack.units) assert.ok(doc.includes(`| \`${u.path}\` |`), `${u.path} is missing from ${page}`);
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged788});
789
architecture and such790test("the architecture's services page names every unit, with the services it binds to", () => {
791 const page = "apps/docs/src/content/docs/architecture/services.md";
792 const doc = readFileSync(join(ROOT, page), "utf8");
793 for (const u of stack.units) {
794 const row = doc.split(/\r?\n/).find((line) => line.startsWith(`| \`${u.path}\` |`));
795 assert.ok(row, `${u.path} is missing from ${page}`);
796 // The last cell lists the services it calls, by name without "g1t-".
797 const cells = row.split("|").map((cell) => cell.trim());
798 const listed = [...cells[cells.length - 2].matchAll(/`([a-z-]+)`/g)].map((m) => m[1]).sort();
799 const config = parseJsonc(readFileSync(join(ROOT, u.path, "wrangler.jsonc"), "utf8"));
800 const bound = [...new Set((config.services ?? []).map((s) => s.service.replace(/^g1t-/, "")))].sort();
801 assert.deepEqual(listed, bound, `${page}: the Calls cell for ${u.path} should list ${bound.join(", ") || "none"}`);
802 }
803});
804
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged805test("resolveStack works on a manifest with no workspace crates or packages", () => {
806 const bare = resolveStack(loadStack(), { cargo: new Map(), npm: new Map() });
807 assert.deepEqual(bare.units.find((u) => u.id === "events").dependsOn, []);
808});
Deploying: never roll production back by accident809
810test("decide: a commit older than what runs is never deployed by accident, even forced; --rollback means it", () => {
811 const newer = { has: () => true, changed: () => ["services/events/src/lib.rs"], show: () => "", isAncestor: (older, newer) => older === HEAD && newer === OLD };
812 const refused = decide([unit("events")], { live: { events: { sha: OLD } }, head: HEAD, force: true, gitApi: newer });
813 assert.equal(refused[0].deploy, false);
814 assert.match(refused[0].reason, /newer than this commit/);
815 const meant = decide([unit("events")], { live: { events: { sha: OLD } }, head: HEAD, rollback: true, gitApi: newer });
816 assert.equal(meant[0].deploy, true);
817 assert.match(meant[0].reason, /rolling back/);
818});
Deploying: a deploy by hand shows on the repository's Deployments page819
820test("a deploy by hand reports to the repository on g1t, and a workflow's does not", async () => {
821 const { g1tRemote, inWorkflow, deployToken, reportDeployment } = await import("./report.mjs");
822 assert.deepEqual(g1tRemote(["git@github.com:flagon-io/g1t.git", "https://g1t.sh/flagon-io/g1t.git"]), { api: "https://api.g1t.sh", repo: "flagon-io/g1t" });
823 assert.equal(g1tRemote(["git@github.com:flagon-io/g1t.git"]), null);
824 assert.equal(inWorkflow({ GITHUB_ACTIONS: "true" }), true);
825 assert.equal(inWorkflow({}), false);
826 assert.equal(deployToken({ G1T_DEPLOY_TOKEN: " g1t_x \n" }, "/nowhere"), "g1t_x");
827 assert.equal(deployToken({}, "/nowhere"), null);
828 let called = false;
829 const settle = await reportDeployment({ head: "abc", subject: "s", units: ["web"], log: () => {}, env: { GITHUB_ACTIONS: "true", G1T_DEPLOY_TOKEN: "t" }, fetchImpl: () => { called = true; } });
830 assert.equal(settle, null);
831 assert.equal(called, false);
832});
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.833
834// What a deploy said when Cloudflare's API failed under it: the reason is
835// in the ✘ [ERROR] block, and the last lines are only the request's trace.
836const API_FAILED = [
837 "Uploaded g1t-runner (12.31 sec)",
838 "✘ [ERROR] A request to the Cloudflare API (/accounts/abc/workers/scripts/g1t-runner/versions) failed.",
839 "",
840 " Service unavailable [code: 10013]",
841 "",
842 " If you think this is a bug, please open an issue at: https://github.com/cloudflare/workers-sdk/issues/new/choose",
843 "",
844 ' "result": {',
845 ' "id": "g1t-runner",',
846 " GET /accounts/abc/workers/services/g1t-runner -> 200 OK",
847 " Cloudflare Ray ID: a49663530d5eebe3-SJC",
848].join("\n");
849
850test("a failed wrangler command is reported by its error, not its trace", () => {
851 const said = wranglerError(API_FAILED);
852 assert.match(said, /^✘ \[ERROR\] A request to the Cloudflare API/);
853 assert.match(said, /Service unavailable \[code: 10013\]/);
854 // No error block: its last lines, as before.
855 assert.equal(wranglerError("one\ntwo\nthree", 2), "two\nthree");
856});
857
858test("Cloudflare failing is tried again; a mistake in what is deployed is not", () => {
859 assert.equal(retryableDeploy(API_FAILED), true);
860 assert.equal(retryableDeploy("✘ [ERROR] Build failed with 1 error:\n src/index.ts:3:7: ERROR: Could not resolve \"./nope\""), false);
861 assert.equal(retryableDeploy("✘ [ERROR] Processing wrangler.jsonc configuration:\n - \"main\" is not a valid path"), false);
862 assert.equal(retryableDeploy("fetch failed: ECONNRESET"), true);
863});
A deploy that changes the runner's image or its container settings rolls every idle sandbox over at once (--containers-rollout=immediate), where Cloudflare's default rollout went in steps over wall-clock time and, with deploys often half an hour apart, never reached every sandbox: jobs kept starting on old images and old machine sizes. A change to the container settings alone, such as the instance type, now rolls out too; before, an unchanged image meant --containers-rollout none and the new settings were never applied. Busy sandboxes still get rollout_active_grace_period to finish.864
865test("sandboxes roll over at once when their image or container settings changed, and not otherwise", () => {
866 const unit = { id: "runner", path: "services/runner" };
867 assert.equal(containersRollout(unit, { image: true, files: [] }), "immediate");
868 assert.equal(containersRollout(unit, { image: false, files: ["services/runner/wrangler.jsonc"] }), "immediate");
869 assert.equal(containersRollout(unit, { image: false, files: ["services/runner/src/egress.ts"] }), "none");
870 assert.equal(containersRollout(unit, { image: false, files: ["services/runnerx/wrangler.jsonc"] }), "none");
871 assert.equal(containersRollout(unit, { image: false, files: [] }, { rebuildImage: true }), "immediate");
872});
The deploy tool parses again: the last change put a newline inside a string in scripts/deploy.mjs, so every deploy failed its first check. A test now runs node --check on scripts/deploy.mjs, since no other test imports it.873
874test("the deploy tool itself parses: a test that never imports it would not notice", () => {
875 execFileSync(process.execPath, ["--check", join(import.meta.dirname, "..", "deploy.mjs")]);
876});

This file's history is long; its oldest lines are credited to the oldest commit read.