| 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] |
| 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 | |
| 37 | import { spawnSync } from "node:child_process"; |
| 38 | import { createHash } from "node:crypto"; |
| 39 | import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; |
| 40 | import { basename, dirname, join } from "node:path"; |
| 41 | import { fileURLToPath } from "node:url"; |
| 42 | |
| 43 | const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 44 | const APP = join(ROOT, "apps/desktop"); |
| 45 | const TAURI = join(APP, "src-tauri"); |
| 46 | const BUCKET = "g1t-downloads"; |
| 47 | |
| 48 | /** What each platform's folder of bundles holds, and what the updater calls the platform. */ |
| 49 | export 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 | |
| 58 | export 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 | |
| 65 | const outDir = (v = version()) => join(ROOT, "target", "desktop-release", v); |
| 66 | export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); |
| 67 | |
| 68 | /** The platform this machine is, as releases name it. */ |
| 69 | export 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. */ |
| 75 | export 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 | */ |
| 85 | export 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. */ |
| 100 | export 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. */ |
| 114 | function 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 | */ |
| 131 | export 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 | |
| 147 | function 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 | |
| 152 | function 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 | function build(windowsFromLinux) { |
| 159 | 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"); |
| 160 | console.error(`building ${hostPlatform()}`); |
| 161 | run("npx", ["tauri", "build", "--ci"], { cwd: APP }); |
| 162 | if (windowsFromLinux) { |
| 163 | // Tauri's cross build: cargo-xwin for the MSVC target, NSIS for the |
| 164 | // installer, both on PATH (the release workflow installs them). |
| 165 | console.error("building windows-x64 from here"); |
| 166 | run("rustup", ["target", "add", PLATFORMS["windows-x64"].triple]); |
| 167 | run("npx", ["tauri", "build", "--ci", "--runner", "cargo-xwin", "--target", PLATFORMS["windows-x64"].triple, "--bundles", "nsis"], { cwd: APP }); |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Copies this machine's bundles into the release folder under their |
| 173 | * release names, with each updater file's signature, and notes what each |
| 174 | * is in collect.json there, one line per file, so folders from several |
| 175 | * machines merge by copying them together. |
| 176 | */ |
| 177 | function collect() { |
| 178 | const v = version(); |
| 179 | const dir = outDir(v); |
| 180 | mkdirSync(dir, { recursive: true }); |
| 181 | const entries = []; |
| 182 | for (const { platform, dir: bundles } of bundleDirs()) { |
| 183 | for (const file of bundleFiles(bundles)) { |
| 184 | const entry = classify(basename(file), platform, v); |
| 185 | if (!entry) continue; |
| 186 | const bytes = readFileSync(file); |
| 187 | copyFileSync(file, join(dir, entry.name)); |
| 188 | let signature = null; |
| 189 | if (entry.updater) { |
| 190 | const sig = `${file}.sig`; |
| 191 | if (!existsSync(sig)) throw new Error(`${basename(file)} has no .sig: was TAURI_SIGNING_PRIVATE_KEY set when it was built?`); |
| 192 | signature = readFileSync(sig, "utf8").trim(); |
| 193 | copyFileSync(sig, join(dir, `${entry.name}.sig`)); |
| 194 | } |
| 195 | entries.push({ ...entry, size: bytes.length, sha256: sha256(bytes), signature }); |
| 196 | console.error(`collected ${entry.name}`); |
| 197 | } |
| 198 | } |
| 199 | if (entries.length === 0) throw new Error("no bundles found: run build first"); |
| 200 | // One notes file per machine, so the folders of several machines merge |
| 201 | // by copying them together (the release workflow does). |
| 202 | writeFileSync(join(dir, `collect-${hostPlatform()}.json`), `${JSON.stringify(entries, null, 2)}\n`); |
| 203 | console.log(`collected ${entries.length} files of ${v} into ${dir}`); |
| 204 | } |
| 205 | |
| 206 | const isNotes = (name) => /^collect-.*\.json$/.test(name); |
| 207 | |
| 208 | function writeManifest(notes) { |
| 209 | const v = version(); |
| 210 | const dir = outDir(v); |
| 211 | const collected = existsSync(dir) ? readdirSync(dir).filter(isNotes) : []; |
| 212 | if (collected.length === 0) throw new Error(`${dir} has no collect-*.json: run collect first`); |
| 213 | const entries = collected.flatMap((name) => JSON.parse(readFileSync(join(dir, name), "utf8"))); |
| 214 | const m = manifest(entries, v, { notes }); |
| 215 | writeFileSync(join(dir, "manifest.json"), `${JSON.stringify(m, null, 2)}\n`); |
| 216 | writeFileSync(join(dir, "latest.json"), `${JSON.stringify(m, null, 2)}\n`); |
| 217 | writeFileSync(join(dir, "SHA256SUMS"), entries.map((e) => `${e.sha256} ${e.name}`).join("\n") + "\n"); |
| 218 | console.log(`manifest for ${v}: ${Object.keys(m.platforms).length} platforms the updater serves, ${m.downloads.length} downloads`); |
| 219 | } |
| 220 | |
| 221 | function publish(dryRun) { |
| 222 | const v = version(); |
| 223 | const dir = outDir(v); |
| 224 | if (!existsSync(join(dir, "latest.json"))) throw new Error("no latest.json: run manifest first"); |
| 225 | const put = (key, file, type) => { |
| 226 | const args = ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type]; |
| 227 | if (dryRun) console.log(`would put ${key}`); |
| 228 | else run("npx", args, { cwd: join(ROOT, "apps/web") }); |
| 229 | }; |
| 230 | const type = (name) => (name.endsWith(".json") ? "application/json" : name.endsWith(".sig") || name === "SHA256SUMS" ? "text/plain" : "application/octet-stream"); |
| 231 | // The version's files first; latest.json last, so no app is pointed at |
| 232 | // files that are not there yet. |
| 233 | for (const name of readdirSync(dir)) { |
| 234 | if (name === "latest.json" || isNotes(name)) continue; |
| 235 | put(`desktop/${v}/${name}`, join(dir, name), type(name)); |
| 236 | } |
| 237 | put("desktop/latest.json", join(dir, "latest.json"), "application/json"); |
| 238 | } |
| 239 | |
| 240 | if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/desktop-release.mjs")) { |
| 241 | const [command, ...rest] = process.argv.slice(2); |
| 242 | const option = (name) => { |
| 243 | const at = rest.indexOf(`--${name}`); |
| 244 | return at >= 0 ? rest[at + 1] : undefined; |
| 245 | }; |
| 246 | try { |
| 247 | switch (command) { |
| 248 | case "keygen": |
| 249 | keygen(); |
| 250 | break; |
| 251 | case "build": |
| 252 | build(rest.includes("--windows-from-linux")); |
| 253 | break; |
| 254 | case "collect": |
| 255 | collect(); |
| 256 | break; |
| 257 | case "manifest": |
| 258 | writeManifest(option("notes") ?? ""); |
| 259 | break; |
| 260 | case "publish": |
| 261 | publish(rest.includes("--dry-run")); |
| 262 | break; |
| 263 | default: |
| 264 | console.error("usage: node scripts/desktop-release.mjs keygen|build|collect|manifest|publish"); |
| 265 | process.exit(2); |
| 266 | } |
| 267 | } catch (error) { |
| 268 | console.error(String(error.message ?? error)); |
| 269 | process.exit(1); |
| 270 | } |
| 271 | } |