Skip to content
164 linesCodeBlameRaw
1#!/usr/bin/env node
2// Releases of the g1t CLI (crates/g1t): built for every platform,
3// checksummed, signed, and published to the g1t-downloads R2 bucket that
4// g1t.sh serves at /downloads/cli/ (apps/web/app/routes/downloads-runner.ts).
5// g1t.sh/install and g1t.sh/install.ps1 (apps/web/app/content/install/)
6// install the newest one.
7//
8// node scripts/cli-release.mjs build # every platform, in the cargo-zigbuild image (Docker)
9// node scripts/cli-release.mjs manifest # SHA256SUMS, manifest.json and latest.json from what was built
10// node scripts/cli-release.mjs sign # latest.json.sig
11// node scripts/cli-release.mjs verify # checks the signature
12// node scripts/cli-release.mjs publish [--dry-run]
13//
14// At cli/<version>/ in the bucket: g1t-linux-x64, -linux-arm64,
15// -macos-x64, -macos-arm64, -windows-x64.exe, SHA256SUMS, manifest.json and
16// latest.json.sig; at cli/: latest.json, the newest release's manifest, and
17// latest.json.sig, its Ed25519 signature in base64. SHA256SUMS is what the
18// install scripts check a download against; the signature is what a CLI
19// that updates itself will check, with the same release key as the
20// runner's (scripts/runner-release.mjs keygen):
21//
22// RUNNER_RELEASE_KEY the private key: a g1t Actions secret
23// G1T_RUNNER_RELEASE_KEY the public key, for `verify`
24//
25// The macOS builds link Apple's Security framework (the keychain), whose
26// SDK the rust-cross/cargo-zigbuild image carries, so `build` runs there.
27
28import { spawnSync } from "node:child_process";
29import { createHash, createPrivateKey } from "node:crypto";
30import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31import { dirname, join } from "node:path";
32import { fileURLToPath } from "node:url";
33
34import { signBytes, verifyBytes } from "./runner-release.mjs";
35
36const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
37const BUCKET = "g1t-downloads";
38const BUILDER = "ghcr.io/rust-cross/cargo-zigbuild:latest";
39export const TARGETS = {
40 "linux-x64": { triple: "x86_64-unknown-linux-musl", file: "g1t-linux-x64" },
41 "linux-arm64": { triple: "aarch64-unknown-linux-musl", file: "g1t-linux-arm64" },
42 "macos-x64": { triple: "x86_64-apple-darwin", file: "g1t-macos-x64" },
43 "macos-arm64": { triple: "aarch64-apple-darwin", file: "g1t-macos-arm64" },
44 "windows-x64": { triple: "x86_64-pc-windows-gnu", file: "g1t-windows-x64.exe", exe: true },
45};
46
47export function version() {
48 const found = /^version\s*=\s*"([^"]+)"/m.exec(readFileSync(join(ROOT, "crates/g1t/Cargo.toml"), "utf8"));
49 if (!found) throw new Error("crates/g1t/Cargo.toml has no version");
50 return found[1];
51}
52
53const outDir = (v = version()) => join(ROOT, "target", "cli-release", v);
54export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
55
56function run(command, args, options = {}) {
57 const done = spawnSync(command, args, { stdio: "inherit", cwd: ROOT, ...options });
58 if (done.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`);
59}
60
61/**
62 * The release's manifest, from the binaries in `dir`: each platform's file
63 * with its SHA-256. Writes SHA256SUMS, manifest.json and latest.json there.
64 */
65export function manifest(dir, v, now = new Date()) {
66 const files = {};
67 for (const [platform, target] of Object.entries(TARGETS)) {
68 const path = join(dir, target.file);
69 if (!existsSync(path)) continue;
70 files[platform] = { name: target.file, sha256: sha256(readFileSync(path)) };
71 }
72 if (Object.keys(files).length === 0) throw new Error(`no binaries in ${dir}: node scripts/cli-release.mjs build`);
73 const body = { version: v, published_at: now.toISOString(), files };
74 const text = `${JSON.stringify(body, null, 2)}\n`;
75 writeFileSync(join(dir, "manifest.json"), text);
76 writeFileSync(join(dir, "latest.json"), text);
77 writeFileSync(join(dir, "SHA256SUMS"), Object.values(files).map((f) => `${f.sha256} ${f.name}`).join("\n") + "\n");
78 return body;
79}
80
81function build() {
82 const v = version();
83 const dir = outDir(v);
84 mkdirSync(dir, { recursive: true });
85 const triples = Object.values(TARGETS).map((t) => t.triple).join(" ");
86 // One container builds every platform; its target folder is kept apart
87 // from the host's so the two never mix.
88 run("docker", [
89 "run", "--rm",
90 "-e", "CARGO_TARGET_DIR=/src/target/zig",
91 "-v", `${ROOT.replaceAll("\\", "/")}:/src`,
92 "-v", "g1t-cargo-registry:/usr/local/cargo/registry",
93 "-w", "/src", BUILDER, "sh", "-c",
94 `set -e; for t in ${triples}; do rustup target add $t >/dev/null 2>&1; cargo zigbuild --release --locked --package g1t --target $t; done`,
95 ], { env: { ...process.env, MSYS_NO_PATHCONV: "1" } });
96 for (const target of Object.values(TARGETS)) {
97 const built = join(ROOT, "target", "zig", target.triple, "release", target.exe ? "g1t.exe" : "g1t");
98 writeFileSync(join(dir, target.file), readFileSync(built));
99 }
100 const m = manifest(dir, v);
101 console.log(`built ${Object.keys(m.files).length} binaries of g1t ${v} into ${dir}`);
102}
103
104function privateKey(text = process.env.RUNNER_RELEASE_KEY) {
105 if (!text) throw new Error("RUNNER_RELEASE_KEY is not set");
106 return createPrivateKey({ key: Buffer.from(text, "base64"), format: "der", type: "pkcs8" });
107}
108
109function signRelease() {
110 const dir = outDir();
111 const bytes = readFileSync(join(dir, "latest.json"));
112 writeFileSync(join(dir, "latest.json.sig"), signBytes(bytes, privateKey()));
113 console.log(`signed g1t ${version()}`);
114}
115
116function verifyRelease() {
117 const dir = outDir();
118 const publicRaw = process.env.G1T_RUNNER_RELEASE_KEY;
119 if (!publicRaw) throw new Error("G1T_RUNNER_RELEASE_KEY is not set");
120 const ok = verifyBytes(readFileSync(join(dir, "latest.json")), readFileSync(join(dir, "latest.json.sig"), "utf8"), publicRaw);
121 if (!ok) throw new Error("latest.json's signature is not the release key's");
122 const m = JSON.parse(readFileSync(join(dir, "latest.json"), "utf8"));
123 for (const file of Object.values(m.files)) {
124 if (sha256(readFileSync(join(dir, file.name))) !== file.sha256) throw new Error(`${file.name} is not the file the manifest names`);
125 }
126 console.log(`g1t ${m.version}: the signature and every file check out`);
127}
128
129function publish(dryRun) {
130 const v = version();
131 const dir = outDir(v);
132 if (!existsSync(join(dir, "manifest.json"))) throw new Error(`nothing built for ${v}: node scripts/cli-release.mjs build`);
133 const signed = existsSync(join(dir, "latest.json.sig"));
134 const put = (key, file, type) => {
135 if (dryRun) return console.log(`would put ${key}`);
136 run("npx", ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type], {
137 shell: process.platform === "win32",
138 // Away from the repository's .env, which may hold a token meant for
139 // something else (as scripts/deploy does).
140 cwd: join(ROOT, "apps/web"),
141 });
142 };
143 const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
144 for (const file of Object.values(m.files)) put(`cli/${v}/${file.name}`, join(dir, file.name), "application/octet-stream");
145 put(`cli/${v}/manifest.json`, join(dir, "manifest.json"), "application/json");
146 put(`cli/${v}/SHA256SUMS`, join(dir, "SHA256SUMS"), "text/plain");
147 if (signed) put(`cli/${v}/latest.json.sig`, join(dir, "latest.json.sig"), "text/plain");
148 // Last, so `latest` never names a version whose files are not up yet.
149 if (signed) put("cli/latest.json.sig", join(dir, "latest.json.sig"), "text/plain");
150 put("cli/latest.json", join(dir, "latest.json"), "application/json");
151}
152
153if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/cli-release.mjs")) {
154 const [command, ...rest] = process.argv.slice(2);
155 if (command === "build") build();
156 else if (command === "manifest") manifest(outDir(), version());
157 else if (command === "sign") signRelease();
158 else if (command === "verify") verifyRelease();
159 else if (command === "publish") publish(rest.includes("--dry-run"));
160 else {
161 console.error("usage: node scripts/cli-release.mjs build|manifest|sign|verify|publish [--dry-run]");
162 process.exit(2);
163 }
164}