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 phones | 1 | // node --test "scripts/deploy/*.test.mjs" (npm run test:deploy) |
| 2 | import assert from "node:assert/strict"; | |
| 3 | import { execFileSync } from "node:child_process"; | |
| 4 | import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { test } from "node:test"; | |
| 8 | ||
| 9 | import { isStaticElf } from "../build-runner.mjs"; | |
| 10 | import { | |
| 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 check | 27 | import { |
| 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 check | 40 | 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 check | 42 | } from "./cloudflare.mjs"; |
| The webhooks empty state is a sentence, without an icon squeezed to a dot on phones | 43 | import { changedNames, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs"; |
| Merge remote-tracking branch 'origin/main' into workspace-chat | 44 | import { decide, git, planJson, pool } from "./plan.mjs"; |
| The webhooks empty state is a sentence, without an icon squeezed to a dot on phones | 45 | import { |
| 46 | ROOT, | |
| 47 | buildGroups, | |
| 48 | cargoWorkspace, | |
| 49 | findWranglerConfigs, | |
| 50 | loadStack, | |
| 51 | npmCiArgs, | |
| 52 | npmWorkspace, | |
| 53 | outsideImports, | |
| architecture and such | 54 | parseJsonc, |
| The webhooks empty state is a sentence, without an icon squeezed to a dot on phones | 55 | pick, |
| 56 | problems, | |
| 57 | resolveStack, | |
| 58 | resolvedStack, | |
| Merge remote-tracking branch 'origin/main' into workspace-chat | 59 | testOnlySource, |
| The webhooks empty state is a sentence, without an icon squeezed to a dot on phones | 60 | 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 phones | 64 | } from "./stack.mjs"; |
| 65 | ||
| 66 | const stack = resolvedStack(); | |
| 67 | const unit = (id) => stack.units.find((u) => u.id === id); | |
| 68 | ||
| 69 | // ── The manifest ────────────────────────────────────────────────────────── | |
| 70 | ||
| 71 | test("the manifest matches every wrangler config in the repository", () => { | |
| 72 | assert.deepEqual(problems(stack, findWranglerConfigs()), []); | |
| 73 | }); | |
| 74 | ||
| 75 | test("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 | ||
| 83 | test("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 | ||
| 97 | test("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. | 107 | test("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 phones | 125 | test("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 | ||
| 135 | test("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 merge | 137 | 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 phones | 139 | 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 | ||
| 148 | test("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 | ||
| 167 | test("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 | ||
| 181 | test("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 | ||
| 194 | test("only the lockfile entries the runner is built from name its image", () => { | |
| 195 | const lock = (wasm) => `[[package]] | |
| 196 | name = "g1t-runner" | |
| 197 | version = "0.1.0" | |
| 198 | dependencies = [ | |
| 199 | "ureq", | |
| 200 | ] | |
| 201 | ||
| 202 | [[package]] | |
| 203 | name = "ureq" | |
| 204 | version = "2.12.1" | |
| 205 | ||
| 206 | [[package]] | |
| 207 | name = "wasm-bindgen" | |
| 208 | version = "${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 | ||
| 215 | test("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 | ||
| 241 | test("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 | ||
| 258 | test("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 | ||
| 269 | test("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 | ||
| 296 | test("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 | ||
| 327 | test("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 | ||
| 338 | const ids = (units, files) => units.filter((u) => touches(u, files)).map((u) => u.id); | |
| 339 | ||
| 340 | test("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 | ||
| 348 | test("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-chat | 354 | test("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. */ | |
| 371 | const sources = (files) => ({ | |
| 372 | read: (path) => files[path] ?? "", | |
| 373 | list: (dir) => Object.keys(files).filter((path) => path.slice(0, path.lastIndexOf("/")) === dir), | |
| 374 | }); | |
| 375 | ||
| 376 | test("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 | ||
| 413 | test("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-chat | 420 | assert.ok(!only("api", "apps/api/src/toolkit.rs")); |
| 421 | }); | |
| 422 | ||
| 423 | test("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-chat | 429 | }; |
| 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-chat | 432 | 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-chat | 434 | }; |
| 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-chat | 440 | }); |
| 441 | ||
| The webhooks empty state is a sentence, without an icon squeezed to a dot on phones | 442 | test("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 phones | 446 | 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 | ||
| 453 | const lock = (sha2) => `version = 4 | |
| 454 | ||
| 455 | [[package]] | |
| 456 | name = "g1t-events" | |
| 457 | version = "0.1.0" | |
| 458 | dependencies = [ | |
| 459 | "g1t-kit", | |
| 460 | ] | |
| 461 | ||
| 462 | [[package]] | |
| 463 | name = "g1t-kit" | |
| 464 | version = "0.1.0" | |
| 465 | dependencies = [ | |
| 466 | "serde", | |
| 467 | ] | |
| 468 | ||
| 469 | [[package]] | |
| 470 | name = "g1t-webhooks" | |
| 471 | version = "0.1.0" | |
| 472 | dependencies = [ | |
| 473 | "g1t-kit", | |
| 474 | "g1t-secrets", | |
| 475 | ] | |
| 476 | ||
| 477 | [[package]] | |
| 478 | name = "g1t-secrets" | |
| 479 | version = "0.1.0" | |
| 480 | dependencies = [ | |
| 481 | "sha2 ${sha2}", | |
| 482 | ] | |
| 483 | ||
| 484 | [[package]] | |
| 485 | name = "serde" | |
| 486 | version = "1.0.0" | |
| 487 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 488 | ||
| 489 | [[package]] | |
| 490 | name = "sha2" | |
| 491 | version = "${sha2}" | |
| 492 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 493 | `; | |
| 494 | ||
| 495 | test("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 | ||
| 504 | test("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 | ||
| 529 | const HEAD = "a".repeat(40); | |
| 530 | const OLD = "b".repeat(40); | |
| 531 | const fakeGit = (files, { has = true, locks = {} } = {}) => ({ | |
| 532 | has: () => has, | |
| 533 | changed: () => files, | |
| 534 | show: (sha, path) => locks[`${sha}:${path}`] ?? "", | |
| 535 | }); | |
| 536 | ||
| 537 | test("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 | ||
| 552 | test("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 | ||
| 560 | test("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 | ||
| 568 | test("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 | ||
| 576 | test("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-artifacts | 582 | { 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 phones | 584 | ]); |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 585 | 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 phones | 586 | assert.deepEqual(data.stages.front.jobs, [ |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 587 | { 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 phones | 589 | ]); |
| 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-artifacts | 596 | 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 phones | 597 | assert.ok(!jobs.find((j) => j.group === "ts").units.includes("runner")); |
| 598 | }); | |
| 599 | ||
| 600 | test("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 | ||
| 605 | test("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 phones | 608 | 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 phones | 610 | "-w", "@g1t/contracts", "-w", "@g1t/status", "-w", "@g1t/theme", |
| 611 | ]); | |
| 612 | }); | |
| 613 | ||
| 614 | // ── Cloudflare's answers ────────────────────────────────────────────────── | |
| 615 | ||
| 616 | const 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 | ||
| 623 | test("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 | ||
| 632 | test("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 | ||
| 644 | test("Wrangler's output: pending migrations and the version a deploy made", () => { | |
| 645 | const pending = ` | |
| 646 | ⛅️ wrangler 4.146.0 | |
| 647 | Resource location: remote | |
| 648 | Migrations 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 check | 661 | // Cloudflare's REST answers, as the API sends them (Wrangler's --json |
| 662 | // prints the first deployment, and the versions' items). | |
| 663 | const AUTH = { token: "t", account: "acct" }; | |
| 664 | const ok = (result) => ({ success: true, errors: [], messages: [], result }); | |
| 665 | const refused = (code, message) => ({ success: false, errors: [{ code, message }], messages: [], result: null }); | |
| 666 | /** A fetch that answers by path: { "GET /path": [status, body] }. */ | |
| 667 | function 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 | } | |
| 677 | const SCRIPT = "/accounts/acct/workers/scripts/g1t-events"; | |
| 678 | const 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 | }); | |
| 685 | const versionsAnswer = ok({ items: [version("v1", 1, annotation(OLD).message), version("v2", 2, null, "secret")] }); | |
| 686 | ||
| 687 | test("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 | ||
| 703 | test("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 | ||
| 725 | test("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 | ||
| 732 | test("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 phones | 759 | test("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 | ||
| 775 | test("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. | 783 | test("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 phones | 787 | }); |
| 788 | ||
| architecture and such | 789 | test("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 phones | 804 | test("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 accident | 808 | |
| 809 | test("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 page | 818 | |
| 819 | test("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. | |
| 835 | const 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 | ||
| 849 | test("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 | ||
| 857 | test("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.