#!/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 [--yes] e.g. bun tools/release.ts 0.8.5 --yes // bun tools/release.ts --rehearsal [] [--yes] the same end to end as version 0.0.0-rehearsal. on the branch // rehearsal/: 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 --resume --yes after a stop past the push: HEAD is origin/main, tagged v, // 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//; a version bump keeps the key); // 2. the version in package.json and every copy, the CHANGELOG's Unreleased section cut as "## · "; // 3. the fast gate (bun run ci:quick), the commit (as CRT), push to main, push the tag v; // 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) // and downloads its Linux / Windows installers while the Mac builds; // 5. add (updater signatures of the Linux / Windows files), the full acceptance matrix (9 runs), 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 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(); 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(name: string, fn: () => Promise): Promise { 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; 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; timeoutMs: number }): Promise { 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 { 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 { 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 { 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'); 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 [--yes] | --rehearsal [] [--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), which is not part of what ships const dirty = sh(['git', 'status', '--porcelain']).split('\n').filter(l => l.trim() && !(resume && /^ M tools\/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 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; }); const fetchP = candP.then(s => phase('download Linux / Windows installers + acceptance runs', () => fetchCandidate(s, dl))); 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 () => { 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]); 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 () => { 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 { 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); 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); 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); 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();