Skip to content
284 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

preparing for cloud work1#!/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
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.11// node scripts/desktop-release.mjs build [--windows-from-linux [--only-windows]]
preparing for cloud work12// 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
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.158/** `windowsFromLinux` adds the Windows cross build; `onlyWindows` skips this machine's own. */
159function build(windowsFromLinux, onlyWindows = false) {
preparing for cloud work160 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");
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.161 if (!onlyWindows) {
162 console.error(`building ${hostPlatform()}`);
163 run("npx", ["tauri", "build", "--ci"], { cwd: APP });
164 }
preparing for cloud work165 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");
A desktop release lists every platform it built. Each build job notes what it collected in a file named for its machine, and the Windows build on Linux runs on the same kind of machine as the Linux build, so its notes replaced the Linux job's when the publish job merged them: 0.2.0's files all uploaded, but its manifest named Windows alone. The notes are named for the platforms they hold now.203 // One notes file per set of platforms collected, so the folders of
204 // several jobs merge by copying them together (the release workflow
205 // does). It is named for what it holds, not the machine: the Windows
206 // build on Linux runs on the same kind of machine as the Linux build, and
207 // a file named for the machine replaced the Linux notes, which left
208 // Linux out of the manifest.
209 writeFileSync(join(dir, notesName(entries)), `${JSON.stringify(entries, null, 2)}\n`);
preparing for cloud work210 console.log(`collected ${entries.length} files of ${v} into ${dir}`);
211}
212
213const isNotes = (name) => /^collect-.*\.json$/.test(name);
214
A desktop release lists every platform it built. Each build job notes what it collected in a file named for its machine, and the Windows build on Linux runs on the same kind of machine as the Linux build, so its notes replaced the Linux job's when the publish job merged them: 0.2.0's files all uploaded, but its manifest named Windows alone. The notes are named for the platforms they hold now.215/** The notes file's name for these entries: the platforms they are for, such as `collect-linux-x64.json`. */
216export function notesName(entries) {
217 const platforms = [...new Set(entries.map((entry) => `${entry.platform}-${entry.arch}`))].sort();
218 return `collect-${platforms.join("+")}.json`;
219}
220
preparing for cloud work221function writeManifest(notes) {
222 const v = version();
223 const dir = outDir(v);
224 const collected = existsSync(dir) ? readdirSync(dir).filter(isNotes) : [];
225 if (collected.length === 0) throw new Error(`${dir} has no collect-*.json: run collect first`);
226 const entries = collected.flatMap((name) => JSON.parse(readFileSync(join(dir, name), "utf8")));
227 const m = manifest(entries, v, { notes });
228 writeFileSync(join(dir, "manifest.json"), `${JSON.stringify(m, null, 2)}\n`);
229 writeFileSync(join(dir, "latest.json"), `${JSON.stringify(m, null, 2)}\n`);
230 writeFileSync(join(dir, "SHA256SUMS"), entries.map((e) => `${e.sha256} ${e.name}`).join("\n") + "\n");
231 console.log(`manifest for ${v}: ${Object.keys(m.platforms).length} platforms the updater serves, ${m.downloads.length} downloads`);
232}
233
234function publish(dryRun) {
235 const v = version();
236 const dir = outDir(v);
237 if (!existsSync(join(dir, "latest.json"))) throw new Error("no latest.json: run manifest first");
238 const put = (key, file, type) => {
239 const args = ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type];
240 if (dryRun) console.log(`would put ${key}`);
241 else run("npx", args, { cwd: join(ROOT, "apps/web") });
242 };
243 const type = (name) => (name.endsWith(".json") ? "application/json" : name.endsWith(".sig") || name === "SHA256SUMS" ? "text/plain" : "application/octet-stream");
244 // The version's files first; latest.json last, so no app is pointed at
245 // files that are not there yet.
246 for (const name of readdirSync(dir)) {
247 if (name === "latest.json" || isNotes(name)) continue;
248 put(`desktop/${v}/${name}`, join(dir, name), type(name));
249 }
250 put("desktop/latest.json", join(dir, "latest.json"), "application/json");
251}
252
253if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/desktop-release.mjs")) {
254 const [command, ...rest] = process.argv.slice(2);
255 const option = (name) => {
256 const at = rest.indexOf(`--${name}`);
257 return at >= 0 ? rest[at + 1] : undefined;
258 };
259 try {
260 switch (command) {
261 case "keygen":
262 keygen();
263 break;
264 case "build":
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.265 build(rest.includes("--windows-from-linux"), rest.includes("--only-windows"));
preparing for cloud work266 break;
267 case "collect":
268 collect();
269 break;
270 case "manifest":
271 writeManifest(option("notes") ?? "");
272 break;
273 case "publish":
274 publish(rest.includes("--dry-run"));
275 break;
276 default:
277 console.error("usage: node scripts/desktop-release.mjs keygen|build|collect|manifest|publish");
278 process.exit(2);
279 }
280 } catch (error) {
281 console.error(String(error.message ?? error));
282 process.exit(1);
283 }
284}