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