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
846 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.

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

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