Skip to content
274 linesCodeBlameRaw
1#!/usr/bin/env node
2// Releases of the desktop app (apps/desktop): built on each platform,
3// signed by Tauri's updater key, collected into one folder, described in
4// a manifest, and published to the g1t-downloads R2 bucket that g1t.sh
5// serves at /downloads/desktop/ (apps/web/app/routes/downloads-runner.ts).
6// The app checks `desktop/latest.json` for updates
7// (apps/desktop/src-tauri/src/updates.rs); g1t.sh/download lists the same
8// release (apps/web/app/routes/download.tsx).
9//
10// node scripts/desktop-release.mjs keygen # once: the updater's signing key
11// node scripts/desktop-release.mjs build [--windows-from-linux [--only-windows]]
12// node scripts/desktop-release.mjs collect # this machine's bundles, into the release folder
13// node scripts/desktop-release.mjs manifest [--notes "…"] # latest.json and SHA256SUMS from what is collected
14// node scripts/desktop-release.mjs publish [--dry-run]
15//
16// What a release is, at desktop/<version>/ in the bucket:
17//
18// g1t-<version>-windows-x64-setup.exe (+ .sig) the Windows installer, and what the updater fetches
19// g1t-<version>-macos-arm64.dmg, -macos-x64.dmg what people download on a Mac
20// g1t-<version>-macos-arm64.app.tar.gz (+ .sig) what the updater fetches on a Mac
21// g1t-<version>-linux-x64.AppImage (+ .sig) runs anywhere; what the updater fetches on Linux
22// g1t-<version>-linux-x64.deb, .rpm packages
23// SHA256SUMS `sha256 name`, one line each
24// manifest.json { version, pub_date, notes, platforms, downloads }
25//
26// and at desktop/: latest.json, the newest release's manifest. `platforms`
27// is what Tauri's updater reads: each platform's file and its signature,
28// made with the private key as the bundles are built and checked against
29// the public key in tauri.conf.json. `downloads` is what the download page
30// lists: every file, with its platform, kind, size and SHA-256.
31//
32// Environment:
33// TAURI_SIGNING_PRIVATE_KEY the updater's private key (keygen makes it): a g1t Actions secret
34// TAURI_SIGNING_PRIVATE_KEY_PASSWORD its password; empty for a key made with --ci
35// CLOUDFLARE_API_TOKEN for publish
36
37import { spawnSync } from "node:child_process";
38import { createHash } from "node:crypto";
39import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
40import { basename, dirname, join } from "node:path";
41import { fileURLToPath } from "node:url";
42
43const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
44const APP = join(ROOT, "apps/desktop");
45const TAURI = join(APP, "src-tauri");
46const BUCKET = "g1t-downloads";
47
48/** What each platform's folder of bundles holds, and what the updater calls the platform. */
49export const PLATFORMS = {
50 "windows-x64": { triple: "x86_64-pc-windows-msvc", updater: "windows-x86_64" },
51 "windows-arm64": { triple: "aarch64-pc-windows-msvc", updater: "windows-aarch64" },
52 "macos-arm64": { triple: "aarch64-apple-darwin", updater: "darwin-aarch64" },
53 "macos-x64": { triple: "x86_64-apple-darwin", updater: "darwin-x86_64" },
54 "linux-x64": { triple: "x86_64-unknown-linux-gnu", updater: "linux-x86_64" },
55 "linux-arm64": { triple: "aarch64-unknown-linux-gnu", updater: "linux-aarch64" },
56};
57
58export function version() {
59 const toml = readFileSync(join(TAURI, "Cargo.toml"), "utf8");
60 const found = /^version\s*=\s*"([^"]+)"/m.exec(toml);
61 if (!found) throw new Error("apps/desktop/src-tauri/Cargo.toml has no version");
62 return found[1];
63}
64
65const outDir = (v = version()) => join(ROOT, "target", "desktop-release", v);
66export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
67
68/** The platform this machine is, as releases name it. */
69export function hostPlatform(platform = process.platform, arch = process.arch) {
70 const os = platform === "win32" ? "windows" : platform === "darwin" ? "macos" : "linux";
71 return `${os}-${arch === "arm64" ? "arm64" : "x64"}`;
72}
73
74/** The platform a Rust target triple builds for, or null. */
75export function platformOfTriple(triple) {
76 return Object.keys(PLATFORMS).find((key) => PLATFORMS[key].triple === triple) ?? null;
77}
78
79/**
80 * A bundle's place in a release: `{ platform, arch, kind, name, updater }`
81 * for a file Tauri produced, or null for one that is not published (an
82 * MSI, say). `name` is the file's name in the release, `updater` whether
83 * the updater fetches this kind of file on that platform.
84 */
85export function classify(file, platform, v) {
86 const [os, arch] = platform.split("-");
87 const lower = file.toLowerCase();
88 const named = (suffix) => `g1t-${v}-${platform}${suffix}`;
89 if (lower.endsWith(".sig")) return null;
90 if (os === "windows" && lower.endsWith("-setup.exe")) return { platform: os, arch, kind: "installer", name: named("-setup.exe"), updater: true };
91 if (os === "macos" && lower.endsWith(".app.tar.gz")) return { platform: os, arch, kind: "update", name: named(".app.tar.gz"), updater: true };
92 if (os === "macos" && lower.endsWith(".dmg")) return { platform: os, arch, kind: "dmg", name: named(".dmg"), updater: false };
93 if (os === "linux" && lower.endsWith(".appimage")) return { platform: os, arch, kind: "appimage", name: named(".AppImage"), updater: true };
94 if (os === "linux" && lower.endsWith(".deb")) return { platform: os, arch, kind: "deb", name: named(".deb"), updater: false };
95 if (os === "linux" && lower.endsWith(".rpm")) return { platform: os, arch, kind: "rpm", name: named(".rpm"), updater: false };
96 return null;
97}
98
99/** The folders Tauri put bundles in, by the platform each is for. */
100export function bundleDirs(target = join(TAURI, "target")) {
101 const found = [];
102 const host = join(target, "release", "bundle");
103 if (existsSync(host)) found.push({ platform: hostPlatform(), dir: host });
104 if (!existsSync(target)) return found;
105 for (const entry of readdirSync(target)) {
106 const platform = platformOfTriple(entry);
107 const dir = join(target, entry, "release", "bundle");
108 if (platform && existsSync(dir)) found.push({ platform, dir });
109 }
110 return found;
111}
112
113/** Every file in a bundle folder, one level of kind folders down. */
114function bundleFiles(dir) {
115 const files = [];
116 for (const kind of readdirSync(dir)) {
117 const folder = join(dir, kind);
118 if (!statSync(folder).isDirectory()) continue;
119 for (const file of readdirSync(folder)) {
120 if (statSync(join(folder, file)).isFile()) files.push(join(folder, file));
121 }
122 }
123 return files;
124}
125
126/**
127 * The manifest of a release folder: Tauri's updater format (`platforms`),
128 * plus `downloads` for the download page. `entries` are the collected
129 * files' notes (collect.json), `signatures` each updater file's `.sig`.
130 */
131export function manifest(entries, v, { date = new Date().toISOString(), notes = "", base = `https://g1t.sh/downloads/desktop/${v}` } = {}) {
132 const platforms = {};
133 const downloads = [];
134 for (const entry of entries) {
135 if (entry.updater && entry.signature) {
136 const key = PLATFORMS[`${entry.platform}-${entry.arch}`]?.updater;
137 if (key) platforms[key] = { url: `${base}/${entry.name}`, signature: entry.signature };
138 }
139 if (entry.kind !== "update") {
140 downloads.push({ platform: entry.platform, arch: entry.arch, kind: entry.kind, name: entry.name, size: entry.size, sha256: entry.sha256 });
141 }
142 }
143 downloads.sort((a, b) => a.platform.localeCompare(b.platform) || a.arch.localeCompare(b.arch) || a.kind.localeCompare(b.kind));
144 return { version: v, pub_date: date, notes, platforms, downloads };
145}
146
147function run(command, args, options = {}) {
148 const done = spawnSync(command, args, { stdio: "inherit", cwd: ROOT, shell: process.platform === "win32", ...options });
149 if (done.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`);
150}
151
152function keygen() {
153 console.error("Making the updater's key pair with Tauri's signer. Keep the private key as the g1t Actions secret DESKTOP_SIGNING_KEY,");
154 console.error("and put the public key in apps/desktop/src-tauri/tauri.conf.json (plugins.updater.pubkey).");
155 run("npx", ["tauri", "signer", "generate", "--ci"], { cwd: APP });
156}
157
158/** `windowsFromLinux` adds the Windows cross build; `onlyWindows` skips this machine's own. */
159function build(windowsFromLinux, onlyWindows = false) {
160 if (!process.env.TAURI_SIGNING_PRIVATE_KEY) console.error("warning: TAURI_SIGNING_PRIVATE_KEY is not set; these bundles cannot be fetched by the updater");
161 if (!onlyWindows) {
162 console.error(`building ${hostPlatform()}`);
163 run("npx", ["tauri", "build", "--ci"], { cwd: APP });
164 }
165 if (windowsFromLinux) {
166 // Tauri's cross build: cargo-xwin for the MSVC target, NSIS for the
167 // installer, both on PATH (the release workflow installs them).
168 console.error("building windows-x64 from here");
169 run("rustup", ["target", "add", PLATFORMS["windows-x64"].triple]);
170 run("npx", ["tauri", "build", "--ci", "--runner", "cargo-xwin", "--target", PLATFORMS["windows-x64"].triple, "--bundles", "nsis"], { cwd: APP });
171 }
172}
173
174/**
175 * Copies this machine's bundles into the release folder under their
176 * release names, with each updater file's signature, and notes what each
177 * is in collect.json there, one line per file, so folders from several
178 * machines merge by copying them together.
179 */
180function collect() {
181 const v = version();
182 const dir = outDir(v);
183 mkdirSync(dir, { recursive: true });
184 const entries = [];
185 for (const { platform, dir: bundles } of bundleDirs()) {
186 for (const file of bundleFiles(bundles)) {
187 const entry = classify(basename(file), platform, v);
188 if (!entry) continue;
189 const bytes = readFileSync(file);
190 copyFileSync(file, join(dir, entry.name));
191 let signature = null;
192 if (entry.updater) {
193 const sig = `${file}.sig`;
194 if (!existsSync(sig)) throw new Error(`${basename(file)} has no .sig: was TAURI_SIGNING_PRIVATE_KEY set when it was built?`);
195 signature = readFileSync(sig, "utf8").trim();
196 copyFileSync(sig, join(dir, `${entry.name}.sig`));
197 }
198 entries.push({ ...entry, size: bytes.length, sha256: sha256(bytes), signature });
199 console.error(`collected ${entry.name}`);
200 }
201 }
202 if (entries.length === 0) throw new Error("no bundles found: run build first");
203 // One notes file per machine, so the folders of several machines merge
204 // by copying them together (the release workflow does).
205 writeFileSync(join(dir, `collect-${hostPlatform()}.json`), `${JSON.stringify(entries, null, 2)}\n`);
206 console.log(`collected ${entries.length} files of ${v} into ${dir}`);
207}
208
209const isNotes = (name) => /^collect-.*\.json$/.test(name);
210
211function writeManifest(notes) {
212 const v = version();
213 const dir = outDir(v);
214 const collected = existsSync(dir) ? readdirSync(dir).filter(isNotes) : [];
215 if (collected.length === 0) throw new Error(`${dir} has no collect-*.json: run collect first`);
216 const entries = collected.flatMap((name) => JSON.parse(readFileSync(join(dir, name), "utf8")));
217 const m = manifest(entries, v, { notes });
218 writeFileSync(join(dir, "manifest.json"), `${JSON.stringify(m, null, 2)}\n`);
219 writeFileSync(join(dir, "latest.json"), `${JSON.stringify(m, null, 2)}\n`);
220 writeFileSync(join(dir, "SHA256SUMS"), entries.map((e) => `${e.sha256} ${e.name}`).join("\n") + "\n");
221 console.log(`manifest for ${v}: ${Object.keys(m.platforms).length} platforms the updater serves, ${m.downloads.length} downloads`);
222}
223
224function publish(dryRun) {
225 const v = version();
226 const dir = outDir(v);
227 if (!existsSync(join(dir, "latest.json"))) throw new Error("no latest.json: run manifest first");
228 const put = (key, file, type) => {
229 const args = ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type];
230 if (dryRun) console.log(`would put ${key}`);
231 else run("npx", args, { cwd: join(ROOT, "apps/web") });
232 };
233 const type = (name) => (name.endsWith(".json") ? "application/json" : name.endsWith(".sig") || name === "SHA256SUMS" ? "text/plain" : "application/octet-stream");
234 // The version's files first; latest.json last, so no app is pointed at
235 // files that are not there yet.
236 for (const name of readdirSync(dir)) {
237 if (name === "latest.json" || isNotes(name)) continue;
238 put(`desktop/${v}/${name}`, join(dir, name), type(name));
239 }
240 put("desktop/latest.json", join(dir, "latest.json"), "application/json");
241}
242
243if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/desktop-release.mjs")) {
244 const [command, ...rest] = process.argv.slice(2);
245 const option = (name) => {
246 const at = rest.indexOf(`--${name}`);
247 return at >= 0 ? rest[at + 1] : undefined;
248 };
249 try {
250 switch (command) {
251 case "keygen":
252 keygen();
253 break;
254 case "build":
255 build(rest.includes("--windows-from-linux"), rest.includes("--only-windows"));
256 break;
257 case "collect":
258 collect();
259 break;
260 case "manifest":
261 writeManifest(option("notes") ?? "");
262 break;
263 case "publish":
264 publish(rest.includes("--dry-run"));
265 break;
266 default:
267 console.error("usage: node scripts/desktop-release.mjs keygen|build|collect|manifest|publish");
268 process.exit(2);
269 }
270 } catch (error) {
271 console.error(String(error.message ?? error));
272 process.exit(1);
273 }
274}