Files
buzz/desktop/scripts/publish-updater-to-github-release.mjs
2026-03-25 15:29:04 -07:00

224 lines
5.9 KiB
JavaScript

import { execFileSync } from "node:child_process";
import {
cpSync,
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
const repo = process.env.GITHUB_REPOSITORY ?? "block/sprout";
const version = requireVersionEnv();
const latestTag = "sprout-desktop-latest";
const tauriTarget = process.env.TAURI_TARGET ?? "aarch64-apple-darwin";
const updaterPlatform = process.env.UPDATER_PLATFORM ?? "darwin-aarch64";
const dryRun = process.env.DRY_RUN === "true" || process.env.DRY_RUN === "1";
const defaultBundleDirs = [
`src-tauri/target/${tauriTarget}/release/bundle/macos`,
"src-tauri/target/release/bundle/macos",
];
const bundleDir = resolve(
process.cwd(),
process.env.UPDATER_BUNDLE_DIR ??
defaultBundleDirs.find((dir) => existsSync(resolve(process.cwd(), dir))) ??
defaultBundleDirs[0],
);
const latestPath = join(bundleDir, "latest.json");
function requireVersionEnv() {
const v = process.env.VERSION;
if (!v || !v.trim()) {
throw new Error(
"VERSION env var is required. CI sets this from the git tag; for local use, run: VERSION=x.y.z pnpm run release:updater:publish",
);
}
return v.trim();
}
function requirePath(path) {
if (!existsSync(path)) {
throw new Error(`Missing required file: ${path}`);
}
}
function runGh(args, options = {}) {
return execFileSync("gh", args, options);
}
function releaseExists(tag) {
try {
runGh(["release", "view", tag, "--repo", repo], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
function ensureRelease(tag, title) {
if (releaseExists(tag)) {
return;
}
const args = [
"release",
"create",
tag,
"--repo",
repo,
"--title",
title,
"--notes",
"Automated release placeholder.",
];
runGh(args, { stdio: "inherit" });
}
function readReleaseAssets(tag) {
const assetsRaw = runGh(
[
"release",
"view",
tag,
"--repo",
repo,
"--json",
"assets",
"--jq",
".assets[].name",
],
{ encoding: "utf-8" },
);
return assetsRaw
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function downloadUrl(name) {
return `https://github.com/${repo}/releases/download/${latestTag}/${encodeURIComponent(name)}`;
}
function resolveArchivePath() {
const canonicalArchiveName = "Sprout.app.tar.gz";
const canonicalPath = join(bundleDir, canonicalArchiveName);
if (existsSync(canonicalPath)) {
return canonicalPath;
}
const candidates = readdirSync(bundleDir).filter((entry) =>
entry.endsWith(".app.tar.gz"),
);
if (candidates.length === 1) {
return join(bundleDir, candidates[0]);
}
if (candidates.length === 0) {
throw new Error(
`Could not find updater archive in ${bundleDir}. Expected ${canonicalArchiveName}.`,
);
}
throw new Error(
`Found multiple updater archives in ${bundleDir}: ${candidates.join(", ")}. Cannot determine which to use.`,
);
}
function buildLatestJson(signaturePath) {
const signature = readFileSync(signaturePath, "utf-8").trim();
return {
version,
notes: `Release v${version}.`,
pub_date: new Date().toISOString(),
platforms: {
[updaterPlatform]: {
signature,
url: "",
},
},
};
}
function main() {
const archivePath = resolveArchivePath();
const signaturePath = `${archivePath}.sig`;
requirePath(archivePath);
requirePath(signaturePath);
const latest = existsSync(latestPath)
? JSON.parse(readFileSync(latestPath, "utf-8"))
: buildLatestJson(signaturePath);
latest.version = version;
latest.pub_date = new Date().toISOString();
const platformRecord = latest?.platforms?.[updaterPlatform];
if (!platformRecord) {
const available = Object.keys(latest?.platforms ?? {});
throw new Error(
`Platform "${updaterPlatform}" missing in latest.json. Available: ${available.join(", ") || "(none)"}`,
);
}
const archiveName = basename(archivePath);
const signatureName = basename(signaturePath);
platformRecord.signature = readFileSync(signaturePath, "utf-8").trim();
platformRecord.url = downloadUrl(archiveName);
const stageDir = mkdtempSync(join(tmpdir(), "sprout-github-updater-"));
const stagedLatestPath = join(stageDir, "latest.json");
try {
cpSync(archivePath, join(stageDir, archiveName));
cpSync(signaturePath, join(stageDir, signatureName));
writeFileSync(stagedLatestPath, `${JSON.stringify(latest, null, 2)}\n`);
const uploadArgs = [
"release",
"upload",
latestTag,
stagedLatestPath,
join(stageDir, archiveName),
join(stageDir, signatureName),
"--repo",
repo,
"--clobber",
];
console.log(`Preparing updater upload for ${repo}`);
console.log(`- latest tag: ${latestTag}`);
console.log(`- updater archive: ${archiveName}`);
console.log(`- updater endpoint: ${downloadUrl("latest.json")}`);
if (dryRun) {
console.log("DRY_RUN enabled. Skipping upload.");
console.log(
`gh release view ${latestTag} --repo ${repo} || gh release create ${latestTag} --repo ${repo} --title "Sprout Desktop Latest" --notes "Automated release placeholder."`,
);
console.log(`gh ${uploadArgs.join(" ")}`);
return;
}
ensureRelease(latestTag, "Sprout Desktop Latest");
runGh(uploadArgs, { stdio: "inherit" });
const latestAssets = readReleaseAssets(latestTag);
for (const expected of ["latest.json", archiveName, signatureName]) {
if (!latestAssets.includes(expected)) {
throw new Error(
`Release ${latestTag} is missing ${expected} after upload`,
);
}
}
console.log("GitHub updater assets verified.");
console.log(`Updater endpoint: ${downloadUrl("latest.json")}`);
} finally {
rmSync(stageDir, { recursive: true, force: true });
}
}
main();