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
201 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.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1#!/usr/bin/env node
2// Releases of the self-hosted runner, `g1t-runner` (crates/runner): built
3// for every platform, checksummed, signed, and published to the
4// g1t-downloads R2 bucket that g1t.sh serves at /downloads/runner/
5// (apps/web/app/routes/downloads-runner.ts).
6//
7// node scripts/runner-release.mjs keygen # once: the release key
8// node scripts/runner-release.mjs build [--targets linux-x64,windows-x64]
9// node scripts/runner-release.mjs sign # latest.json + .sig
10// node scripts/runner-release.mjs verify # checks the signature
11// node scripts/runner-release.mjs publish [--dry-run]
12//
13// What a release is, at runner/<version>/ in the bucket:
14//
15// g1t-runner-linux-x64, -linux-arm64, -macos-x64, -macos-arm64,
16// -windows-x64.exe the binaries
17// SHA256SUMS `sha256 name`, one line each
18// manifest.json { version, published_at, agent_image, files: { platform: { name, sha256 } } }
19//
20// and at runner/: latest.json (the newest release's manifest) and
21// latest.json.sig, its Ed25519 signature in base64. A runner trusts a
22// release only when the signature checks out against the public key built
23// into it (G1T_RUNNER_RELEASE_KEY at build time) and every file's SHA-256
24// is the manifest's (crates/runner/src/selfhosted/update.rs).
25//
26// Environment:
27// RUNNER_RELEASE_KEY the private key (keygen prints it): a g1t Actions secret
28// G1T_RUNNER_RELEASE_KEY the public key, built into the binaries
29// RUNNER_AGENT_IMAGE the image agent work runs in, named in the manifest
30//
31// Every target is cross-compiled with cargo-zigbuild (`cargo install
32// cargo-zigbuild`, and zig on PATH), so one Linux machine builds them all.
33
34import { spawnSync } from "node:child_process";
35import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
36import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
37import { dirname, join } from "node:path";
38import { fileURLToPath } from "node:url";
39
40const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
41export const TARGETS = {
42 "linux-x64": { triple: "x86_64-unknown-linux-musl", file: "g1t-runner-linux-x64" },
43 "linux-arm64": { triple: "aarch64-unknown-linux-musl", file: "g1t-runner-linux-arm64" },
44 "macos-x64": { triple: "x86_64-apple-darwin", file: "g1t-runner-macos-x64" },
45 "macos-arm64": { triple: "aarch64-apple-darwin", file: "g1t-runner-macos-arm64" },
46 "windows-x64": { triple: "x86_64-pc-windows-gnu", file: "g1t-runner-windows-x64.exe", exe: true },
47};
48const BUCKET = "g1t-downloads";
49
50export function version() {
51 const toml = readFileSync(join(ROOT, "crates/runner/Cargo.toml"), "utf8");
52 const found = /^version\s*=\s*"([^"]+)"/m.exec(toml);
53 if (!found) throw new Error("crates/runner/Cargo.toml has no version");
54 return found[1];
55}
56
57const outDir = (v = version()) => join(ROOT, "target", "runner-release", v);
58
59/** The 32 raw bytes of an Ed25519 public key, in base64, as the runner takes it. */
60export function rawPublicKey(publicKey) {
61 const der = publicKey.export({ type: "spki", format: "der" });
62 return der.subarray(der.length - 32).toString("base64");
63}
64
65export function keygen() {
66 const { privateKey, publicKey } = generateKeyPairSync("ed25519");
67 return {
68 private: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
69 public: rawPublicKey(publicKey),
70 };
71}
72
73function privateKey(text = process.env.RUNNER_RELEASE_KEY) {
74 if (!text) throw new Error("RUNNER_RELEASE_KEY is not set");
75 return createPrivateKey({ key: Buffer.from(text, "base64"), format: "der", type: "pkcs8" });
76}
77
78/** Signs `bytes`: the signature in base64. */
79export function signBytes(bytes, key) {
80 return sign(null, bytes, key).toString("base64");
81}
82
83/** Whether `signature` is the key's over `bytes`, given the raw public key in base64. */
84export function verifyBytes(bytes, signature, publicRaw) {
85 const der = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(publicRaw, "base64")]);
86 const key = createPublicKey({ key: der, format: "der", type: "spki" });
87 return verify(null, bytes, key, Buffer.from(signature, "base64"));
88}
89
90export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
91
92/** The manifest of the files in a release folder. */
93export function manifest(dir, v, agentImage = process.env.RUNNER_AGENT_IMAGE || null) {
94 const files = {};
95 for (const [platform, target] of Object.entries(TARGETS)) {
96 const path = join(dir, target.file);
97 if (existsSync(path)) files[platform] = { name: target.file, sha256: sha256(readFileSync(path)) };
98 }
99 return { version: v, published_at: new Date().toISOString(), agent_image: agentImage, files };
100}
101
102function run(command, args, options = {}) {
103 const done = spawnSync(command, args, { stdio: "inherit", cwd: ROOT, shell: process.platform === "win32", ...options });
104 if (done.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`);
105}
106
107function build(targets) {
108 const v = version();
109 const dir = outDir(v);
110 mkdirSync(dir, { recursive: true });
111 if (!process.env.G1T_RUNNER_RELEASE_KEY) console.error("warning: G1T_RUNNER_RELEASE_KEY is not set; these builds will not update themselves");
112 for (const platform of targets) {
113 const target = TARGETS[platform];
114 if (!target) throw new Error(`unknown target ${platform}; one of ${Object.keys(TARGETS).join(", ")}`);
115 console.error(`building ${platform} (${target.triple})`);
116 run("rustup", ["target", "add", target.triple]);
117 run("cargo", ["zigbuild", "--release", "--locked", "--package", "g1t-runner", "--target", target.triple]);
118 const built = join(ROOT, "target", target.triple, "release", target.exe ? "g1t-runner.exe" : "g1t-runner");
119 copyFileSync(built, join(dir, target.file));
120 }
121 const m = manifest(dir, v);
122 writeFileSync(join(dir, "manifest.json"), `${JSON.stringify(m, null, 2)}\n`);
123 writeFileSync(join(dir, "SHA256SUMS"), Object.values(m.files).map((f) => `${f.sha256} ${f.name}`).join("\n") + "\n");
124 console.log(`built ${Object.keys(m.files).length} binaries of ${v} into ${dir}`);
125}
126
127function signRelease() {
128 const v = version();
129 const dir = outDir(v);
130 const bytes = readFileSync(join(dir, "manifest.json"));
131 writeFileSync(join(dir, "latest.json"), bytes);
132 writeFileSync(join(dir, "latest.json.sig"), signBytes(bytes, privateKey()));
133 console.log(`signed ${v}`);
134}
135
136function verifyRelease() {
137 const dir = outDir();
138 const publicRaw = process.env.G1T_RUNNER_RELEASE_KEY;
139 if (!publicRaw) throw new Error("G1T_RUNNER_RELEASE_KEY is not set");
140 const ok = verifyBytes(readFileSync(join(dir, "latest.json")), readFileSync(join(dir, "latest.json.sig"), "utf8"), publicRaw);
141 if (!ok) throw new Error("latest.json's signature is not the release key's");
142 const m = JSON.parse(readFileSync(join(dir, "latest.json"), "utf8"));
143 for (const file of Object.values(m.files)) {
144 if (sha256(readFileSync(join(dir, file.name))) !== file.sha256) throw new Error(`${file.name}'s SHA-256 is not the manifest's`);
145 }
146 console.log(`verified ${m.version}: ${Object.keys(m.files).length} files`);
147}
148
149function publish(dryRun) {
150 const v = version();
151 const dir = outDir(v);
152 const put = (key, file, type) => {
153 const args = ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type];
154 if (dryRun) console.log(`would put ${key}`);
155 else run("npx", args, { cwd: join(ROOT, "apps/web") });
156 };
157 // The version's files first; latest.json last, so no runner is pointed
158 // at files that are not there yet.
159 for (const name of readdirSync(dir)) {
160 if (name.startsWith("latest.json")) continue;
161 put(`runner/${v}/${name}`, join(dir, name), name.endsWith(".json") ? "application/json" : "application/octet-stream");
162 }
163 put("runner/latest.json", join(dir, "latest.json"), "application/json");
164 put("runner/latest.json.sig", join(dir, "latest.json.sig"), "text/plain");
165}
166
167if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/runner-release.mjs")) {
168 const [command, ...rest] = process.argv.slice(2);
169 const option = (name) => {
170 const at = rest.indexOf(`--${name}`);
171 return at >= 0 ? rest[at + 1] : undefined;
172 };
173 try {
174 switch (command) {
175 case "keygen": {
176 const keys = keygen();
177 console.log(`RUNNER_RELEASE_KEY=${keys.private}\nG1T_RUNNER_RELEASE_KEY=${keys.public}`);
178 console.error("Keep RUNNER_RELEASE_KEY secret (a g1t Actions secret); G1T_RUNNER_RELEASE_KEY is public and goes into every build.");
179 break;
180 }
181 case "build":
182 build((option("targets") ?? Object.keys(TARGETS).join(",")).split(","));
183 break;
184 case "sign":
185 signRelease();
186 break;
187 case "verify":
188 verifyRelease();
189 break;
190 case "publish":
191 publish(rest.includes("--dry-run"));
192 break;
193 default:
194 console.error("usage: node scripts/runner-release.mjs keygen|build|sign|verify|publish");
195 process.exit(2);
196 }
197 } catch (error) {
198 console.error(String(error.message ?? error));
199 process.exit(1);
200 }
201}

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