// Build one release artifact for one target, on the matching host: // bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64] // Default target = the host. electrobun cannot cross-compile OSes (its // packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on // Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on // an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under // Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64. // Artifacts land in release//miniSCP-Build-.. import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { ensureSigned } from "./fix-electrobun-cli"; const ROOT = path.join(import.meta.dir, ".."); const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const; type Target = (typeof TARGETS)[number]; function hostTarget(): Target { if (process.platform === "win32") return "win-x64"; return process.arch === "arm64" ? "mac-arm64" : "mac-x64"; } function fail(message: string): never { console.error(`\n[release] ERROR: ${message}`); process.exit(1); } function run(cmd: string[], opts: { cwd?: string } = {}): void { console.log(`[release] $ ${cmd.join(" ")}`); const proc = Bun.spawnSync(cmd, { cwd: opts.cwd ?? ROOT, stdout: "inherit", stderr: "inherit", }); if (!proc.success) fail(`command failed: ${cmd.join(" ")}`); } // ---- target selection + host guards ---- const argIndex = process.argv.indexOf("--target"); const requested = (argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget()) as Target; if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS.join(", ")})`); if (requested === "win-x64" && process.platform !== "win32") { fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile."); } if (requested.startsWith("mac") && process.platform !== "darwin") { fail(`${requested} must be built on macOS.`); } const viaRosetta = requested === "mac-x64" && process.arch === "arm64"; // ---- prepare: version, icons, UI bundle ---- run(["bun", "scripts/gen-version.ts"]); run(["bun", "scripts/fix-electrobun-cli.ts"]); run(["bun", "scripts/gen-icons.ts"]); run(["bun", "run", "--cwd", "ui", "build"]); const { BUILD } = await import("../src/shared/version.gen"); const { BRANDING } = await import("../src/shared/branding"); const buildName = `Build${BUILD.number}`; // ---- electrobun build (native, or x64 CLI under Rosetta) ---- async function x64Cli(): Promise { const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json(); const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`); const bin = path.join(dir, "electrobun"); // Same broken upstream ad-hoc signature as the arm64 CLI (see // scripts/fix-electrobun-cli.ts) — unsigned it is SIGKILLed on exec. if (existsSync(bin)) { if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`); return bin; } console.log(`[release] downloading electrobun x64 CLI ${pkg.version} (one-time)…`); mkdirSync(dir, { recursive: true }); const url = `https://github.com/blackboardsh/electrobun/releases/download/v${pkg.version}/electrobun-cli-darwin-x64.tar.gz`; const tarball = path.join(dir, "cli.tar.gz"); run(["curl", "-fsSL", "-o", tarball, url]); run(["tar", "-xzf", tarball, "-C", dir]); if (!existsSync(bin)) fail(`x64 CLI tarball did not contain "electrobun" (${dir})`); run(["chmod", "+x", bin]); if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`); return bin; } if (viaRosetta) { // x64 Mach-O runs under Rosetta transparently; it sees process.arch === x64 const rosetta = Bun.spawnSync(["arch", "-x86_64", "true"]); if (!rosetta.success) { fail("Rosetta 2 is not installed — run: softwareupdate --install-rosetta --agree-to-license"); } run([await x64Cli(), "build", "--env=stable"]); } else { run(["bunx", "electrobun", "build", "--env=stable"]); } // ---- collect artifacts ---- const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)]; const prefix = `stable-${osName}-${archName}-`; const artifactsDir = path.join(ROOT, "artifacts"); const outDir = path.join(ROOT, "release", requested); mkdirSync(outDir, { recursive: true }); const produced = readdirSync(artifactsDir).filter( (f) => f.startsWith(prefix) && f.includes(BRANDING.appName), ); if (!produced.length) fail(`no ${prefix}* artifacts found in artifacts/ — did the build run?`); const out: string[] = []; for (const file of produced) { const ext = file.endsWith(".dmg") ? ".dmg" : file.endsWith("-Setup.zip") ? "-Setup.zip" : null; if (!ext) continue; // skip the raw .app.tar.zst + update.json const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`; cpSync(path.join(artifactsDir, file), path.join(outDir, name)); out.push(name); } // Windows: also produce a portable zip from the assembled app folder (the // Setup.exe is an installer; portable = unzip anywhere and run). Best-effort: // the folder layout is confirmed by the first real Windows build. if (requested === "win-x64") { const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName); if (existsSync(appDir)) { const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`); run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]); out.push(path.basename(portable)); } else { console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`); } } if (!out.length) fail("artifacts were found but none matched a known package extension."); console.log(`\n[release] ${BRANDING.appName} ${BRANDING.buildLabel} (${BUILD.commit}) — ${requested}`); for (const name of out) console.log(`[release] release/${requested}/${name}`);