Skip to content
862 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.

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

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