g1t/scripts/cli-release.mjs

103 lines5,123 bytesCodeBlame
1#!/usr/bin/env node
2// Releases of the g1t CLI (crates/g1t): built for every platform,
3// checksummed, and published to the g1t-downloads R2 bucket that g1t.sh
4// serves at /downloads/cli/ (apps/web/app/routes/downloads-runner.ts).
5//
6// node scripts/cli-release.mjs build # every platform, in the cargo-zigbuild image (Docker)
7// node scripts/cli-release.mjs publish [--dry-run]
8//
9// At cli/<version>/ in the bucket: g1t-linux-x64, -linux-arm64,
10// -macos-x64, -macos-arm64, -windows-x64.exe, SHA256SUMS and
11// manifest.json; at cli/: latest.json, the newest release's manifest.
12// Unlike the runner, the CLI does not update itself, so nothing is signed:
13// SHA256SUMS is what to check a download against.
14
15import { spawnSync } from "node:child_process";
16import { createHash } from "node:crypto";
17import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18import { dirname, join } from "node:path";
19import { fileURLToPath } from "node:url";
20
21const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
22const BUCKET = "g1t-downloads";
23const BUILDER = "ghcr.io/rust-cross/cargo-zigbuild:latest";
24export const TARGETS = {
25 "linux-x64": { triple: "x86_64-unknown-linux-musl", file: "g1t-linux-x64" },
26 "linux-arm64": { triple: "aarch64-unknown-linux-musl", file: "g1t-linux-arm64" },
27 "macos-x64": { triple: "x86_64-apple-darwin", file: "g1t-macos-x64" },
28 "macos-arm64": { triple: "aarch64-apple-darwin", file: "g1t-macos-arm64" },
29 "windows-x64": { triple: "x86_64-pc-windows-gnu", file: "g1t-windows-x64.exe", exe: true },
30};
31
32export function version() {
33 const found = /^version\s*=\s*"([^"]+)"/m.exec(readFileSync(join(ROOT, "crates/g1t/Cargo.toml"), "utf8"));
34 if (!found) throw new Error("crates/g1t/Cargo.toml has no version");
35 return found[1];
36}
37
38const outDir = (v = version()) => join(ROOT, "target", "cli-release", v);
39const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
40
41function run(command, args, options = {}) {
42 const done = spawnSync(command, args, { stdio: "inherit", cwd: ROOT, ...options });
43 if (done.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`);
44}
45
46function build() {
47 const v = version();
48 const dir = outDir(v);
49 mkdirSync(dir, { recursive: true });
50 const triples = Object.values(TARGETS).map((t) => t.triple).join(" ");
51 // One container builds every platform; its target folder is kept apart
52 // from the host's so the two never mix.
53 run("docker", [
54 "run", "--rm",
55 "-e", "CARGO_TARGET_DIR=/src/target/zig",
56 "-v", `${ROOT.replaceAll("\\", "/")}:/src`,
57 "-v", "g1t-cargo-registry:/usr/local/cargo/registry",
58 "-w", "/src", BUILDER, "sh", "-c",
59 `set -e; for t in ${triples}; do rustup target add $t >/dev/null 2>&1; cargo zigbuild --release --locked --package g1t --target $t; done`,
60 ], { env: { ...process.env, MSYS_NO_PATHCONV: "1" } });
61 const files = {};
62 for (const [platform, target] of Object.entries(TARGETS)) {
63 const built = join(ROOT, "target", "zig", target.triple, "release", target.exe ? "g1t.exe" : "g1t");
64 const bytes = readFileSync(built);
65 writeFileSync(join(dir, target.file), bytes);
66 files[platform] = { name: target.file, sha256: sha256(bytes) };
67 }
68 const manifest = { version: v, published_at: new Date().toISOString(), files };
69 writeFileSync(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
70 writeFileSync(join(dir, "latest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
71 writeFileSync(join(dir, "SHA256SUMS"), Object.values(files).map((f) => `${f.sha256} ${f.name}`).join("\n") + "\n");
72 console.log(`built ${Object.keys(files).length} binaries of g1t ${v} into ${dir}`);
73}
74
75function publish(dryRun) {
76 const v = version();
77 const dir = outDir(v);
78 if (!existsSync(join(dir, "manifest.json"))) throw new Error(`nothing built for ${v}: node scripts/cli-release.mjs build`);
79 const put = (key, file, type) => {
80 if (dryRun) return console.log(`would put ${key}`);
81 run("npx", ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type], {
82 shell: process.platform === "win32",
83 // Away from the repository's .env, which may hold a token meant for
84 // something else (as scripts/deploy does).
85 cwd: join(ROOT, "apps/web"),
86 });
87 };
88 for (const target of Object.values(TARGETS)) put(`cli/${v}/${target.file}`, join(dir, target.file), "application/octet-stream");
89 put(`cli/${v}/manifest.json`, join(dir, "manifest.json"), "application/json");
90 put(`cli/${v}/SHA256SUMS`, join(dir, "SHA256SUMS"), "text/plain");
91 // Last, so `latest` never names a version whose files are not up yet.
92 put("cli/latest.json", join(dir, "latest.json"), "application/json");
93}
94
95if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/cli-release.mjs")) {
96 const [command, ...rest] = process.argv.slice(2);
97 if (command === "build") build();
98 else if (command === "publish") publish(rest.includes("--dry-run"));
99 else {
100 console.error("usage: node scripts/cli-release.mjs build|publish [--dry-run]");
101 process.exit(2);
102 }
103}