Skip to content
190 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 { homedir } from "node:os";
32import { dirname, join } from "node:path";
33import { fileURLToPath } from "node:url";
34
35import { signBytes, verifyBytes } from "./runner-release.mjs";
36
37const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
38const BUCKET = "g1t-downloads";
39// Pinned by digest, so a release builds with the toolchain (Rust 1.93.0,
40// zig 0.16.0) its cache was built with, and `latest` moving never changes a
41// release under it. To move on: `docker pull ghcr.io/rust-cross/cargo-zigbuild:latest`
42// and take the digest from `docker image inspect --format '{{json .RepoDigests}}'`.
43// The Caches workflow keys the build's cache by it (ci-cache.mjs keys:
44// zigbuild), so a new digest starts a new entry.
45export const BUILDER = "ghcr.io/rust-cross/cargo-zigbuild@sha256:d8313491ec5798de0633fdc1c5753761bff79967bea69076020dc78121b2cca8";
46export const TARGETS = {
47 "linux-x64": { triple: "x86_64-unknown-linux-musl", file: "g1t-linux-x64" },
48 "linux-arm64": { triple: "aarch64-unknown-linux-musl", file: "g1t-linux-arm64" },
49 "macos-x64": { triple: "x86_64-apple-darwin", file: "g1t-macos-x64" },
50 "macos-arm64": { triple: "aarch64-apple-darwin", file: "g1t-macos-arm64" },
51 "windows-x64": { triple: "x86_64-pc-windows-gnu", file: "g1t-windows-x64.exe", exe: true },
52};
53
54export function version() {
55 const found = /^version\s*=\s*"([^"]+)"/m.exec(readFileSync(join(ROOT, "crates/g1t/Cargo.toml"), "utf8"));
56 if (!found) throw new Error("crates/g1t/Cargo.toml has no version");
57 return found[1];
58}
59
60const outDir = (v = version()) => join(ROOT, "target", "cli-release", v);
61export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
62
63function run(command, args, options = {}) {
64 const done = spawnSync(command, args, { stdio: "inherit", cwd: ROOT, ...options });
65 if (done.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`);
66}
67
68/**
69 * The release's manifest, from the binaries in `dir`: each platform's file
70 * with its SHA-256. Writes SHA256SUMS, manifest.json and latest.json there.
71 */
72export function manifest(dir, v, now = new Date()) {
73 const files = {};
74 for (const [platform, target] of Object.entries(TARGETS)) {
75 const path = join(dir, target.file);
76 if (!existsSync(path)) continue;
77 files[platform] = { name: target.file, sha256: sha256(readFileSync(path)) };
78 }
79 if (Object.keys(files).length === 0) throw new Error(`no binaries in ${dir}: node scripts/cli-release.mjs build`);
80 const body = { version: v, published_at: now.toISOString(), files };
81 const text = `${JSON.stringify(body, null, 2)}\n`;
82 writeFileSync(join(dir, "manifest.json"), text);
83 writeFileSync(join(dir, "latest.json"), text);
84 writeFileSync(join(dir, "SHA256SUMS"), Object.values(files).map((f) => `${f.sha256} ${f.name}`).join("\n") + "\n");
85 return body;
86}
87
88/**
89 * The builder container's flags. Its target is target/zig in the
90 * checkout, which the workflows cache (cli-release-…). In a workflow job,
91 * Cargo's registry is the job's own ~/.cargo/registry, which the crates
92 * entry (cargo-crates-…) restores; elsewhere a Docker volume keeps it.
93 */
94export function builderArgs({ root = ROOT, env = process.env, home = homedir() } = {}) {
95 const registry = env.GITHUB_ACTIONS === "true" ? `${join(env.CARGO_HOME || join(home, ".cargo"), "registry").replaceAll("\\", "/")}` : "g1t-cargo-registry";
96 return [
97 "-e", "CARGO_TARGET_DIR=/src/target/zig",
98 "-v", `${root.replaceAll("\\", "/")}:/src`,
99 "-v", `${registry}:/usr/local/cargo/registry`,
100 "-w", "/src", BUILDER,
101 ];
102}
103
104/**
105 * What the container runs. It runs as root, so on a host with users what
106 * it wrote is handed back to the one running this, for the cache to save
107 * and prune.
108 */
109export function builderScript(triples, owner = process.getuid ? `${process.getuid()}:${process.getgid()}` : null) {
110 const build = `set -e; for t in ${triples}; do rustup target add $t >/dev/null 2>&1; cargo zigbuild --release --locked --package g1t --target $t; done`;
111 return owner ? `trap 'chown -R ${owner} /src/target/zig /usr/local/cargo/registry' EXIT; ${build}` : build;
112}
113
114function build() {
115 const v = version();
116 const dir = outDir(v);
117 mkdirSync(dir, { recursive: true });
118 const triples = Object.values(TARGETS).map((t) => t.triple).join(" ");
119 // One container builds every platform; its target folder is kept apart
120 // from the host's so the two never mix.
121 run("docker", ["run", "--rm", ...builderArgs(), "sh", "-c", builderScript(triples)], { env: { ...process.env, MSYS_NO_PATHCONV: "1" } });
122 for (const target of Object.values(TARGETS)) {
123 const built = join(ROOT, "target", "zig", target.triple, "release", target.exe ? "g1t.exe" : "g1t");
124 writeFileSync(join(dir, target.file), readFileSync(built));
125 }
126 const m = manifest(dir, v);
127 console.log(`built ${Object.keys(m.files).length} binaries of g1t ${v} into ${dir}`);
128}
129
130function privateKey(text = process.env.RUNNER_RELEASE_KEY) {
131 if (!text) throw new Error("RUNNER_RELEASE_KEY is not set");
132 return createPrivateKey({ key: Buffer.from(text, "base64"), format: "der", type: "pkcs8" });
133}
134
135function signRelease() {
136 const dir = outDir();
137 const bytes = readFileSync(join(dir, "latest.json"));
138 writeFileSync(join(dir, "latest.json.sig"), signBytes(bytes, privateKey()));
139 console.log(`signed g1t ${version()}`);
140}
141
142function verifyRelease() {
143 const dir = outDir();
144 const publicRaw = process.env.G1T_RUNNER_RELEASE_KEY;
145 if (!publicRaw) throw new Error("G1T_RUNNER_RELEASE_KEY is not set");
146 const ok = verifyBytes(readFileSync(join(dir, "latest.json")), readFileSync(join(dir, "latest.json.sig"), "utf8"), publicRaw);
147 if (!ok) throw new Error("latest.json's signature is not the release key's");
148 const m = JSON.parse(readFileSync(join(dir, "latest.json"), "utf8"));
149 for (const file of Object.values(m.files)) {
150 if (sha256(readFileSync(join(dir, file.name))) !== file.sha256) throw new Error(`${file.name} is not the file the manifest names`);
151 }
152 console.log(`g1t ${m.version}: the signature and every file check out`);
153}
154
155function publish(dryRun) {
156 const v = version();
157 const dir = outDir(v);
158 if (!existsSync(join(dir, "manifest.json"))) throw new Error(`nothing built for ${v}: node scripts/cli-release.mjs build`);
159 const signed = existsSync(join(dir, "latest.json.sig"));
160 const put = (key, file, type) => {
161 if (dryRun) return console.log(`would put ${key}`);
162 run("npx", ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type], {
163 shell: process.platform === "win32",
164 // Away from the repository's .env, which may hold a token meant for
165 // something else (as scripts/deploy does).
166 cwd: join(ROOT, "apps/web"),
167 });
168 };
169 const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
170 for (const file of Object.values(m.files)) put(`cli/${v}/${file.name}`, join(dir, file.name), "application/octet-stream");
171 put(`cli/${v}/manifest.json`, join(dir, "manifest.json"), "application/json");
172 put(`cli/${v}/SHA256SUMS`, join(dir, "SHA256SUMS"), "text/plain");
173 if (signed) put(`cli/${v}/latest.json.sig`, join(dir, "latest.json.sig"), "text/plain");
174 // Last, so `latest` never names a version whose files are not up yet.
175 if (signed) put("cli/latest.json.sig", join(dir, "latest.json.sig"), "text/plain");
176 put("cli/latest.json", join(dir, "latest.json"), "application/json");
177}
178
179if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/cli-release.mjs")) {
180 const [command, ...rest] = process.argv.slice(2);
181 if (command === "build") build();
182 else if (command === "manifest") manifest(outDir(), version());
183 else if (command === "sign") signRelease();
184 else if (command === "verify") verifyRelease();
185 else if (command === "publish") publish(rest.includes("--dry-run"));
186 else {
187 console.error("usage: node scripts/cli-release.mjs build|manifest|sign|verify|publish [--dry-run]");
188 process.exit(2);
189 }
190}