flagon-io/g1t

public

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/scripts/runner-release.mjs

201 lines9,223 bytesCodeBlame
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 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}