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.

155 linesCodeBlameRaw
1#!/usr/bin/env node
2// Builds the g1t runner (crates/runner) as one static Linux binary, the
3// only file the runner's image adds to its base (services/runner/Dockerfile).
4// Built outside the image, so a change to the runner is a Cargo build and
5// a one-file image, not a Docker build of Rust.
6//
7// The target is x86_64-unknown-linux-musl: statically linked, so it runs
8// on any x86-64 Linux, whatever its libc (the sandbox's Debian, or a
9// self-hosted runner's machine). Where it is built:
10//
11// - natively, on x86-64 Linux with the musl target and musl-gcc
12// (`rustup target add x86_64-unknown-linux-musl`, `apt-get install
13// musl-tools`), which a CI machine can have;
14// - otherwise in a small builder container (rust on Alpine, whose own
15// target is musl), with Docker volumes for Cargo's registry and target
16// directory, so a second build is incremental. This is how it builds
17// on Windows and macOS, where no musl cross-linker is at hand.
18//
19// node scripts/build-runner.mjs # -> target/runner-image/g1t-runner
20// node scripts/build-runner.mjs --docker # the builder container, wherever
21// node scripts/build-runner.mjs --native # this machine's Cargo, or fail
22
23import { spawnSync } from "node:child_process";
24import { copyFileSync, mkdirSync, readFileSync, statSync } from "node:fs";
25import { arch, platform } from "node:os";
26import { dirname, join } from "node:path";
27import { fileURLToPath } from "node:url";
28
29export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
30export const TARGET = "x86_64-unknown-linux-musl";
31/** Where the binary is written: the thin image's whole build context. */
32export const OUT_DIR = join(ROOT, "target", "runner-image");
33export const BINARY = join(OUT_DIR, "g1t-runner");
34
35/**
36 * The builder container's image: Rust on Alpine, the same Rust release as
37 * rust-toolchain users get, plus musl's headers for crates with C in them
38 * (ring). Built once per Rust version and kept by Docker.
39 */
40export const BUILDER_RUST = "1.99";
41const BUILDER_IMAGE = `g1t-runner-builder:${BUILDER_RUST}`;
42const BUILDER_DOCKERFILE = `FROM rust:${BUILDER_RUST}-alpine\nRUN apk add --no-cache musl-dev\n`;
43/** Docker volumes the builder keeps between builds. */
44const VOLUMES = { registry: "g1t-runner-cargo-registry", git: "g1t-runner-cargo-git", target: "g1t-runner-target" };
45
46const run = (command, args, options = {}) => spawnSync(command, args, { stdio: "inherit", ...options });
47const quiet = (command, args) => spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
48
49/** Whether this machine can build the musl binary itself. */
50export function canBuildNatively() {
51 if (platform() !== "linux" || arch() !== "x64") return false;
52 const targets = quiet("rustup", ["target", "list", "--installed"]);
53 if (targets.status !== 0 || !targets.stdout.split("\n").includes(TARGET)) return false;
54 return quiet("musl-gcc", ["--version"]).status === 0 || quiet("sh", ["-c", "command -v musl-gcc"]).status === 0;
55}
56
57function native() {
58 const built = run("cargo", ["build", "--release", "--locked", "--package", "g1t-runner", "--target", TARGET], {
59 cwd: ROOT,
60 env: { ...process.env, CC_x86_64_unknown_linux_musl: "musl-gcc" },
61 });
62 if (built.status !== 0) throw new Error("cargo build of the runner failed");
63 return join(ROOT, "target", TARGET, "release", "g1t-runner");
64}
65
66function inDocker() {
67 const have = quiet("docker", ["image", "inspect", BUILDER_IMAGE, "--format", "{{.Id}}"]);
68 if (have.status !== 0) {
69 console.error(`building ${BUILDER_IMAGE}`);
70 const made = run("docker", ["build", "--platform", "linux/amd64", "-t", BUILDER_IMAGE, "-"], {
71 input: BUILDER_DOCKERFILE,
72 stdio: ["pipe", "inherit", "inherit"],
73 });
74 if (made.status !== 0) throw new Error(`could not build ${BUILDER_IMAGE}`);
75 }
76 mkdirSync(OUT_DIR, { recursive: true });
77 // The source is mounted read-only; Cargo writes only to its volumes, and
78 // the binary is copied out to target/runner-image.
79 const script = ["set -e", "cargo build --release --locked --package g1t-runner", "cp /target/release/g1t-runner /out/g1t-runner"].join("\n");
80 const built = run("docker", [
81 "run",
82 "--rm",
83 "--platform",
84 "linux/amd64",
85 "-v",
86 `${ROOT}:/src:ro`,
87 "-v",
88 `${OUT_DIR}:/out`,
89 "-v",
90 `${VOLUMES.registry}:/usr/local/cargo/registry`,
91 "-v",
92 `${VOLUMES.git}:/usr/local/cargo/git`,
93 "-v",
94 `${VOLUMES.target}:/target`,
95 "-e",
96 "CARGO_TARGET_DIR=/target",
97 "-e",
98 "CARGO_TERM_COLOR=never",
99 "-w",
100 "/src",
101 BUILDER_IMAGE,
102 "sh",
103 "-c",
104 script,
105 ]);
106 if (built.status !== 0) throw new Error("cargo build of the runner in the builder container failed");
107 return BINARY;
108}
109
110/** Whether a file is a statically linked x86-64 ELF executable. */
111export function isStaticElf(path) {
112 const bytes = readFileSync(path);
113 if (bytes.length < 64 || bytes.readUInt32BE(0) !== 0x7f454c46) return false;
114 // 64-bit, little-endian, x86-64.
115 if (bytes[4] !== 2 || bytes[5] !== 1 || bytes.readUInt16LE(18) !== 0x3e) return false;
116 // No PT_INTERP program header: nothing to load it but the kernel.
117 const phoff = Number(bytes.readBigUInt64LE(32));
118 const phentsize = bytes.readUInt16LE(54);
119 const phnum = bytes.readUInt16LE(56);
120 for (let i = 0; i < phnum; i++) {
121 if (bytes.readUInt32LE(phoff + i * phentsize) === 3) return false;
122 }
123 return true;
124}
125
126/** Builds the binary; returns its path (always target/runner-image/g1t-runner). */
127export function buildRunner({ mode = "auto" } = {}) {
128 const started = Date.now();
129 const useNative = mode === "native" || (mode === "auto" && canBuildNatively());
130 if (mode === "native" && !canBuildNatively()) {
131 throw new Error(`no native musl build here: needs x86-64 Linux, rustup target ${TARGET} and musl-gcc`);
132 }
133 const built = useNative ? native() : inDocker();
134 if (built !== BINARY) {
135 mkdirSync(OUT_DIR, { recursive: true });
136 copyFileSync(built, BINARY);
137 }
138 if (!isStaticElf(BINARY)) throw new Error(`${BINARY} is not a static x86-64 Linux binary`);
139 const size = statSync(BINARY).size;
140 console.error(
141 `g1t-runner: ${(size / 1048576).toFixed(1)} MB, static, built ${useNative ? "natively" : "in the builder container"} in ${((Date.now() - started) / 1000).toFixed(1)}s`,
142 );
143 return BINARY;
144}
145
146if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/build-runner.mjs")) {
147 const args = process.argv.slice(2);
148 const mode = args.includes("--docker") ? "docker" : args.includes("--native") ? "native" : "auto";
149 try {
150 console.log(buildRunner({ mode }));
151 } catch (error) {
152 console.error(String(error.message ?? error));
153 process.exit(1);
154 }
155}