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.
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1 | // The runner's Containers images, as the deploy tool builds and ships them |
| 2 | // (docs/DEPLOYING.md, "The runner's images"): | |
| 3 | // | |
| 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 | ||
| 19 | import { createHash } from "node:crypto"; | |
| 20 | import { execFileSync } from "node:child_process"; | |
| 21 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 22 | import { dirname, join } from "node:path"; | |
| 23 | ||
| 24 | import { ACCOUNT_ID, exec, jsonFrom, lastLines, wrangler } from "./cloudflare.mjs"; | |
| 25 | import { parseCargoLock } from "./lockfiles.mjs"; | |
| 26 | import { ROOT, parseJsonc } from "./stack.mjs"; | |
| 27 | ||
| 28 | export const REGISTRY = "registry.cloudflare.com"; | |
| 29 | ||
| 30 | /** The generated config a runner deploy uses, beside its wrangler.jsonc. */ | |
| 31 | export const DEPLOY_CONFIG = "wrangler.deploy.json"; | |
| 32 | ||
| 33 | const posix = (path) => path.replaceAll("\\", "/"); | |
| 34 | ||
| 35 | /** Tracked and untracked (not ignored) files under `paths`, sorted. */ | |
| 36 | export 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 | */ | |
| 46 | export 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. */ | |
| 58 | export function repositoryOf(unit, account = ACCOUNT_ID) { | |
| 59 | return `${REGISTRY}/${account}/${unit.image.repository}`; | |
| 60 | } | |
| 61 | ||
| 62 | /** What services/runner/base.json says, or null. */ | |
| 63 | export 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. */ | |
| 69 | export 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`. */ | |
| 74 | export 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 | */ | |
| 83 | export 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 | */ | |
| 96 | export 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. */ | |
| 110 | export 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. */ | |
| 127 | export function runnerRef(unit, root = ROOT, account = ACCOUNT_ID) { | |
| 128 | return `${repositoryOf(unit, account)}:${runnerTag(unit, root)}`; | |
| 129 | } | |
| 130 | ||
| 131 | // ── The registry ────────────────────────────────────────────────────────── | |
| 132 | ||
| 133 | let creds = null; | |
| 134 | ||
| 135 | /** Short-lived registry credentials, from Wrangler (your login or the token). */ | |
| 136 | export 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"); | |
| The runner's base image, pushed as Wrangler builds images, with pushes that fail loudly | 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") }); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 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. */ | |
| 149 | export 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 | ||
| 155 | const 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. */ | |
| 163 | export 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. */ | |
| 180 | export 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. */ | |
| 185 | export 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 | */ | |
| 197 | export async function buildBase(unit, { tag, previous, onLine, account = ACCOUNT_ID, noCache = false }) { | |
| 198 | const ref = `${repositoryOf(unit, account)}:${tag}`; | |
| The runner's base image, pushed as Wrangler builds images, with pushes that fail loudly | 199 | const args = ["buildx", "build", ...PLAIN_IMAGE, "--progress", "plain", "--load", "--build-arg", "BUILDKIT_INLINE_CACHE=1", "-t", ref]; |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 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. */ | |
| 209 | export 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)"', | |
| 216 | 'echo "git=$(git --version | cut -d" " -f3)"', | |
| 217 | 'echo "claude_code=$(claude --version | cut -d" " -f1)"', | |
| 218 | 'echo "debian=$(cat /etc/debian_version)"', | |
| 219 | ].join("; "); | |
| 220 | const found = await exec("docker", ["run", "--rm", "--platform", "linux/amd64", "--entrypoint", "bash", ref, "-c", script]); | |
| 221 | if (found.code !== 0) return {}; | |
| 222 | return Object.fromEntries(found.out.trim().split(/\r?\n/).map((line) => line.split("=")).filter((pair) => pair.length === 2)); | |
| 223 | } | |
| 224 | ||
| 225 | /** Pushes `ref`; returns its digest in the registry. */ | |
| The runner's base image, pushed as Wrangler builds images, with pushes that fail loudly | 226 | export async function pushImage(ref, { onLine = () => {}, attempts = 3, run = exec } = {}) { |
| 227 | let last = ""; | |
| 228 | for (let attempt = 1; attempt <= attempts; attempt++) { | |
| 229 | const pushed = await run("docker", ["push", ref], { onLine }); | |
| 230 | last = pushed.out; | |
| 231 | const digest = pushedDigest(pushed); | |
| 232 | if (digest) return digest; | |
| 233 | // Cloudflare's registry can answer "blob unknown" for a layer it has | |
| 234 | // just taken; pushing again finds the layers there and finishes. | |
| 235 | if (attempt < attempts) onLine(`docker push ${ref} did not finish (attempt ${attempt} of ${attempts}); trying again`); | |
| 236 | } | |
| 237 | throw new Error(`docker push ${ref} failed:\n${lastLines(last)}`); | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * The digest a `docker push` ended with, or null if it did not end with | |
| 242 | * one, whatever its exit code: an error from the registry is a failure | |
| 243 | * even when Docker exits 0. | |
| 244 | */ | |
| 245 | export function pushedDigest({ code, out }) { | |
| 246 | if (code !== 0 || /error from registry|blob unknown|unknown blob|denied|unauthorized/i.test(out)) return null; | |
| 247 | return /digest: (sha256:[0-9a-f]{64})/.exec(out)?.[1] ?? null; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 248 | } |
| 249 | ||
| 250 | /** | |
| The runner's base image, pushed as Wrangler builds images, with pushes that fail loudly | 251 | * Build flags for an image Cloudflare Containers takes as Wrangler builds |
| 252 | * it: one manifest for linux/amd64, with no provenance or SBOM attestation, | |
| 253 | * which would make it an index with an `unknown/unknown` manifest. | |
| 254 | */ | |
| 255 | export const PLAIN_IMAGE = ["--platform", "linux/amd64", "--provenance=false", "--sbom=false"]; | |
| 256 | ||
| 257 | /** | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 258 | * Builds the runner's image: the base and the binary, from a build |
| 259 | * context holding only the binary (target/runner-image). | |
| 260 | */ | |
| 261 | export async function buildRunnerImage(unit, { ref, base, binaryDir, onLine }) { | |
| 262 | const built = await exec( | |
| 263 | "docker", | |
| The runner's base image, pushed as Wrangler builds images, with pushes that fail loudly | 264 | ["buildx", "build", ...PLAIN_IMAGE, "--progress", "plain", "--load", "--build-arg", `BASE=${base}`, "-f", join(ROOT, unit.image.dockerfile), "-t", ref, binaryDir], |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 265 | { onLine }, |
| 266 | ); | |
| 267 | if (built.code !== 0) throw new Error(`docker build of the runner's image failed:\n${lastLines(built.out, 30)}`); | |
| 268 | return ref; | |
| 269 | } | |
| 270 | ||
| 271 | // ── The Worker's config ─────────────────────────────────────────────────── | |
| 272 | ||
| 273 | /** | |
| 274 | * Writes the config a runner deploy uses: its wrangler.jsonc with every | |
| 275 | * container's image set to `ref`, beside it so its relative paths hold. | |
| 276 | * Returns the file's name (pass it as --config). Remove it after. | |
| 277 | */ | |
| 278 | export function writeDeployConfig(unit, ref, root = ROOT) { | |
| 279 | const config = parseJsonc(readFileSync(join(root, unit.path, "wrangler.jsonc"), "utf8")); | |
| 280 | delete config.$schema; | |
| 281 | for (const container of config.containers ?? []) { | |
| 282 | container.image = ref; | |
| 283 | delete container.image_build_context; | |
| 284 | delete container.image_vars; | |
| 285 | } | |
| 286 | const file = join(root, unit.path, DEPLOY_CONFIG); | |
| 287 | mkdirSync(dirname(file), { recursive: true }); | |
| 288 | writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); | |
| 289 | return DEPLOY_CONFIG; | |
| 290 | } | |
| 291 | ||
| 292 | export function removeDeployConfig(unit, root = ROOT) { | |
| 293 | rmSync(join(root, unit.path, DEPLOY_CONFIG), { force: true }); | |
| 294 | } | |
| 295 | ||
| 296 | /** Writes services/runner/base.json. */ | |
| 297 | export function writeBaseLock(unit, lock, root = ROOT) { | |
| 298 | writeFileSync(join(root, unit.image.base.lock), `${JSON.stringify(lock, null, 2)}\n`); | |
| 299 | } |