Skip to content

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

g1t.sh
303 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 runner base keeps its old comment, so the pushed base image still matches its folder1// The runner's Containers images, as the deploy tool builds and ships them
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.2// (docs.g1t.sh/guides/deploy-to-cloudflare/#the-runners-images):
The runner base keeps its old comment, so the pushed base image still matches its folder3//
4// base g1t-runner:base-<date>-<inputs> services/runner/base/Dockerfile:
5// the OS, toolchains and the Claude Code CLI. Rebuilt only when
6// its folder changes, or weekly (.g1t/workflows/runner-base.yml),
7// and recorded in services/runner/base.json.
8// runner g1t-runner:<content> services/runner/Dockerfile: the
9// base plus the runner binary (scripts/build-runner.mjs). Its tag
10// is a hash of everything it is built from, so the same source
11// always names the same image, and an image already in the
12// registry is never built again.
13//
14// Both live in one repository of Cloudflare's registry, so pushing the
15// runner uploads only its one new layer. `wrangler deploy` is given the
16// runner's registry reference (a generated config), so a deploy never
17// builds an image itself.
18
19import { createHash } from "node:crypto";
20import { execFileSync } from "node:child_process";
21import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22import { dirname, join } from "node:path";
23
24import { ACCOUNT_ID, exec, jsonFrom, lastLines, wrangler } from "./cloudflare.mjs";
25import { parseCargoLock } from "./lockfiles.mjs";
26import { ROOT, parseJsonc } from "./stack.mjs";
27
28export const REGISTRY = "registry.cloudflare.com";
29
30/** The generated config a runner deploy uses, beside its wrangler.jsonc. */
31export const DEPLOY_CONFIG = "wrangler.deploy.json";
32
33const posix = (path) => path.replaceAll("\\", "/");
34
35/** Tracked and untracked (not ignored) files under `paths`, sorted. */
36export function filesUnder(paths, root = ROOT) {
37 if (!paths.length) return [];
38 const out = execFileSync("git", ["ls-files", "-co", "--exclude-standard", "-z", "--", ...paths], { cwd: root, encoding: "utf8", maxBuffer: 64 << 20 });
39 return [...new Set(out.split("\0").filter(Boolean).map(posix))].filter((file) => existsSync(join(root, file))).sort();
40}
41
42/**
43 * A hash of files' paths and contents, line endings made LF so a Windows
44 * checkout and a Linux one agree.
45 */
46export function contentHash(files, root = ROOT, read = (file) => readFileSync(join(root, file))) {
47 const hash = createHash("sha256");
48 for (const file of [...files].sort()) {
49 const bytes = read(file);
50 const text = bytes.includes(0) ? bytes : Buffer.from(bytes.toString("utf8").replaceAll("\r\n", "\n"));
51 hash.update(`${file}\0${text.length}\0`);
52 hash.update(text);
53 }
54 return hash.digest("hex");
55}
56
57/** The repository both images are pushed to, for an account. */
58export function repositoryOf(unit, account = ACCOUNT_ID) {
59 return `${REGISTRY}/${account}/${unit.image.repository}`;
60}
61
62/** What services/runner/base.json says, or null. */
63export function readBaseLock(unit, root = ROOT) {
64 const file = join(root, unit.image.base.lock);
65 return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
66}
67
68/** A hash of everything the base is built from: its folder. */
69export function baseInputs(unit, root = ROOT) {
70 return contentHash(filesUnder([unit.image.base.context], root), root);
71}
72
73/** The base's tag for its inputs, built on `date`. */
74export function baseTag(inputs, date = new Date()) {
75 return `base-${date.toISOString().slice(0, 10).replaceAll("-", "")}-${inputs.slice(0, 12)}`;
76}
77
78/**
79 * Whether the base recorded in base.json is the one its folder builds:
80 * { lock, inputs, current, pushed, ref } where `ref` is what the runner's
81 * image is built FROM (by digest, once pushed).
82 */
83export function baseState(unit, root = ROOT) {
84 const lock = readBaseLock(unit, root);
85 const inputs = baseInputs(unit, root);
86 // Pinned by digest once pushed, so the tag moving cannot change what is built.
87 const ref = lock ? (lock.pushed && lock.digest ? `${lock.image}@${lock.digest}` : lock.image) : null;
88 return { lock, inputs, current: Boolean(lock && lock.inputs === inputs), pushed: Boolean(lock?.pushed), ref };
89}
90
91/**
92 * The runner image's tag: a hash of its Dockerfile, the base it is built
93 * on, and every file the binary is built from (the crates in
94 * `unit.image.dirs`, the workspace's Cargo files, the build script).
95 */
96export function runnerTag(unit, root = ROOT) {
97 const base = readBaseLock(unit, root);
98 // Cargo.lock counts only for the packages the binary is built from, so a
99 // dependency bumped for another service names the same image.
100 const files = filesUnder([...unit.image.dirs, ...unit.image.files.filter((f) => f !== unit.image.base.lock && f !== "Cargo.lock")], root);
101 const lock = existsSync(join(root, "Cargo.lock")) ? readFileSync(join(root, "Cargo.lock"), "utf8") : "";
102 const hash = createHash("sha256");
103 hash.update(contentHash(files, root));
104 hash.update(`\0lock\0${lockFor(lock, unit.image.crate)}`);
105 hash.update(`\0base\0${base?.image ?? ""}\0${base?.digest ?? ""}`);
106 return hash.digest("hex").slice(0, 16);
107}
108
109/** The Cargo.lock entries `crate` is built from, in a stable order. */
110export function lockFor(text, crate) {
111 const packages = parseCargoLock(text);
112 const seen = new Set();
113 const stack = [crate];
114 while (stack.length) {
115 const name = stack.pop();
116 if (seen.has(name)) continue;
117 seen.add(name);
118 for (const entry of packages.get(name) ?? []) stack.push(...entry.deps);
119 }
120 return [...seen]
121 .sort()
122 .map((name) => `${name} ${JSON.stringify((packages.get(name) ?? []).map((e) => [e.version, e.source, e.checksum]))}`)
123 .join("\n");
124}
125
126/** The runner image's full reference for this checkout. */
127export function runnerRef(unit, root = ROOT, account = ACCOUNT_ID) {
128 return `${repositoryOf(unit, account)}:${runnerTag(unit, root)}`;
129}
130
131// ── The registry ──────────────────────────────────────────────────────────
132
133let creds = null;
134
135/** Short-lived registry credentials, from Wrangler (your login or the token). */
136export async function registryCredentials({ push = false } = {}) {
137 if (creds && creds.until > Date.now() && (!push || creds.push)) return creds;
138 const args = ["containers", "registries", "credentials", REGISTRY, "--pull", "--json", "--expiration-minutes", "60"];
139 if (push) args.push("--push");
140 // In a unit's folder, not the root, whose .env may hold a token for something else.
141 const got = await wrangler(args, { cwd: join(ROOT, "services/runner") });
142 if (got.code !== 0) throw new Error(`could not get registry credentials:\n${lastLines(got.out)}`);
143 const { username, password } = jsonFrom(got.out);
144 creds = { username, password, push, until: Date.now() + 50 * 60 * 1000 };
145 return creds;
146}
147
148/** Logs Docker in to Cloudflare's registry. */
149export async function dockerLogin({ push = false } = {}) {
150 const { username, password } = await registryCredentials({ push });
151 const done = await exec("docker", ["login", "--username", username, "--password-stdin", REGISTRY], { input: password });
152 if (done.code !== 0) throw new Error(`docker login ${REGISTRY} failed:\n${lastLines(done.out)}`);
153}
154
155const MANIFEST_TYPES = [
156 "application/vnd.oci.image.index.v1+json",
157 "application/vnd.oci.image.manifest.v1+json",
158 "application/vnd.docker.distribution.manifest.list.v2+json",
159 "application/vnd.docker.distribution.manifest.v2+json",
160].join(", ");
161
162/** Whether `ref` (registry/account/repo:tag) is in the registry. Needs no Docker. */
163export async function registryHas(ref, { fetchImpl = fetch, credentials = registryCredentials } = {}) {
164 const match = /^([^/]+)\/(.+):([^:/]+)$/.exec(ref);
165 if (!match) throw new Error(`not an image reference with a tag: ${ref}`);
166 const [, host, name, tag] = match;
167 const { username, password } = await credentials();
168 const response = await fetchImpl(`https://${host}/v2/${name}/manifests/${tag}`, {
169 method: "HEAD",
170 headers: { authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`, accept: MANIFEST_TYPES },
171 });
172 if (response.status === 404) return false;
173 if (!response.ok) throw new Error(`the registry answered ${response.status} for ${ref}`);
174 return true;
175}
176
177// ── Docker ────────────────────────────────────────────────────────────────
178
179/** Whether Docker has `ref` locally. */
180export async function dockerHas(ref) {
181 return (await exec("docker", ["image", "inspect", ref, "--format", "{{.Id}}"])).code === 0;
182}
183
184/** Bytes of an image as Docker keeps it unpacked, and its layers' compressed total when known. */
185export async function imageSize(ref) {
186 const found = await exec("docker", ["image", "inspect", ref, "--format", "{{.Size}}"]);
187 return found.code === 0 ? Number(found.out.trim()) : null;
188}
189
190/**
191 * Builds the base. Its cache comes from the base it replaces: the image
192 * carries its own build cache (BUILDKIT_INLINE_CACHE), so a machine with
193 * none, or one just pruned, pulls the layers that did not change instead
194 * of building them again. `--cache-from` an image that cannot be pulled
195 * (no login, a first build) is skipped with a warning.
196 */
197export async function buildBase(unit, { tag, previous, onLine, account = ACCOUNT_ID, noCache = false }) {
198 const ref = `${repositoryOf(unit, account)}:${tag}`;
199 const args = ["buildx", "build", ...PLAIN_IMAGE, "--progress", "plain", "--load", "--build-arg", "BUILDKIT_INLINE_CACHE=1", "-t", ref];
200 if (previous && !noCache) args.push("--cache-from", `type=registry,ref=${previous}`);
201 if (noCache) args.push("--no-cache");
202 args.push(join(ROOT, unit.image.base.context));
203 const built = await exec("docker", args, { onLine });
204 if (built.code !== 0) throw new Error(`docker build of the base failed:\n${lastLines(built.out, 30)}`);
205 return ref;
206}
207
208/** What the base has, for base.json: each toolchain's version. */
209export async function baseVersions(ref) {
210 const script = [
211 'echo "node=$(node --version)"',
212 'echo "npm=$(npm --version)"',
213 'echo "python=$(python3 --version | cut -d" " -f2)"',
214 'echo "go=$(go version | cut -d" " -f3)"',
215 'echo "rust=$(rustc --version | cut -d" " -f2)"',
Merge runner image: Java 21, .NET 8, Ruby 3.3216 'echo "java=$(java --version | head -n1 | cut -d" " -f2)"',
217 'echo "dotnet=$(dotnet --version)"',
218 'echo "ruby=$(ruby --version | cut -d" " -f2)"',
The runner base keeps its old comment, so the pushed base image still matches its folder219 'echo "git=$(git --version | cut -d" " -f3)"',
220 'echo "claude_code=$(claude --version | cut -d" " -f1)"',
Merge branch 'main' into actions-toolkit-oidc-artifacts221 'echo "docker=$(dockerd --version | cut -d" " -f3 | tr -d ,)"',
The runner base keeps its old comment, so the pushed base image still matches its folder222 'echo "debian=$(cat /etc/debian_version)"',
223 ].join("; ");
224 const found = await exec("docker", ["run", "--rm", "--platform", "linux/amd64", "--entrypoint", "bash", ref, "-c", script]);
225 if (found.code !== 0) return {};
226 return Object.fromEntries(found.out.trim().split(/\r?\n/).map((line) => line.split("=")).filter((pair) => pair.length === 2));
227}
228
229/** Pushes `ref`; returns its digest in the registry. */
230export async function pushImage(ref, { onLine = () => {}, attempts = 3, run = exec } = {}) {
231 let last = "";
232 for (let attempt = 1; attempt <= attempts; attempt++) {
233 const pushed = await run("docker", ["push", ref], { onLine });
234 last = pushed.out;
235 const digest = pushedDigest(pushed);
236 if (digest) return digest;
237 // Cloudflare's registry can answer "blob unknown" for a layer it has
238 // just taken; pushing again finds the layers there and finishes.
239 if (attempt < attempts) onLine(`docker push ${ref} did not finish (attempt ${attempt} of ${attempts}); trying again`);
240 }
241 throw new Error(`docker push ${ref} failed:\n${lastLines(last)}`);
242}
243
244/**
245 * The digest a `docker push` ended with, or null if it did not end with
246 * one, whatever its exit code: an error from the registry is a failure
247 * even when Docker exits 0.
248 */
249export function pushedDigest({ code, out }) {
250 if (code !== 0 || /error from registry|blob unknown|unknown blob|denied|unauthorized/i.test(out)) return null;
251 return /digest: (sha256:[0-9a-f]{64})/.exec(out)?.[1] ?? null;
252}
253
254/**
255 * Build flags for an image Cloudflare Containers takes as Wrangler builds
256 * it: one manifest for linux/amd64, with no provenance or SBOM attestation,
257 * which would make it an index with an `unknown/unknown` manifest.
258 */
259export const PLAIN_IMAGE = ["--platform", "linux/amd64", "--provenance=false", "--sbom=false"];
260
261/**
262 * Builds the runner's image: the base and the binary, from a build
263 * context holding only the binary (target/runner-image).
264 */
265export async function buildRunnerImage(unit, { ref, base, binaryDir, onLine }) {
266 const built = await exec(
267 "docker",
268 ["buildx", "build", ...PLAIN_IMAGE, "--progress", "plain", "--load", "--build-arg", `BASE=${base}`, "-f", join(ROOT, unit.image.dockerfile), "-t", ref, binaryDir],
269 { onLine },
270 );
271 if (built.code !== 0) throw new Error(`docker build of the runner's image failed:\n${lastLines(built.out, 30)}`);
272 return ref;
273}
274
275// ── The Worker's config ───────────────────────────────────────────────────
276
277/**
278 * Writes the config a runner deploy uses: its wrangler.jsonc with every
279 * container's image set to `ref`, beside it so its relative paths hold.
280 * Returns the file's name (pass it as --config). Remove it after.
281 */
282export function writeDeployConfig(unit, ref, root = ROOT) {
283 const config = parseJsonc(readFileSync(join(root, unit.path, "wrangler.jsonc"), "utf8"));
284 delete config.$schema;
285 for (const container of config.containers ?? []) {
286 container.image = ref;
287 delete container.image_build_context;
288 delete container.image_vars;
289 }
290 const file = join(root, unit.path, DEPLOY_CONFIG);
291 mkdirSync(dirname(file), { recursive: true });
292 writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
293 return DEPLOY_CONFIG;
294}
295
296export function removeDeployConfig(unit, root = ROOT) {
297 rmSync(join(root, unit.path, DEPLOY_CONFIG), { force: true });
298}
299
300/** Writes services/runner/base.json. */
301export function writeBaseLock(unit, lock, root = ROOT) {
302 writeFileSync(join(root, unit.image.base.lock), `${JSON.stringify(lock, null, 2)}\n`);
303}

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