Files
miniSCP/scripts/release.ts
T
JokinandClaude 6c05e39ce1 Re-sign electrobun's CLI binary so the app actually launches
`bun run start` printed nothing and exited 0 without ever opening a window.
electrobun's prebuilt CLI is a Bun single-file executable whose payload is
appended after the linker ad-hoc signed it, so `codesign -v` reports "code or
signature have been modified" and the macOS kernel SIGKILLs it at exec. The
failure was invisible because bin/electrobun.cjs spawns the real binary and
forwards `process.exit(code || 0)` — a signal death has `code === null`.

Confirmed upstream, not local corruption: a fresh download from the GitHub
release hashes identical to the shipped copy and fails the same check. Both
the darwin-arm64 and darwin-x64 CLI tarballs are affected.

scripts/fix-electrobun-cli.ts re-seals the binary with `codesign --force
--sign -`. It runs from postinstall AND ahead of every electrobun invocation,
because the wrapper downloads the CLI lazily on first use rather than at
install time — postinstall alone would no-op on a fresh checkout. When the
binary is missing it provokes that download first, so a clean tree self-heals.
It also fixes .cache/electrobun, which the wrapper restores bin/ from, and
release.ts re-signs the Rosetta x64 CLI on download and on cache reuse.

Idempotent (~0.3s when already valid) and a no-op off macOS. Verified from a
deleted-binary state: `bun run start` downloads, re-signs and launches.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-22 13:10:28 +02:00

137 lines
5.9 KiB
TypeScript

// 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/<target>/miniSCP-Build<N>-<target>.<ext>.
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<string> {
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}`);