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"); | |
| 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 | ||
| 209 | const isNotes = (name) => /^collect-.*\.json$/.test(name); | |
| 210 | ||
| 211 | function 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 | ||
| 224 | function 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 | ||
| 243 | if (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": | |
| 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. | 255 | build(rest.includes("--windows-from-linux"), rest.includes("--only-windows")); |
| preparing for cloud work | 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 | } |