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 work | 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 | |
| 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 work | 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 | ||
| 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. */ |
| 159 | function build(windowsFromLinux, onlyWindows = false) { | |
| preparing for cloud work | 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"); |
| 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 work | 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 | */ | |
| 180 | function 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 work | 210 | console.log(`collected ${entries.length} files of ${v} into ${dir}`); |
| 211 | } | |
| 212 | ||
| 213 | const 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`. */ |
| 216 | export 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 work | 221 | function 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 | ||
| 234 | function 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 | ||
| 253 | if (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 work | 266 | 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 | } |