storebound/tools/release.ts

522 lines
38 KiB
TypeScript

#!/usr/bin/env bun
// One command for a whole storebound release on the owner's Mac (docs/desktop-release.md, "The release in one
// command"). Everything but `npm publish` (the owner's step; the commands are printed at the end):
//
// bun tools/release.ts <version> [--yes] e.g. bun tools/release.ts 0.8.5 --yes
// bun tools/release.ts --rehearsal [<n>] [--yes] the same end to end as version 0.0.0-rehearsal.<n> on the branch
// rehearsal/<n>: a prerelease on git.crt.fyi and the rehearsal
// channel of storebound.dev (no app reads it), never npm and never
// /Applications; everything it made is deleted at the end
// bun tools/release.ts <version> [--sign-local] fallback: download the Linux / Windows installers and sign here
// bun tools/release.ts <version> --resume --yes after a stop past the push: HEAD is origin/main, tagged v<version>,
// with that version; skips the bump and continues from step 4
// bun tools/release.ts npm-token once: the npm granular token from the clipboard to ~/.storebound-keys
// bun tools/release.ts npm-publish npm publish + deprecate of this checkout's version with that token
// bun tools/release.ts mac-acceptance the macOS acceptance runs for this checkout's CLI key (cached)
//
// Without --yes: the checks and the plan only. With --yes, in order:
// 1. preflight: a clean checkout of origin/main whose candidate is green or still running (tools/release-candidate.ts),
// the updater key, the publish host; the macOS acceptance matrix starts in the background unless its results for
// this CLI key are cached (~/.storebound-releases/acceptance-cache/<key>/; a version bump keeps the key);
// 2. the version in package.json and every copy, the CHANGELOG's Unreleased section cut as "## <version> · <date>";
// 3. the fast gate (bun run ci:quick), the commit (as CRT), push to main, push the tag v<version>;
// 4. at once, in parallel: the Mac build (tools/desktop-release.ts mac: universal app, sign, notarize app and DMG,
// every check), Forgejo's ci run of the bump commit (version-only: the installers only, the parent's CI and
// matrix count) and the promotion of the tag (release.yml); this waits for the candidate (heartbeat, timeout)
// (with --sign-local also downloads its Linux / Windows installers while the Mac builds);
// 5. add-remote: the VDS fetches the Linux / Windows files of the release, checks their SHA-256 and signs them with
// its copy of the updater key; only the .sig texts come back and are verified here (--sign-local: the old path,
// the files downloaded to this Mac in step 4 and signed here); the acceptance matrix (TESTS), verify;
// 6. publish: the VDS fetches the Linux / Windows files from Forgejo itself, rsync sends only the Mac's files,
// the VDS attaches them to the Forgejo release from its copy, then storebound.dev's latest.json;
// 7. /Applications/storebound.app: the running app is quit (gracefully, then its exact PID), the new one copied
// from the DMG, reopened in the background;
// 8. prints `npm publish` and `npm deprecate`, and the time of every phase (also in
// ~/.storebound-releases/timings/).
// Every wait has a timeout and a heartbeat; child processes are killed by PID when this exits.
import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { cliKey } from './cli-key';
import { remoteSessionReason } from './desktop-release';
import { COPIES, SEMVER, setVersion } from './desktop-version';
import { candidateTag, deleteTest, listReleases, upsert } from './forgejo-release';
import { fetchCandidate, status, waitGreen } from './release-candidate';
const ROOT = resolve(import.meta.dir, '..');
const HOME = homedir();
const RELEASES = join(HOME, '.storebound-releases');
const KEYS = process.env.STOREBOUND_KEYS_DIR ?? join(HOME, '.storebound-keys');
const ACC_CACHE = join(RELEASES, 'acceptance-cache');
const APP_ID = 'dev.storebound.desktop';
const HEARTBEAT_MS = 30_000;
/** The owner's decision (2026-10-15): no CI and no acceptance runs in a release (no ci:quick, no macOS matrix, no
* matrix report); the build, signing, notarization, privacy scans and verify stay. */
const TESTS = false;
// ---------------------------------------------------------------------------------------------------------------------
// Pure parts (tests/release.test.ts)
/** The Linux / Windows assets of the promoted release that the VDS signs, with the SHA-256 of the candidate's record
* (green.json). An installer without a recorded hash is refused: the VDS checks every byte against it. */
export function vdsSignAssets(assets: { name: string; size: number; browser_download_url: string }[], recorded: Record<string, string>): { name: string; url: string; sha256: string; size: number }[] {
const mine = assets.filter(a => /\.(AppImage|deb|exe)$/.test(a.name));
if (!mine.length) throw new Error('the release has no Linux / Windows installers');
return mine.map(a => {
const sha256 = recorded[a.name];
if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) throw new Error(`${a.name}: no SHA-256 in the candidate's record`);
return { name: a.name, url: a.browser_download_url, sha256, size: a.size };
}).sort((x, y) => x.name.localeCompare(y.name));
}
/** The CHANGELOG with its Unreleased section turned into the version's section and a new empty Unreleased on top. */
export function cutChangelog(text: string, version: string, date: string): string {
const lines = text.split('\n');
const i = lines.findIndex(l => /^## Unreleased\s*$/.test(l));
if (i < 0) throw new Error('CHANGELOG.md has no "## Unreleased" section');
const next = lines.findIndex((l, j) => j > i && l.startsWith('## '));
const body = lines.slice(i + 1, next < 0 ? lines.length : next).join('\n').trim();
if (!body) throw new Error('the Unreleased section of CHANGELOG.md is empty: write the release summary first');
if (lines.some(l => l.startsWith('## ') && l.slice(3).trim().split(/\s/)[0] === version)) throw new Error(`CHANGELOG.md already has a section for ${version}`);
return [...lines.slice(0, i), '## Unreleased', '', `## ${version} · ${date}`, ...lines.slice(i + 1)].join('\n');
}
/** a > b for x.y.z versions (pre-release parts lower than the plain version). */
export function newer(a: string, b: string): boolean {
const p = (v: string) => { const [core, pre] = v.split('-', 2); return { n: core!.split('.').map(Number), pre: pre ?? null }; };
const x = p(a); const y = p(b);
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i]! > y.n[i]!;
if (x.pre === y.pre) return false;
if (x.pre === null) return true;
if (y.pre === null) return false;
return x.pre > y.pre;
}
export interface Phase { name: string; start: number; end?: number }
/** The timing table: each phase's start offset and duration, the critical path is the total. */
export function timingTable(phases: Phase[], t0: number): string {
const f = (ms: number) => { const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
const rows = phases.map(p => ` ${p.name.padEnd(46)} start +${f(p.start - t0).padStart(5)} took ${p.end ? f(p.end - p.start).padStart(5) : ' open'}`);
const end = Math.max(...phases.map(p => p.end ?? p.start));
return [...rows, ` ${'total (tag push to everything published)'.padEnd(46)} ${''.padStart(13)} ${f(end - t0).padStart(10)}`].join('\n');
}
// ---------------------------------------------------------------------------------------------------------------------
const t0 = Date.now();
const phases: Phase[] = [];
const children = new Set<ChildProcess>();
const say = (m: string) => console.log(`[${new Date().toISOString().slice(11, 19)} +${String(Math.round((Date.now() - t0) / 1000)).padStart(4)}s] ${m}`);
function fail(msg: string): never {
console.error(`\nrelease: ${msg}`);
if (phases.length) console.error(`\n${timingTable(phases, t0)}`);
process.exit(1);
}
process.on('exit', () => { for (const c of children) if (c.exitCode === null && c.pid) { try { process.kill(c.pid, 'SIGTERM'); } catch { /* gone */ } } });
process.on('SIGINT', () => fail('interrupted'));
function phase<T>(name: string, fn: () => Promise<T>): Promise<T> {
const p: Phase = { name, start: Date.now() };
phases.push(p);
say(`>> ${name}`);
return fn().then(r => { p.end = Date.now(); say(`<< ${name} (${Math.round((p.end - p.start) / 1000)} s)`); return r; });
}
function sh(cmd: string[], opts: { cwd?: string; allowFail?: boolean; env?: Record<string, string | undefined>; input?: string } = {}): string {
const r = spawnSync(cmd[0]!, cmd.slice(1), { cwd: opts.cwd ?? ROOT, encoding: 'utf8', maxBuffer: 256 << 20, env: (opts.env ?? process.env) as NodeJS.ProcessEnv, input: opts.input });
if (r.status !== 0 && !opts.allowFail) fail(`${cmd.slice(0, 3).join(' ')} failed (exit ${r.status}): ${(r.stderr || r.stdout).trim().split('\n').slice(-8).join('\n')}`);
return r.status === 0 ? r.stdout : '';
}
/** A child process with its output in a log file; resolves with the exit code. Heartbeat: the log's last line. */
function bg(name: string, cmd: string[], log: string, opts: { env?: Record<string, string | undefined>; timeoutMs: number }): Promise<number> {
mkdirSync(resolve(log, '..'), { recursive: true });
const fd = Bun.file(log).writer();
const c = spawn(cmd[0]!, cmd.slice(1), { cwd: ROOT, env: (opts.env ?? process.env) as NodeJS.ProcessEnv, stdio: ['ignore', 'pipe', 'pipe'] });
children.add(c);
let lastLine = '';
const onData = (d: Buffer) => { fd.write(d); const t = d.toString().trim().split('\n').at(-1); if (t) lastLine = t; };
c.stdout!.on('data', onData); c.stderr!.on('data', onData);
const beat = setInterval(() => say(` ${name} (pid ${c.pid}): ${lastLine.slice(0, 150)}`), HEARTBEAT_MS);
const timer = setTimeout(() => { say(`${name}: timeout after ${opts.timeoutMs / 60_000} min, killing pid ${c.pid}`); c.kill('SIGTERM'); }, opts.timeoutMs);
return new Promise(res => c.on('close', code => { clearInterval(beat); clearTimeout(timer); children.delete(c); void fd.end(); res(code ?? 1); }));
}
function publishHost(): string {
const f = join(KEYS, 'publish-host');
const h = process.env.STOREBOUND_PUBLISH_HOST ?? (existsSync(f) ? readFileSync(f, 'utf8').trim() : '');
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(h)) fail(`the publish host (user@host of the VDS) is not set: STOREBOUND_PUBLISH_HOST or ${f.replace(HOME, '~')}`);
return h;
}
/** Node binaries for the macOS acceptance runs: fnm's Node 20 and 22 (installed from nodejs.org once if missing). */
function nodeBin(major: 20 | 22): string {
const find = () => sh(['fnm', 'exec', `--using=${major}`, '--', 'node', '-p', 'process.execPath'], { allowFail: true }).trim();
let p = find();
if (!p) { say(`fnm install ${major} (Node ${major} for the macOS acceptance run)`); sh(['fnm', 'install', String(major)]); p = find(); }
if (!p || !existsSync(p)) fail(`no Node ${major}: fnm install ${major}`);
return p;
}
const RUNTIMES = [
{ file: 'macOS-bun.json', args: ['--runtime', 'bun'] },
{ file: 'macOS-node20.json', args: ['--runtime', 'node', '--node', '@20'] },
{ file: 'macOS-node22.json', args: ['--runtime', 'node', '--node', '@22'] },
];
function cachedMacAcceptance(key: string): string | null {
const dir = join(ACC_CACHE, key);
const ok = RUNTIMES.every(r => {
const f = join(dir, r.file);
if (!existsSync(f)) return false;
const run = JSON.parse(readFileSync(f, 'utf8')) as { cliKey?: string; quick?: boolean; results: { ok: boolean }[] };
return run.cliKey === key && !run.quick && run.results.length > 0 && run.results.every(x => x.ok);
});
return ok ? dir : null;
}
/** The macOS acceptance matrix for this checkout's CLI key: from the cache, else the three runs in parallel from one
* packed tarball (parallel `npm pack` in one checkout would race on dist/). */
async function macAcceptance(key: string, logDir: string): Promise<string> {
const hit = cachedMacAcceptance(key);
if (hit) { say(`macOS acceptance: cached for CLI key ${key.slice(0, 12)} (${hit.replace(HOME, '~')})`); return hit; }
const dir = join(ACC_CACHE, key);
mkdirSync(dir, { recursive: true });
const packDir = mkdtempSync(join(tmpdir(), 'sb-pack-'));
sh(['npm', 'pack', '--silent', '--pack-destination', packDir]);
const tgz = join(packDir, readdirSync(packDir).find(f => f.endsWith('.tgz')) ?? fail('npm pack made no tarball'));
const codes = await Promise.all(RUNTIMES.map(r => {
const args = r.args.map(a => (a === '@20' ? nodeBin(20) : a === '@22' ? nodeBin(22) : a));
return bg(`acceptance ${r.file}`, ['bun', 'tools/acceptance.ts', 'run', ...args, '--tgz', tgz, '--platform', 'macOS', '--jobs', '3', '--out', join(dir, r.file)], join(logDir, `acceptance-${r.file}.log`), { timeoutMs: 30 * 60_000 });
}));
rmSync(packDir, { recursive: true, force: true });
if (codes.some(c => c !== 0)) fail(`macOS acceptance has red cells (logs in ${logDir.replace(HOME, '~')}); nothing is released`);
if (!cachedMacAcceptance(key)) fail('macOS acceptance: results missing after the run');
return dir;
}
async function waitRelease(tag: string, timeoutMin: number): Promise<void> {
const end = Date.now() + timeoutMin * 60_000;
let last = 0;
for (;;) {
const rel = (await listReleases()).find(r => r.tag_name === tag);
if (rel && !rel.draft && rel.assets.some(a => a.name.endsWith('.AppImage')) && rel.assets.some(a => a.name.endsWith('.exe'))) return;
if (Date.now() > end) fail(`timeout after ${timeoutMin} min: release.yml has not released ${tag} (Actions > release on git.crt.fyi)`);
if (Date.now() - last >= HEARTBEAT_MS) { last = Date.now(); say(` waiting for release.yml to release ${tag}${rel ? ` (${rel.assets.length} assets so far)` : ''}`); }
await Bun.sleep(5_000);
}
}
/** Quit the running app (gracefully, then its exact PID), copy the new one from the DMG, reopen it in the
* background. In a rehearsal the copy goes to a scratch folder and nothing is quit or opened. */
function installApp(dmg: string, target: string, rehearsal: boolean): string {
const pids = rehearsal ? [] : sh(['pgrep', '-f', `${target}/Contents/MacOS/storebound-desktop`], { allowFail: true }).trim().split('\n').filter(Boolean).map(Number);
if (pids.length) {
say(`quitting storebound (pid ${pids.join(', ')}) gracefully`);
sh(['osascript', '-e', `tell application id "${APP_ID}" to quit`], { allowFail: true });
const gone = (pid: number) => { try { process.kill(pid, 0); return false; } catch { return true; } };
const until = Date.now() + 15_000;
while (Date.now() < until && !pids.every(gone)) Bun.sleepSync(250);
for (const pid of pids.filter(p => !gone(p))) { say(`pid ${pid} still running: SIGTERM`); try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } }
const until2 = Date.now() + 5_000;
while (Date.now() < until2 && !pids.every(gone)) Bun.sleepSync(250);
for (const pid of pids.filter(p => !gone(p))) { say(`pid ${pid} still running: SIGKILL`); try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
}
const mnt = mkdtempSync(join(tmpdir(), 'sb-inst-'));
sh(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mnt, dmg]);
try {
mkdirSync(resolve(target, '..'), { recursive: true });
const tmp = `${target}.new`;
rmSync(tmp, { recursive: true, force: true });
sh(['ditto', join(mnt, 'storebound.app'), tmp]);
sh(['codesign', '--verify', '--deep', '--strict', tmp]);
rmSync(target, { recursive: true, force: true });
sh(['mv', tmp, target]);
} finally { sh(['hdiutil', 'detach', mnt, '-force'], { allowFail: true }); rmSync(mnt, { recursive: true, force: true }); }
const v = JSON.parse(sh(['plutil', '-convert', 'json', '-o', '-', join(target, 'Contents/Info.plist')])).CFBundleShortVersionString as string;
if (pids.length) { sh(['open', '-g', target]); say(`reopened ${target} in the background`); }
return v;
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const has = (f: string) => argv.includes(f);
const yes = has('--yes');
if (argv[0] === 'npm-token') { npmTokenSetup(); return; }
if (argv[0] === 'npm-publish') { const t = npmToken() ?? fail(`no npm token: bun tools/release.ts npm-token`); await npmRelease(COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!, t); return; }
if (argv[0] === 'mac-acceptance') { const key = cliKey(ROOT); console.log(await macAcceptance(key, join(RELEASES, 'logs', `acceptance-${key}`))); return; }
const rehearsal = has('--rehearsal');
const resume = has('--resume');
const signLocal = has('--sign-local');
if (resume && rehearsal) fail('--resume is for a real version');
const positional = argv.filter(a => !a.startsWith('--'));
const pkgVersion = COPIES[0]!.read(readFileSync(join(ROOT, 'package.json'), 'utf8'))!;
const current = pkgVersion;
const n = rehearsal ? Number(positional[0] ?? Math.floor(Date.now() / 1000)) : 0;
const version = rehearsal ? `0.0.0-rehearsal.${n}` : positional[0] ?? fail('usage: bun tools/release.ts <version> [--yes] | --rehearsal [<n>] [--yes]');
if (!SEMVER.test(version)) fail(`${version} is not a version`);
if (!rehearsal && !resume && !newer(version, current)) fail(`${version} is not newer than ${current}`);
const tag = `v${version}`;
const branch = rehearsal ? `rehearsal/${n}` : 'main';
const out = join(RELEASES, 'desktop', version);
const logDir = join(RELEASES, 'logs', version);
const host = publishHost();
// one release at a time on this Mac: two runs once built into the same target folder and the signing failed
if (yes) {
const lock = join(RELEASES, 'release.lock');
const held = existsSync(lock) ? Number(readFileSync(lock, 'utf8').trim()) : 0;
const alive = held > 0 && (() => { try { process.kill(held, 0); return true; } catch { return false; } })();
if (alive && held !== process.pid) fail(`another release runs (pid ${held}, ${lock.replace(HOME, '~')}): wait for it, or end it first`);
mkdirSync(RELEASES, { recursive: true });
writeFileSync(lock, `${process.pid}\n`);
process.on('exit', () => { try { if (readFileSync(lock, 'utf8').trim() === String(process.pid)) rmSync(lock, { force: true }); } catch { /* gone */ } });
}
// ---------------------------------------------------------------- 1. preflight
const remote = remoteSessionReason(process.env, sh(['launchctl', 'managername'], { allowFail: true }).trim());
if (remote) fail(`${remote}: signing and notarization need the owner's logged-in macOS session`);
if (!existsSync(join(KEYS, 'updater.key'))) fail(`no updater key in ${KEYS.replace(HOME, '~')}`);
// --resume may run with a fixed release tool (tools/release*.ts, tools/desktop-release.ts), which is not part of what ships
const dirty = sh(['git', 'status', '--porcelain']).split('\n').filter(l => l.trim() && !(resume && /^ M tools\/(desktop-)?release(-candidate)?\.ts$/.test(l)));
if (dirty.length) fail('the checkout has uncommitted changes');
sh(['git', 'fetch', '--quiet', 'origin', 'main', '--tags']);
const head = sh(['git', 'rev-parse', 'HEAD']).trim();
if (head !== sh(['git', 'rev-parse', 'origin/main']).trim()) fail(`HEAD ${head.slice(0, 10)} is not origin/main: release from main as it is on git.crt.fyi (pull / push first), so the bump's parent is a candidate`);
const tagged = sh(['git', 'rev-parse', '-q', '--verify', `refs/tags/${tag}^{commit}`], { allowFail: true }).trim();
if (resume) {
if (tagged !== head || pkgVersion !== version) fail(`--resume needs HEAD = origin/main tagged ${tag} with version ${version} (HEAD ${head.slice(0, 10)}, ${tag} ${tagged.slice(0, 10) || 'missing'}, package.json ${pkgVersion})`);
} else if (!rehearsal && tagged) fail(`tag ${tag} exists already (--resume continues a release that stopped after its push)`);
const parent = await status(resume ? sh(['git', 'rev-parse', 'HEAD~1']).trim() : head);
if (parent.state === 'red' && !resume) fail(`origin/main ${head.slice(0, 10)} is red (${parent.failed.join(', ')}): ${parent.run}`);
if (parent.state === 'none' && !resume) fail(`origin/main ${head.slice(0, 10)} has no ci run: its candidate is needed (push to main runs ci.yml)`);
say(`storebound ${current} -> ${version}${rehearsal ? ' (rehearsal: prerelease, rehearsal channel, deleted at the end)' : ''}; parent ${head.slice(0, 10)} is ${parent.state}${parent.state === 'pending' ? ` (${parent.run})` : ''}`);
// the release commit's author is the one of the commits already on main (the project's identity)
const identity = sh(['git', 'log', '-1', '--format=%an <%ae>', 'origin/main']).trim();
const key = cliKey(ROOT);
const accCached = cachedMacAcceptance(key);
console.log(`plan:
bump ${current} -> ${version} (package.json + ${COPIES.length - 1} copies)${rehearsal ? '' : ', CHANGELOG Unreleased -> ' + version}; bun run ci:quick; commit as ${identity}
push ${branch}, push ${tag}; then in parallel: Mac build + notarize -> ${out.replace(HOME, '~')}; macOS acceptance (${accCached ? 'cached' : 'runs now'}, key ${key.slice(0, 12)});
Forgejo ci (candidate of the bump commit) + release.yml (promotion); add, verify, publish to ${rehearsal ? 'the rehearsal channel' : 'stable'} via ${host};
${rehearsal ? 'app copied to a scratch folder; then the tag, release, candidates, branch and storebound.dev files of the rehearsal are deleted' : 'update /Applications/storebound.app; print npm publish + npm deprecate'}`);
// background tests never gate a release; a red result is printed so the next patch fixes it
try {
const { mainTests } = await import('./ci-status');
const t = await mainTests();
if (t.state === 'red') say(`WARNING: background tests are red on ${t.sha.slice(0, 10)} (${t.failed.join(', ')}): ${t.run}; releasing anyway, fix it in the next patch`);
} catch (e) { say(`background tests: not read (${(e as Error).message.slice(0, 120)})`); }
// the desktop's own typecheck (stricter: unused locals) runs in tauri's beforeBuildCommand: catch it before the tag
// (0.8.14's Mac build stopped on an unused variable after the tag was pushed)
{ const t = spawnSync('bunx', ['tsc', '--noEmit', '-p', 'apps/desktop'], { cwd: ROOT, encoding: 'utf8', timeout: 180_000 });
if (t.status !== 0) fail(`the desktop typecheck fails (the Mac build would stop on it):\n${(t.stdout + t.stderr).trim().split('\n').slice(0, 8).join('\n')}`); }
// the dry run catches what the bump would refuse
if (!rehearsal && !resume) { try { cutChangelog(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, '2000-01-01'); } catch (e) { fail((e as Error).message); } }
if (!yes) { console.log('\ndry run: nothing changed (--yes to release)'); return; }
const accP = TESTS ? phase('macOS acceptance (cache or 3 runs)', () => macAcceptance(key, logDir)) : Promise.resolve('');
accP.catch(() => undefined);
// ---------------------------------------------------------------- 2. + 3. bump, gate, commit, push
const startBranch = sh(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
let sha = resume ? head : '';
if (!resume) await phase('bump + fast gate + commit + push', async () => {
if (rehearsal) sh(['git', 'checkout', '-q', '-b', branch]);
// the CHANGELOG cut is checked before any file changes (an empty Unreleased once left every version copy bumped)
const cut = rehearsal ? null : cutChangelog(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, new Date().toISOString().slice(0, 10));
setVersion(version);
if (cut) writeFileSync(join(ROOT, 'CHANGELOG.md'), cut);
const code = TESTS ? await bg('ci:quick', ['bun', 'run', 'ci:quick'], join(logDir, 'ci-quick.log'), { timeoutMs: 15 * 60_000 }) : 0;
if (code !== 0) {
// nothing was committed or pushed: put the checkout back as it was
sh(['git', 'checkout', '-q', '--', '.']);
if (rehearsal) { sh(['git', 'checkout', '-q', startBranch === 'HEAD' ? head : startBranch]); sh(['git', 'branch', '-q', '-D', branch], { allowFail: true }); }
fail(`bun run ci:quick failed (${join(logDir, 'ci-quick.log').replace(HOME, '~')}); nothing committed, the checkout is back as it was`);
}
const me = `${sh(['git', 'config', 'user.name']).trim()} <${sh(['git', 'config', 'user.email']).trim()}>`;
if (me !== identity) fail(`git identity is ${me}; the commits on main are by ${identity} (git config user.name / user.email)`);
sh(['git', 'add', '-A']);
sh(['git', 'commit', '-q', '-m', rehearsal ? `release rehearsal ${version} (deleted after the run)` : `storebound ${version}: version in package.json and every copy, CHANGELOG ${version} section`]);
sha = sh(['git', 'rev-parse', 'HEAD']).trim();
sh(['git', 'push', '-q', 'origin', `HEAD:refs/heads/${branch}`]);
sh(['git', 'tag', tag]);
sh(['git', 'push', '-q', 'origin', `refs/tags/${tag}`]);
say(`pushed ${sha.slice(0, 10)} to ${branch} and ${tag}`);
});
const tPush = Date.now();
phases.push({ name: '== tag pushed: clock for "tag -> published" ==', start: tPush, end: tPush });
// ---------------------------------------------------------------- 4. in parallel
rmSync(out, { recursive: true, force: true });
const macP = phase('Mac build + sign + notarize (app, DMG)', async () => {
const code = await bg('mac build', ['bun', 'tools/desktop-release.ts', 'mac', '--out', out], join(logDir, 'mac.log'), { timeoutMs: 60 * 60_000 });
if (code !== 0) fail(`the Mac build failed: ${join(logDir, 'mac.log').replace(HOME, '~')}`);
});
const dl = join(RELEASES, 'candidates', sha);
const candP = phase('Forgejo: candidate of the bump commit (installers)', async () => {
const s = await waitGreen(sha, 45);
return s;
});
// the Linux / Windows installers stay off this Mac: the VDS fetches and signs them (owner, 21 Oct 2026); only
// --sign-local (the fallback) downloads them here to sign with ~/.storebound-keys
const fetchP = signLocal ? candP.then(s => phase('download Linux / Windows installers + acceptance runs', () => fetchCandidate(s, dl)))
: TESTS ? candP.then(s => phase('download acceptance runs', () => fetchCandidate(s, dl, /^acceptance-.*\.json$/))) : Promise.resolve([]);
const relP = candP.then(() => phase('Forgejo: release.yml promotes the tag', () => waitRelease(tag, 15)));
await Promise.all([macP, fetchP, relP, accP]);
const cand = await candP;
// ---------------------------------------------------------------- 5. add, acceptance matrix, verify
await phase('add Linux / Windows + acceptance matrix + verify', async () => {
if (signLocal) {
const files = readdirSync(dl).filter(f => /\.(AppImage|deb|exe)$/.test(f)).map(f => join(dl, f));
sh(['bun', 'tools/desktop-release.ts', 'add', '--out', out, '--commit', sha, '--run', cand.record.run, ...files]);
} else {
const rel = (await listReleases()).find(r => r.tag_name === tag) ?? fail(`no Forgejo release ${tag}`);
const plan = vdsSignAssets(rel.assets, cand.record.files);
const assetsFile = join(logDir, 'vds-sign-assets.json');
mkdirSync(logDir, { recursive: true });
writeFileSync(assetsFile, `${JSON.stringify(plan, null, 2)}\n`);
const code = await bg('VDS sign', ['bun', 'tools/desktop-release.ts', 'add-remote', '--out', out, '--host', host, '--assets', assetsFile, '--commit', sha, '--run', cand.record.run], join(logDir, 'add-remote.log'), { timeoutMs: 35 * 60_000 });
if (code !== 0) fail(`VDS signing failed: ${join(logDir, 'add-remote.log').replace(HOME, '~')} (fallback: --resume --sign-local)`);
}
if (TESTS) {
const accDir = mkdtempSync(join(tmpdir(), 'sb-acc-'));
for (const f of readdirSync(dl).filter(f => /^acceptance-.*\.json$/.test(f))) writeFileSync(join(accDir, f), readFileSync(join(dl, f)));
const macDir = await accP;
for (const r of RUNTIMES) writeFileSync(join(accDir, r.file), readFileSync(join(macDir, r.file)));
const md = join(logDir, `storebound_${version}_acceptance.md`);
sh(['bun', 'tools/acceptance.ts', 'report', accDir, '--md', md, '--expect', '9', '--strict']);
rmSync(accDir, { recursive: true, force: true });
await upsert({ tag, files: [md], create: false, yes: true });
}
sh(['bun', 'tools/desktop-release.ts', 'verify', '--out', out]);
});
// ---------------------------------------------------------------- 6. publish
await phase('publish (VDS seeds from Forgejo, Mac files once, latest.json)', async () => {
// a resume after a publish that went out (only its check failed, e.g. the site was down) must not publish again:
// signatures are not byte-identical on a second run and the server never replaces a published file
if (!rehearsal && resume) {
const live = await fetch(`https://storebound.dev/desktop/latest.json?t=${Date.now()}`, { signal: AbortSignal.timeout(20_000) })
.then(r => (r.ok ? r.json() as Promise<{ version?: string }> : null)).catch(() => null);
if (live?.version === version) { say(`desktop ${version} is already live on the stable channel: publish skipped`); return; }
}
const code = await bg('publish', ['bun', 'tools/desktop-release.ts', 'publish', '--out', out, '--host', host, '--channel', rehearsal ? 'rehearsal' : 'stable', '--forgejo-tag', tag, '--yes'], join(logDir, 'publish.log'), { timeoutMs: 45 * 60_000 });
if (code !== 0) fail(`publish failed: ${join(logDir, 'publish.log').replace(HOME, '~')}`);
});
const tPublished = Date.now();
phases.push({ name: '== published ==', start: tPublished, end: tPublished });
// ---------------------------------------------------------------- 7. the app on this Mac
const dmg = readdirSync(out).find(f => f.endsWith('.dmg')) ?? fail('no DMG');
const target = rehearsal ? join(RELEASES, 'rehearsal-install', 'storebound.app') : '/Applications/storebound.app';
const installed = await phase(`install ${target.replace(HOME, '~')}`, async () => installApp(join(out, dmg), target, rehearsal));
say(`installed storebound ${installed} at ${target.replace(HOME, '~')}`);
// ---------------------------------------------------------------- 8. the end
if (rehearsal) {
await phase('rehearsal cleanup', async () => {
await deleteTest(tag, true);
for (const s of [sha]) await deleteTest(candidateTag(s), true);
sh(['git', 'push', '-q', 'origin', '--delete', branch], { allowFail: true });
sh(['git', 'checkout', '-q', startBranch === 'HEAD' ? head : startBranch]);
sh(['git', 'branch', '-q', '-D', branch], { allowFail: true });
sh(['git', 'tag', '-d', tag], { allowFail: true });
if (!/^0\.0\.0-rehearsal\.\d+$/.test(version)) fail('cleanup: not a rehearsal version');
sh(['ssh', '-o', 'BatchMode=yes', host, `rm -rf /var/www/storebound-desktop/dl/${version} /var/www/storebound-desktop/rehearsal/latest.json /var/lib/storebound-desktop/incoming/${version}`]);
rmSync(out, { recursive: true, force: true });
rmSync(dl, { recursive: true, force: true });
rmSync(resolve(target, '..'), { recursive: true, force: true });
});
}
const table = timingTable(phases, tPush);
mkdirSync(join(RELEASES, 'timings'), { recursive: true });
const tf = join(RELEASES, 'timings', `${version}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
writeFileSync(tf, `${JSON.stringify({ version, sha, tPush, tPublished, phases }, null, 2)}\n`);
console.log(`\nstorebound ${version}: ${rehearsal ? 'rehearsal done and cleaned up' : 'released'}. Times (from the tag push):\n${table}\n start of this command to tag push: ${Math.round((tPush - t0) / 1000)} s; timings in ${tf.replace(HOME, '~')}`);
if (!rehearsal) {
const npm = npmToken();
if (npm) await phase('npm publish + deprecate (token)', async () => npmRelease(version, npm));
else console.log(`\nnpm (no token in ${NPM_TOKEN_FILE.replace(HOME, '~')}: \`bun tools/release.ts npm-token\` sets it up once), from this checkout at ${tag}:
npm publish
npm deprecate storebound@"<${version}" "storebound ${version} is out: npx storebound@${version}"`);
}
}
// ---------------------------------------------------------------------------------------------------------------------
// npm with a granular access token (the owner's decision 2026-10-15: releases publish to npm by themselves). The token
// lives only in ~/.storebound-keys/npm-token (0600); npm reads it from the environment through a temporary userconfig
// that names ${NPM_TOKEN}, so it is never written into the checkout, a log or argv.
const NPM_TOKEN_FILE = join(KEYS, 'npm-token');
function npmToken(): string | null {
const v = process.env.NPM_TOKEN ?? (existsSync(NPM_TOKEN_FILE) ? readFileSync(NPM_TOKEN_FILE, 'utf8') : '');
const t = v.trim();
return /^npm_[A-Za-z0-9]{20,}$/.test(t) ? t : null;
}
function npmRun(args: string[], token: string): { code: number; out: string } {
const dir = mkdtempSync(join(tmpdir(), 'sb-npm-'));
try {
const rc = join(dir, 'npmrc');
writeFileSync(rc, '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n', { mode: 0o600 });
const r = spawnSync('npm', args, { cwd: ROOT, encoding: 'utf8', timeout: 10 * 60_000, env: { ...process.env, NPM_TOKEN: token, NPM_CONFIG_USERCONFIG: rc } });
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`.split(token).join('npm_***');
return { code: r.status ?? 1, out };
} finally { rmSync(dir, { recursive: true, force: true }); }
}
async function npmRelease(version: string, token: string): Promise<void> {
const who = npmRun(['whoami'], token);
if (who.code !== 0) fail(`npm token refused (npm whoami): ${who.out.trim().split('\n').slice(-2).join(' ')}; make a new one: bun tools/release.ts npm-token`);
const pub = npmRun(['publish'], token);
// npm stages a token publish for the maintainer's approval on npmjs.com (409 "previously staged version" on a retry):
// nothing to wait for here, the owner approves it once
if (/staged/i.test(pub.out)) { say(`npm: ${version} is staged: npm published 0.8.25 and 0.8.26 on its own about 10 minutes later with nothing waiting at https://www.npmjs.com/settings/<user>/staged-packages; check "npm view storebound dist-tags.latest" before approving anything, then run bun tools/release.ts npm-publish for the deprecate`); return; }
if (pub.code !== 0 && !/previously published|cannot publish over/i.test(pub.out)) fail(`npm publish failed: ${pub.out.trim().split('\n').slice(-4).join(' ')}`);
// the registry lags a little after a publish; wait (timeout, heartbeat) until it serves the version as latest
const end = Date.now() + 5 * 60_000;
let last = 0;
let again = false;
for (;;) {
// a publish that answered 0 once left no version on the registry (0.8.10): publish once more when the version is
// still missing after 2 minutes (a second publish of a version that landed answers "cannot publish over")
if (!again && Date.now() > end - 3 * 60_000) {
const v = spawnSync('npm', ['view', `storebound@${version}`, 'version'], { encoding: 'utf8', timeout: 30_000 });
if (!(v.stdout ?? '').trim()) {
again = true;
say(`npm: ${version} is not on the registry yet: publishing once more`);
const p2 = npmRun(['publish'], token);
// 0.8.25: the first publish was staged silently; the retry's 409 "previously staged" says so (no 5-minute wait)
if (/staged/i.test(p2.out)) { say(`npm: ${version} is staged: npm published 0.8.25 and 0.8.26 on its own about 10 minutes later with nothing waiting at https://www.npmjs.com/settings/<user>/staged-packages; check "npm view storebound dist-tags.latest" before approving anything, then run bun tools/release.ts npm-publish for the deprecate`); return; }
if (p2.code !== 0 && !/previously published|cannot publish over/i.test(p2.out)) fail(`npm publish (second try) failed: ${p2.out.trim().split('\n').slice(-4).join(' ')}`);
}
}
const r = spawnSync('npm', ['view', 'storebound', 'dist-tags.latest'], { encoding: 'utf8', timeout: 30_000 });
if ((r.stdout ?? '').trim() === version) break;
if (Date.now() > end) fail(`npm: ${version} published but the registry still serves ${(r.stdout ?? '').trim()} as latest after 5 min`);
if (Date.now() - last > HEARTBEAT_MS) { last = Date.now(); say(`npm: waiting for the registry to serve ${version}`); }
await new Promise(res => setTimeout(res, 5_000));
}
const dep = npmRun(['deprecate', `storebound@<${version}`, `storebound ${version} is out: npx storebound@${version}`], token);
// a deprecation that is already set (an earlier npm-publish run) answers 422: fine when the message is the same
if (dep.code !== 0 && /E422|Unprocessable/i.test(dep.out) && (spawnSync('npm', ['view', `storebound@<${version}`, 'deprecated'], { encoding: 'utf8', timeout: 30_000 }).stdout ?? '').includes(`storebound ${version} is out`)) say('npm: older versions were already deprecated');
else if (dep.code !== 0) fail(`npm deprecate failed: ${dep.out.trim().split('\n').slice(-3).join(' ')}`);
say(`npm: storebound@${version} published and older versions deprecated`);
}
/** One-time setup: the token from the clipboard (macOS pbpaste) into ~/.storebound-keys/npm-token, never printed. */
function npmTokenSetup(): void {
const r = spawnSync('pbpaste', [], { encoding: 'utf8' });
const t = (r.stdout ?? '').trim();
if (!/^npm_[A-Za-z0-9]{20,}$/.test(t)) fail('the clipboard holds no npm token (npm_...): copy the token on npmjs.com first, then run this again');
mkdirSync(KEYS, { recursive: true });
writeFileSync(NPM_TOKEN_FILE, `${t}\n`, { mode: 0o600 });
spawnSync('pbcopy', [], { input: '' });
const who = npmRun(['whoami'], t);
if (who.code !== 0) fail(`saved, but npm refuses it: ${who.out.trim().split('\n').slice(-2).join(' ')}`);
console.log(`npm token saved to ${NPM_TOKEN_FILE.replace(HOME, '~')} (0600; clipboard cleared); npm account: ${who.out.trim()}`);
}
if (import.meta.main) await main();