storebound/tools/desktop-release.ts

1004 lines
71 KiB
TypeScript

#!/usr/bin/env bun
// Release pipeline of the storebound desktop app. Builds, signs, checks and lays out one version for
// storebound.dev/desktop/; nothing here installs anything, and publishing is a separate, explicit step.
//
// bun tools/desktop-release.ts mac [--out <dir>] [--target-dir <dir>] [--identity "<Developer ID Application: ...>"]
// [--no-notarize] [--notes "..."] [--allow-dirty]
// Universal (Apple silicon + Intel) app, updater bundle (.app.tar.gz) and DMG. With a "Developer ID Application"
// identity in the keychain: hardened runtime, notarization (notarytool, App Store Connect API key) and stapling.
// Without one: an ad-hoc signed build named `-unsigned`, and what the owner has to do. Must run in the owner's
// local GUI session: codesign over SSH fails (the keychain is locked there), so SSH sessions are refused.
// The built window is booted headless first (tools/desktop-boot-check.ts). Then, on the exact artifacts: codesign / Gatekeeper expectations, lipo (every executable universal; the x86_64 slice is
// never run, Rosetta), the native self-test (sidecar handshake) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with
// the updater key (~/.storebound-keys/updater.key), SHA256SUMS, latest.json and evidence.json.
// bun tools/desktop-release.ts add --out <dir> [--commit <sha>] [--run <url>] <files...> [--code-signed]
// Adds Windows / Linux installers built by the Forgejo workflow (.forgejo/workflows/release.yml) to the version
// folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS and
// evidence-<linux|windows>-<version>.json (the run and commit they came from; a commit other than the one of the
// folder's other evidence is refused: every artifact of a version comes from one commit).
// bun tools/desktop-release.ts add-remote --out <dir> --host <user@host> --assets <json> [--commit <sha>] [--run <url>] [--code-signed]
// The default since 21 Oct 2026: the VDS fetches the Linux / Windows files of the Forgejo release into its incoming
// folder, checks their SHA-256, signs them with its copy of the updater key (tools/vds-sign.ts); only the .sig
// texts come back and are verified here. The files stay on the VDS (.remote.json); publish checks them there.
// bun tools/desktop-release.ts vds-key [--host root@<host>]
// Installs or rotates the updater key in /root/.storebound-keys on the VDS and verifies a test signature here.
// bun tools/desktop-release.ts publish --out <dir> --host <user@host> [--channel stable|beta|rehearsal]
// [--forgejo-tag v<version>] [--yes]
// Checks the folder again, uploads it to the server's incoming folder (rsync) and runs the root-owned publish
// script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run). --forgejo-tag: the
// server fetches the files the Forgejo release already has, and attaches the Mac's DMG and updater bundle to it
// from its copy (one upload over the owner's line instead of three; tools/release.ts uses this).
// The folder's version must be package.json's: the npm CLI and the app are released together, the app first,
// then `npm publish` of the same version.
// bun tools/desktop-release.ts upload-forgejo --out <dir> [--tag v<version>] [--yes]
// After `mac` (signed, notarized): attaches the DMG and the updater bundle with their minisign signatures to the
// Forgejo release of the tag that the release workflow made (.forgejo/workflows/release.yml); SHA256SUMS is merged.
// Without --yes only the plan. Token: FORGEJO_TOKEN or the git credential helper (tools/forgejo-release.ts).
// bun tools/desktop-release.ts verify --out <dir>
// The checks of publish without uploading.
// bun tools/desktop-release.ts exec -- <command...>
// Runs a build command (tauri build on a runner) with the path-free environment described at pathFreeEnv.
//
// Version: package.json "version", the same as the npm CLI (bun tools/desktop-version.ts); both ship together. Docs: docs/desktop-release.md.
import { spawnSync, type SpawnSyncOptions } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { homedir, release as osRelease, tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { type Download, type DownloadKey, type Manifest, manifestProblems } from '../cli/lib/desktop-dist';
import { verify as verifySig } from '../cli/lib/minisign';
import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
import { versions } from './desktop-version';
import { listReleases, upsert as forgejoUpsert } from './forgejo-release';
import { isMacArtifact, scanSignedMac } from './mac-signature-scan';
import { parseSigLines, REMOTE_KEY, REMOTE_KEY_PASSWORD, REMOTE_KEYS, type RemoteItem, remoteSigProblems, remoteSignScript, signCommand, sshScript, verifyDigest } from './vds-sign';
import { allTerms, DEFAULT_TERMS, type Report, scan, TERMS_ENV } from './privacy-scan';
export const ROOT = resolve(import.meta.dir, '..');
const APP_DIR = join(ROOT, 'apps/desktop');
const TAURI_DIR = join(APP_DIR, 'src-tauri');
export const BASE_URL = 'https://storebound.dev/desktop';
export const KEYS_DIR = process.env.STOREBOUND_KEYS_DIR ?? join(homedir(), '.storebound-keys');
const PRODUCT = 'storebound';
const APP_NAME = 'storebound.app';
const MAIN_BIN = 'storebound-desktop';
/** Where the server takes uploads and the root-owned script that publishes them (tools/vds/). */
export const REMOTE_INCOMING = '/var/lib/storebound-desktop/incoming';
export const REMOTE_PUBLISH = '/usr/local/sbin/storebound-desktop-publish';
// ---------------------------------------------------------------------------------------------------------------------
// Pure parts (tests/desktop-release.test.ts)
/** Why this session cannot sign (null = a local session). codesign needs the unlocked login keychain of the GUI
* session; over SSH it stops at errSecInternalComponent. */
export function remoteSessionReason(env: Record<string, string | undefined>, managerName: string): string | null {
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'this is an SSH session';
if (managerName && managerName !== 'Aqua') return `this is not the logged-in GUI session (launchctl manager: ${managerName})`;
return null;
}
/** The first "Developer ID Application" identity of `security find-identity -v -p codesigning`. */
export function developerIdIdentity(securityOutput: string): string | null {
const m = /^\s*\d+\)\s+[0-9A-F]{40}\s+"(Developer ID Application: [^"]+)"/m.exec(securityOutput);
return m?.[1] ?? null;
}
/** File names of a version. Unsigned (no Developer ID / Authenticode) builds say so in the name. */
export function artifactName(version: string, kind: 'dmg' | 'updater' | 'nsis' | 'appimage' | 'deb', codeSigned: boolean): string {
const u = codeSigned ? '' : '-unsigned';
switch (kind) {
case 'dmg': return `${PRODUCT}_${version}_universal${u}.dmg`;
case 'updater': return `${PRODUCT}_${version}_universal${u}.app.tar.gz`;
case 'nsis': return `${PRODUCT}_${version}_x64-setup${u}.exe`;
case 'appimage': return `${PRODUCT}_${version}_amd64.AppImage`;
case 'deb': return `${PRODUCT}_${version}_amd64.deb`;
}
}
export const releaseUrl = (version: string, file: string): string => `${BASE_URL}/dl/${version}/${file}`;
/**
* Environment of a build whose binaries hold no build-machine path: rustc's --remap-path-prefix (and
* -ffile-prefix-map for C compiled by build scripts) turn the home folder, the checkout, CARGO_HOME, RUSTUP_HOME and
* CARGO_TARGET_DIR into ~, /src, /cargo, /rustup and /target. General roots first: rustc applies the last match.
*/
export function pathFreeEnv(root: string, env: Record<string, string | undefined>, home = homedir(), platform: NodeJS.Platform = process.platform): Record<string, string> {
const win = platform === 'win32';
const sep = win ? '\\' : '/';
const trim = (p: string) => (win ? p.replace(/[\\/]+$/, '') : p.replace(/\/+$/, '') || '/');
const at = (...parts: string[]) => parts.join(sep);
const roots: [string | undefined, string][] = [
[home, '~'],
[root, '/src'],
[env.CARGO_TARGET_DIR && (win ? env.CARGO_TARGET_DIR : resolve(env.CARGO_TARGET_DIR)), '/target'],
[env.CARGO_HOME ?? at(home, '.cargo'), '/cargo'],
[env.RUSTUP_HOME ?? at(home, '.rustup'), '/rustup'],
];
const maps: [string, string][] = [];
for (const [f, t] of roots) {
if (!f) continue;
maps.push([trim(f), t]);
// Windows hands some paths out in verbatim form (canonicalized by build scripts and cargo)
if (win && /^[A-Za-z]:/.test(f)) maps.push([`\\\\?\\${trim(f)}`, t]);
}
const SEP = '\x1f';
const flags = env.CARGO_ENCODED_RUSTFLAGS ? env.CARGO_ENCODED_RUSTFLAGS.split(SEP) : (env.RUSTFLAGS ?? '').split(/\s+/).filter(Boolean);
const add = (fl: string) => { if (!flags.includes(fl)) flags.push(fl); };
for (const [f, t] of maps) add(`--remap-path-prefix=${f}=${t}`);
const out: Record<string, string> = {};
if (win) {
// MSVC: the PDB is named without its folder, and cl.exe cuts the roots off __FILE__ (longest root first)
add('-Clink-arg=/PDBALTPATH:%_PDB%');
const trims = roots.map(r => r[0]).filter((f): f is string => !!f && !f.includes('"')).map(trim).sort((a, b) => b.length - a.length).map(f => `"/d1trimfile:${f}/"`);
out.CL = [...new Set([env.CL, ...trims].filter(Boolean))].join(' ');
out.LINK = [...new Set([env.LINK, '/PDBALTPATH:%_PDB%'].filter(Boolean))].join(' ');
} else {
const prefix = maps.filter(([f]) => !/\s/.test(f)).map(([f, t]) => `-ffile-prefix-map=${f}=${t}`);
for (const n of ['CFLAGS', 'CXXFLAGS']) out[n] = [...(env[n] ?? '').split(/\s+/).filter(Boolean), ...prefix].join(' ');
}
out.CARGO_ENCODED_RUSTFLAGS = flags.join(SEP);
return out;
}
/** latest.json of a version: an existing one of the same version keeps its other platforms. */
export function mergeManifest(existing: Manifest | null, version: string, notes: string, now: string,
add: { platforms?: Manifest['platforms']; downloads?: Partial<Record<DownloadKey, Download>> }): Manifest {
const base: Manifest = existing && existing.version === version ? existing : { version, notes, pub_date: now, platforms: {}, downloads: {} };
return {
version,
notes: base.notes || notes,
pub_date: now,
platforms: { ...base.platforms, ...add.platforms },
downloads: { ...base.downloads, ...add.downloads },
};
}
/** `sha256sum -c` format, sorted. */
export function sumsText(entries: { file: string; sha256: string }[]): string {
return [...entries].sort((a, b) => a.file.localeCompare(b.file)).map(e => `${e.sha256} ${e.file}`).join('\n') + '\n';
}
/** Files of a version folder that are uploaded (evidence and temp files stay local). */
export const uploadable = (name: string): boolean => /^[A-Za-z0-9._-]+$/.test(name) && !name.startsWith('.') && !/^evidence/.test(name) && !name.endsWith('.part');
// ---------------------------------------------------------------------------------------------------------------------
// Helpers with side effects
const t0 = Date.now();
const say = (msg: string) => console.log(`[${((Date.now() - t0) / 1000).toFixed(0).padStart(4)}s] ${msg}`);
function fail(msg: string): never { console.error(`\ndesktop-release: ${msg}`); process.exit(1); }
function run(cmd: string[], opts: SpawnSyncOptions & { quiet?: boolean; allowFail?: boolean } = {}): { code: number; out: string; err: string } {
const r = spawnSync(cmd[0]!, cmd.slice(1), { encoding: 'utf8', maxBuffer: 256 << 20, ...opts, stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'] });
const res = { code: r.status ?? 1, out: String(r.stdout ?? ''), err: String(r.stderr ?? '') };
if (res.code !== 0 && !opts.allowFail) fail(`${basename(cmd[0]!)} ${cmd.slice(1, 3).join(' ')} failed (exit ${res.code})\n${res.err.trim().split('\n').slice(-15).join('\n')}`);
return res;
}
/** A command in the background (notarytool waiting for Apple, the boot check) while this process checks other
* artifacts. Output is collected; the caller decides what a failure means. */
/** notarytool submit --wait with up to 3 tries when the upload is cut off before Apple has it (abortedUpload, a reset
* connection); the later tries without S3 transfer acceleration. Returns notarytool's status or the last error. */
async function notarizeWithRetry(file: string, n: Record<string, string | undefined>, what: string): Promise<string> {
let last = '';
for (let tries = 1; tries <= 3; tries++) {
const args = ['xcrun', 'notarytool', 'submit', file, '--key', n.APPLE_API_KEY_PATH!, '--key-id', n.APPLE_API_KEY!, '--issuer', n.APPLE_API_ISSUER!, ...(tries > 1 ? ['--no-s3-acceleration'] : []), '--wait', '--output-format', 'json'];
const r = await runAsync(args, { timeoutMs: 3_600_000 });
const status = (() => { try { return (JSON.parse(r.out) as { status?: string }).status; } catch { return undefined; } })();
if (status) return status;
last = (r.err.trim() || r.out.trim() || `notarytool exited ${r.code}`).slice(0, 300);
if (!/abortedUpload|connection (was )?reset|timed out|network connection was lost|NSURLErrorDomain/i.test(last)) return last;
say(`notarytool (${what}): the upload was cut off before Apple had it (try ${tries}); ${tries < 3 ? 'again without S3 acceleration' : 'giving up'}`);
}
return last;
}
function runAsync(cmd: string[], opts: { cwd?: string; env?: Record<string, string | undefined>; timeoutMs?: number; inherit?: boolean } = {}): Promise<{ code: number; out: string; err: string; ms: number }> {
const start = Date.now();
const p = Bun.spawn(cmd, { cwd: opts.cwd, env: (opts.env ?? process.env) as Record<string, string>, stdin: 'ignore', stdout: opts.inherit ? 'inherit' : 'pipe', stderr: opts.inherit ? 'inherit' : 'pipe' });
const timer = opts.timeoutMs ? setTimeout(() => p.kill(), opts.timeoutMs) : null;
const text = (s: ReadableStream<Uint8Array> | number | undefined | null) => (s && typeof s !== 'number' ? new Response(s).text() : Promise.resolve(''));
return Promise.all([text(p.stdout as ReadableStream<Uint8Array>), text(p.stderr as ReadableStream<Uint8Array>), p.exited]).then(([out, err, code]) => {
if (timer) clearTimeout(timer);
return { code, out, err, ms: Date.now() - start };
});
}
const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
const mb = (n: number) => `${(n / 1e6).toFixed(1)} MB`;
const confPubkey = (): string => JSON.parse(readFileSync(join(TAURI_DIR, 'tauri.conf.json'), 'utf8')).plugins.updater.pubkey;
function updaterKey(): string {
const key = process.env.STOREBOUND_UPDATER_KEY ?? join(KEYS_DIR, 'updater.key');
if (!existsSync(key)) fail(`no updater signing key at ${key.replace(homedir(), '~')}: see docs/desktop-release.md (bunx tauri signer generate)`);
const pub = `${key}.pub`;
if (existsSync(pub) && readFileSync(pub, 'utf8').trim() !== confPubkey()) fail(`${basename(pub)} is not the public key in tauri.conf.json: the app would refuse these updates`);
return key;
}
/** minisign signature with the updater key (tauri signer), checked right away against tauri.conf.json's key. */
function signFile(file: string): string {
const key = updaterKey();
rmSync(`${file}.sig`, { force: true });
// the key goes by path only: a TAURI_SIGNING_PRIVATE_KEY left in the env (a bundle step, the shell) made the signer
// refuse "--private-key-path cannot be used with --private-key" (0.8.59)
const env: Record<string, string | undefined> = { ...process.env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' };
delete env.TAURI_SIGNING_PRIVATE_KEY; delete env.TAURI_SIGNING_PRIVATE_KEY_PATH;
run(['bunx', 'tauri', 'signer', 'sign', '-f', key, '-p', '', file], { cwd: APP_DIR, env });
const sig = readFileSync(`${file}.sig`, 'utf8').trim();
const v = verifySig(readFileSync(file), sig, confPubkey());
if (!v.ok) fail(`${basename(file)}: fresh signature does not verify (${v.reason})`);
return sig;
}
/**
* The privacy scan of release artifacts. Signed macOS artifacts (.app, .dmg, .app.tar.gz) are scanned without their
* Apple code signatures (tools/mac-signature-scan.ts): the Developer ID certificate in them names the developer, and
* that name may appear there only. Every other byte is scanned; a hit outside a signature fails the release.
*/
function privacyGate(paths: string[]): { files: number; bytes: number; hits: number } {
const mac = process.platform === 'darwin' ? paths.filter(isMacArtifact) : [];
const r = scan(paths.filter(p => !mac.includes(p)));
if (mac.length) {
const termsFile = process.env[TERMS_ENV] ?? DEFAULT_TERMS;
const terms = allTerms(termsFile);
const m: Report = { files: 0, bytes: 0, hits: [], errors: [] };
for (const p of mac) {
if (!existsSync(p)) { m.errors.push(`${p}: does not exist`); continue; }
try { scanSignedMac(p, terms, m); } catch (e) { m.errors.push((e as Error).message); }
}
r.files += m.files; r.bytes += m.bytes; r.hits.push(...m.hits); r.errors.push(...m.errors);
r.terms = terms.length;
if (!r.termsFile && existsSync(termsFile)) r.termsFile = termsFile;
say(`privacy: ${mac.length} signed macOS artifact(s) scanned without their code signatures (the Developer ID certificate may name the developer there only)`);
}
for (const h of r.hits) console.error(`privacy: ${h.file} @${h.offset}${h.encoding ? ` (${h.encoding})` : ''}: ${h.what}`);
for (const e of r.errors) console.error(`privacy: could not open ${e}`);
if (r.hits.length || r.errors.length) fail(`privacy scan failed (${r.hits.length} hits, ${r.errors.length} errors): nothing may ship`);
say(`privacy scan clean: ${r.files} files, ${mb(r.bytes)}, ${r.terms} terms${r.termsFile ? '' : ' (no forbidden-terms file: only this machine\'s names)'}`);
return { files: r.files, bytes: r.bytes, hits: 0 };
}
/** Files of the version folder that live only on the VDS (signed there, tools/vds-sign.ts): `.remote.json`, never
* uploaded. Their SHA-256 was checked on the VDS against the candidate's record; BLAKE2b-512 is what was signed. */
export interface RemoteFile { file: string; sha256: string; size: number; blake2b: string }
const REMOTE_JSON = '.remote.json';
function readRemote(out: string): RemoteFile[] {
const p = join(out, REMOTE_JSON);
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as RemoteFile[] : [];
}
function readManifest(out: string): Manifest | null {
const p = join(out, 'latest.json');
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) as Manifest : null;
}
function writeVersionFiles(out: string, manifest: Manifest): void {
const problems = manifestProblems(manifest);
if (problems.length) fail(`latest.json: ${problems.join('; ')}`);
writeFileSync(join(out, 'latest.json.part'), `${JSON.stringify(manifest, null, 2)}\n`);
renameSync(join(out, 'latest.json.part'), join(out, 'latest.json'));
const remote = readRemote(out);
const files = readdirSync(out).filter(n => uploadable(n) && n !== 'SHA256SUMS' && !remote.some(r => r.file === n));
writeFileSync(join(out, 'SHA256SUMS'), sumsText([...files.map(file => ({ file, sha256: sha256(join(out, file)) })), ...remote.map(r => ({ file: r.file, sha256: r.sha256 }))]));
}
function gitState(): { commit: string; dirty: boolean } {
const commit = run(['git', 'rev-parse', 'HEAD'], { cwd: ROOT }).out.trim();
const dirty = run(['git', 'status', '--porcelain'], { cwd: ROOT }).out.trim().length > 0;
return { commit, dirty };
}
function args(argv: string[]) {
const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
return { get, has: (n: string) => argv.includes(`--${n}`), rest: argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1]!.startsWith('--') && !['--allow-dirty', '--no-notarize', '--yes', '--code-signed', '--allow-unsigned'].includes(argv[i - 1]!))) };
}
// ---------------------------------------------------------------------------------------------------------------------
// mac
interface SelfTest { arch: string; result: Record<string, unknown> }
/** The x86_64 slice is never run on this Mac: launching it starts Rosetta, and macOS then tells the owner that
* "storebound" includes a component that will not open in the next major release. It is checked by structure only
* (`lipo -archs` on every Mach-O in the bundle, each must carry arm64 and x86_64); the self-test runs natively. */
export function sliceProblems(files: { file: string; archs: string[] }[]): string[] {
return files.filter(f => !(f.archs.includes('arm64') && f.archs.includes('x86_64'))).map(f => `${f.file} (${f.archs.join(' ') || 'no slices'})`);
}
/** Architecture guard (pure): users must never get macOS's "App Update Required ... will not open in macOS 28"
* notice, which macOS shows for any app that needs Rosetta. Every Mach-O in the bundle (main binary, sidecar,
* helpers, dylibs, frameworks, anything executable) must contain arm64; the Info.plist must not force Intel
* (LSRequiresNativeExecution false, LSArchitecturePriority with x86_64 / i386 first). `lipo` is `lipo -archs` output. */
export function archProblems(files: { file: string; lipo: string }[], plist: Record<string, unknown>): string[] {
const out: string[] = [];
for (const f of files) {
const archs = f.lipo.trim().split(/\s+/).filter(Boolean);
if (!archs.includes('arm64')) out.push(`${f.file}: no arm64 slice (${archs.join(' ') || 'no slices'}${archs.length && archs.every(a => /^(x86_64h?|i386)$/.test(a)) ? ', Intel-only: needs Rosetta' : ''})`);
}
if (plist.LSRequiresNativeExecution === false) out.push('Info.plist: LSRequiresNativeExecution is false');
const prio = plist.LSArchitecturePriority;
if (Array.isArray(prio) && prio.length && /^(x86_64h?|i386)$/.test(String(prio[0]))) out.push(`Info.plist: LSArchitecturePriority puts ${prio[0]} first`);
return out;
}
export interface ArchGuard { files: { file: string; archs: string }[]; plist: string }
/** The architecture guard on one app: walks the whole bundle for Mach-O files and reads Info.plist (plutil). */
function archGuard(appPath: string, label: string): ArchGuard {
const files = machOFiles(appPath).map(f => ({ file: f.slice(appPath.length + 1), lipo: run(['lipo', '-archs', f]).out.trim() }));
const plist = JSON.parse(run(['plutil', '-convert', 'json', '-o', '-', join(appPath, 'Contents/Info.plist')]).out) as Record<string, unknown>;
const bad = archProblems(files, plist);
if (!files.length) bad.push('no Mach-O files found');
if (bad.length) fail(`architecture guard (${label}): ${bad.join('; ')}`);
const plistNote = `LSRequiresNativeExecution ${plist.LSRequiresNativeExecution ?? 'unset'}, LSArchitecturePriority ${Array.isArray(plist.LSArchitecturePriority) ? plist.LSArchitecturePriority.join(' > ') : 'unset'}`;
say(`architecture guard (${label}): ${files.length} Mach-O files, each contains arm64: ${files.map(f => `${f.file} [${f.lipo}]`).join(', ')}; Info.plist ${plistNote}`);
return { files: files.map(f => ({ file: f.file, archs: f.lipo })), plist: plistNote };
}
function machOFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const f = join(dir, e.name);
if (e.isDirectory()) out.push(...machOFiles(f));
else if (e.isFile() && /Mach-O/.test(run(['file', '-b', f]).out)) out.push(f);
}
return out;
}
function selfTest(appPath: string, label: string): SelfTest[] {
const bin = join(appPath, 'Contents/MacOS', MAIN_BIN);
const slices = machOFiles(appPath).map(f => ({ file: f.slice(appPath.length + 1), archs: run(['lipo', '-archs', f]).out.trim().split(/\s+/).filter(Boolean) }));
const bad = sliceProblems(slices);
if (!slices.length || bad.length) fail(`${label}: not universal: ${bad.join(', ') || 'no executables'}`);
say(`${label}: every executable is universal (${slices.map(s => s.file.replace('Contents/MacOS/', '')).join(', ')}: arm64 x86_64); x86_64 checked by lipo, not run (no Rosetta)`);
if (process.arch !== 'arm64') fail('the self-test runs the arm64 slice natively: build on Apple silicon');
const r = run([bin, '--self-test'], { allowFail: true, timeout: 90_000 });
if (r.code !== 0) fail(`${label}: self-test (arm64) failed: ${r.err.trim() || r.out.trim()}`);
const result = JSON.parse(r.out.trim().split('\n').at(-1)!) as Record<string, unknown>;
if (result.ok !== true || result.protocol !== 'storebound-desktop/1') fail(`${label}: self-test (arm64) answered ${r.out}`);
say(`self-test ${label} (arm64): sidecar ${result.sidecar}, ${result.methods} methods, ${result.ms} ms`);
return [{ arch: 'arm64', result }];
}
export interface Gate { codesign: string; spctl: string; stapler: string; binaries: string[] }
/** Signature checks of one app, inside out: every executable in Contents/MacOS (the app and the CLI sidecar), then the
* bundle. Signed: Developer ID, hardened runtime, a secure timestamp, no sandbox entitlement (Developer ID, not the
* Mac App Store), Gatekeeper accepts it as notarized and the ticket is stapled. Unsigned: ad-hoc, Gatekeeper rejects. */
function gatekeeper(appPath: string, signed: boolean, notarized: boolean, label: string): Gate {
run(['codesign', '--verify', '--deep', '--strict', '--verbose=2', appPath]);
const binaries: string[] = [];
const macos = join(appPath, 'Contents/MacOS');
for (const name of [...readdirSync(macos).sort(), '']) {
const target = name ? join(macos, name) : appPath;
const info = run(['codesign', '-dvv', '--verbose=4', target]).err;
const flags = /flags=0x[0-9a-f]+\(([^)]*)\)/.exec(info)?.[1] ?? '';
const authority = /Authority=(Developer ID Application[^\n]*)/.exec(info)?.[1];
const ents = run(['codesign', '-d', '--entitlements', '-', '--xml', target], { allowFail: true }).out;
const what = name || APP_NAME;
if (signed) {
if (!authority) fail(`${label}: ${what} is not signed with Developer ID`);
if (!/runtime/.test(flags)) fail(`${label}: ${what} has no hardened runtime`);
if (!/Timestamp=/.test(info)) fail(`${label}: ${what} has no secure timestamp`);
if (/com\.apple\.security\.app-sandbox/.test(ents)) fail(`${label}: ${what} carries the App Sandbox entitlement (Mac App Store), not for Developer ID`);
if (/get-task-allow/.test(ents)) fail(`${label}: ${what} is debuggable (get-task-allow)`);
} else if (!/Signature=adhoc/.test(info)) fail(`${label}: unsigned build should be ad-hoc signed (${what})`);
binaries.push(`${what}: ${signed ? 'Developer ID' : 'ad-hoc'}, flags ${flags || 'none'}${/Timestamp=/.test(info) ? ', timestamped' : ''}${/allow-jit/.test(ents) ? ', JIT entitlement' : ''}`);
}
const sp = run(['spctl', '-a', '-vvv', '-t', 'exec', appPath], { allowFail: true });
const spOut = (sp.err || sp.out).trim();
const spctl = `${sp.code === 0 ? 'accepted' : 'rejected'}: ${spOut.split('\n').slice(1, 3).join(' / ')}`;
let stapler = 'not stapled (unsigned)';
if (notarized) {
if (sp.code !== 0 || !/source=Notarized Developer ID/.test(spOut)) fail(`${label}: Gatekeeper does not accept the app as notarized: ${spctl}`);
const st = run(['xcrun', 'stapler', 'validate', appPath], { allowFail: true });
if (st.code !== 0) fail(`${label}: stapler validate failed: ${(st.out + st.err).trim()}`);
stapler = 'ticket stapled, validated';
} else if (signed) {
stapler = 'signed, not notarized (--no-notarize)';
} else if (sp.code === 0) {
fail(`${label}: Gatekeeper accepted an ad-hoc build: unexpected`);
}
const codesign = signed ? 'Developer ID Application, hardened runtime, timestamped' : 'ad-hoc';
say(`${label}: codesign ${codesign}; spctl -t exec ${spctl}; ${stapler}`);
return { codesign, spctl, stapler, binaries };
}
/** notarytool / Tauri credentials: APPLE_API_KEY + APPLE_API_ISSUER + APPLE_API_KEY_PATH, or storebound's own
* ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 (path or PEM; default ~/.appstoreconnect/private_keys/AuthKey_<id>.p8), or
* ~/.storebound-keys/notary.json {keyId, issuerId, keyPath}. Nothing of it is printed. */
function notaryEnv(): Record<string, string> | null {
const env = process.env;
const fromJson = (() => {
const f = join(KEYS_DIR, 'notary.json');
if (!existsSync(f)) return {} as { keyId?: string; issuerId?: string; keyPath?: string };
return JSON.parse(readFileSync(f, 'utf8')) as { keyId?: string; issuerId?: string; keyPath?: string };
})();
const id = env.APPLE_API_KEY ?? env.ASC_KEY_ID ?? fromJson.keyId;
const issuer = env.APPLE_API_ISSUER ?? env.ASC_ISSUER_ID ?? fromJson.issuerId;
if (!id || !issuer) return null;
let path = env.APPLE_API_KEY_PATH ?? env.ASC_P8 ?? fromJson.keyPath ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${id}.p8`);
if (/BEGIN PRIVATE KEY/.test(path)) {
const dir = mkdtempSync(join(tmpdir(), 'sb-notary-'));
writeFileSync(join(dir, `AuthKey_${id}.p8`), path, { mode: 0o600 });
path = join(dir, `AuthKey_${id}.p8`);
}
path = path.replace(/^~(?=\/)/, homedir());
if (!existsSync(path)) fail(`notarization key file not found (${basename(path)}): set ASC_P8 or APPLE_API_KEY_PATH`);
return { APPLE_API_KEY: id, APPLE_API_ISSUER: issuer, APPLE_API_KEY_PATH: path };
}
async function mac(argv: string[]): Promise<void> {
const a = args(argv);
if (process.platform !== 'darwin') fail('the macOS build runs on a Mac');
const remote = remoteSessionReason(process.env, run(['launchctl', 'managername'], { allowFail: true }).out.trim());
if (remote) fail(`${remote}. Signing and notarization must run in the owner's logged-in macOS session (Terminal on the Mac itself): codesign over SSH fails with errSecInternalComponent.`);
const { version, drift } = versions();
if (!version || drift.length) fail(`desktop version drift: ${drift.map(d => d.file).join(', ')} (bun tools/desktop-version.ts)`);
const git = gitState();
if (git.dirty && !a.has('allow-dirty')) fail('the checkout has uncommitted changes: a release is built from a commit (--allow-dirty for a test build)');
updaterKey();
const securityOut = run(['security', 'find-identity', '-v', '-p', 'codesigning']).out;
const identity = a.get('identity') ?? developerIdIdentity(securityOut);
if (a.get('identity') && !securityOut.includes(`"${a.get('identity')}"`)) fail(`identity not in the keychain: ${a.get('identity')}`);
const signed = !!identity;
const notary = signed && !a.has('no-notarize') ? notaryEnv() : null;
if (signed && !a.has('no-notarize') && !notary) fail('Developer ID found but no notarization key: set ASC_KEY_ID + ASC_ISSUER_ID (+ ASC_P8), APPLE_API_KEY + APPLE_API_ISSUER + APPLE_API_KEY_PATH, or ~/.storebound-keys/notary.json (docs/desktop-release.md), or pass --no-notarize for a local test');
const notarized = signed && !!notary;
say(`storebound desktop ${version} for macOS (universal), commit ${git.commit.slice(0, 10)}${git.dirty ? ' + uncommitted changes' : ''}: ${signed ? `signing as Developer ID${notarized ? ', notarized' : ' (not notarized)'}` : 'NO Developer ID Application identity in the keychain: ad-hoc signed preview, named -unsigned'}`);
const out = resolve(a.get('out') ?? join(homedir(), '.storebound-releases', 'desktop', version));
mkdirSync(resolve(a.get('target-dir') ?? '/tmp/storebound-desktop-target'), { recursive: true });
const targetDir = realpathSync(resolve(a.get('target-dir') ?? '/tmp/storebound-desktop-target'));
if (targetDir.startsWith(homedir())) fail('--target-dir must be outside the home folder (build paths end up in binaries)');
mkdirSync(out, { recursive: true });
// toolchain: rustup's (Homebrew's Rust has no x86_64 standard library for the universal build)
const cargoBin = join(homedir(), '.cargo/bin');
const PATH = `${cargoBin}:${process.env.PATH}`;
const installed = run(['rustup', 'target', 'list', '--installed'], { env: { ...process.env, PATH } }).out;
for (const t of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) if (!installed.includes(t)) fail(`rustup target ${t} missing: rustup target add ${t}`);
// sidecars: both CPU types, then one universal file (Tauri looks for binaries/storebound-universal-apple-darwin)
say('sidecars: bun build --compile for darwin-arm64 and darwin-x64');
const arm = await buildOne('bun-darwin-arm64');
const x64 = await buildOne('bun-darwin-x64');
const uni = join(SIDECAR_DIR, 'storebound-universal-apple-darwin');
rmSync(uni, { force: true });
run(['lipo', '-create', '-output', uni, arm, x64]);
run(['codesign', '--force', '--sign', '-', uni]);
// native slices only: running the x64 sidecar would start Rosetta (see sliceProblems)
if (run(['lipo', '-archs', uni]).out.trim().split(/\s+/).sort().join(' ') !== 'arm64 x86_64') fail(`${basename(uni)} is not universal`);
const sideBad = archProblems([{ file: basename(uni), lipo: run(['lipo', '-archs', uni]).out }], {});
if (sideBad.length) fail(`architecture guard: ${sideBad.join('; ')}`);
for (const f of [arm, uni]) {
const v = run([f, 'version'], { allowFail: true, timeout: 60_000 });
if (v.code !== 0) fail(`${basename(f)} does not run: ${v.err}`);
}
// the app: tauri build with the path-free environment, CARGO_TARGET_DIR outside home
const env: Record<string, string | undefined> = { ...process.env, PATH, CARGO_TARGET_DIR: targetDir, CI: 'true' };
delete env.APPLE_API_KEY; delete env.APPLE_API_ISSUER; delete env.APPLE_API_KEY_PATH; delete env.APPLE_SIGNING_IDENTITY;
delete env.APPLE_CERTIFICATE; delete env.APPLE_ID; delete env.APPLE_PASSWORD; delete env.TAURI_SIGNING_PRIVATE_KEY;
Object.assign(env, pathFreeEnv(ROOT, env), notary ?? {});
const override = { bundle: { macOS: signed ? { signingIdentity: identity, hardenedRuntime: true, entitlements: './entitlements.plist' } : { signingIdentity: '-' } } };
const overrideFile = join(mkdtempSync(join(tmpdir(), 'sb-release-')), 'override.json');
writeFileSync(overrideFile, JSON.stringify(override));
// 1. build and bundle WITHOUT signing (no notarization either), 2. the architecture guard on that unsigned app,
// 3. tauri bundle signs, notarizes and staples the very binaries that passed the guard (no recompile)
const unsignedEnv: Record<string, string | undefined> = { ...env };
delete unsignedEnv.APPLE_API_KEY; delete unsignedEnv.APPLE_API_ISSUER; delete unsignedEnv.APPLE_API_KEY_PATH;
say(`tauri build --target universal-apple-darwin --bundles app --no-sign (target dir ${targetDir}; this takes a while)`);
const tb = spawnSync('bunx', ['tauri', 'build', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--no-sign', '--config', overrideFile],
{ cwd: APP_DIR, env: unsignedEnv as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
if (tb.status !== 0) fail(`tauri build failed (exit ${tb.status})`);
const preSignGuard = archGuard(join(targetDir, 'universal-apple-darwin/release/bundle/macos', APP_NAME), 'unsigned app (before signing)');
// in parallel: tauri bundle signs, notarizes (Apple's queue: minutes) and staples the app, while the boot check
// runs the frontend tauri just embedded (apps/desktop/dist): it must draw under the release CSP and the frozen
// Object.prototype with old saved states (tools/desktop-boot-check.ts); a blank window never ships again
say(`tauri bundle --target universal-apple-darwin --bundles app (${signed ? `sign${notarized ? ', notarize, staple' : ''}` : 'ad-hoc sign'}), boot check of the built window at the same time`);
const bootP = runAsync(['bun', join(ROOT, 'tools/desktop-boot-check.ts')], { cwd: ROOT, timeoutMs: 600_000 });
// tauri signs only: notarization runs below with notarytool directly (retries, no S3 acceleration), because the
// upload inside tauri bundle was cut off twice (abortedUpload) for 0.8.12 and tauri gives no retry
const signEnv: Record<string, string | undefined> = { ...env };
delete signEnv.APPLE_API_KEY; delete signEnv.APPLE_API_ISSUER; delete signEnv.APPLE_API_KEY_PATH;
const tbs = await runAsync(['bunx', 'tauri', 'bundle', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile], { cwd: APP_DIR, env: signEnv, inherit: true, timeoutMs: 3_600_000 });
say(`tauri bundle done in ${(tbs.ms / 1000).toFixed(0)} s`);
const boot = await bootP;
process.stdout.write(boot.out);
if (tbs.code !== 0) fail(`tauri bundle (signing) failed (exit ${tbs.code})`);
if (notarized) {
const appPath = join(targetDir, 'universal-apple-darwin/release/bundle/macos', APP_NAME);
const zip = join(mkdtempSync(join(tmpdir(), 'sb-notary-')), 'storebound.zip');
const z = spawnSync('ditto', ['-c', '-k', '--keepParent', appPath, zip]);
if (z.status !== 0) fail('ditto: could not zip the app for notarization');
const n = await notarizeWithRetry(zip, notary!, 'app');
rmSync(dirname(zip), { recursive: true, force: true });
if (n !== 'Accepted') fail(`notarization of the app: ${n}`);
const st = spawnSync('xcrun', ['stapler', 'staple', appPath], { encoding: 'utf8' });
if (st.status !== 0) fail(`stapler staple (app): ${(st.stderr || st.stdout).trim().slice(-300)}`);
say('app notarized and stapled');
}
if (boot.code !== 0) fail(`the built window does not boot cleanly (tools/desktop-boot-check.ts): ${boot.err.trim().split('\n').slice(-3).join(' / ')}`);
say(`boot check passed in ${(boot.ms / 1000).toFixed(0)} s`);
const bootCheck = boot.out.trim().split('\n').at(-1) ?? 'ok';
const bundleDir = join(targetDir, 'universal-apple-darwin/release/bundle/macos');
const app = join(bundleDir, APP_NAME);
if (!existsSync(app)) fail(`no ${APP_NAME} in ${bundleDir}`);
const dsyms = readdirSync(bundleDir).filter(n => n.endsWith('.dSYM'));
if (dsyms.length) fail(`debug symbols next to the app: ${dsyms.join(', ')}`);
// updater bundle: the app as Tauri's updater expects it (tar.gz, no macOS metadata, root-owned entries)
const updaterName = artifactName(version, 'updater', signed);
const updater = join(out, updaterName);
rmSync(updater, { force: true });
run(['tar', '--uid', '0', '--gid', '0', '--uname', 'root', '--gname', 'wheel', '--no-mac-metadata', '-czf', updater, '-C', bundleDir, APP_NAME], { env: { ...process.env, COPYFILE_DISABLE: '1' } });
// DMG: the app and an Applications link, made with hdiutil only (no Finder scripting, nothing comes to the front)
const dmgName = artifactName(version, 'dmg', signed);
const dmg = join(out, dmgName);
const stage = mkdtempSync(join(tmpdir(), 'sb-dmg-'));
run(['ditto', app, join(stage, APP_NAME)]);
symlinkSync('/Applications', join(stage, 'Applications'));
rmSync(dmg, { force: true });
say(`DMG ${dmgName}`);
run(['hdiutil', 'create', '-volname', PRODUCT, '-srcfolder', stage, '-fs', 'HFS+', '-format', 'UDZO', '-imagekey', 'zlib-level=9', '-ov', dmg]);
rmSync(stage, { recursive: true, force: true });
// the DMG goes to Apple now; while Apple checks it, this process checks the app and the updater bundle (they
// carry the app's own stapled ticket and do not depend on the DMG's)
let dmgNotary: Promise<{ code: number; out: string; err: string; ms: number }> | null = null;
if (signed) {
run(['codesign', '--force', '--sign', identity!, '--timestamp', dmg]);
if (notarized) {
say('notarytool submit (DMG): checking the app and the updater bundle while Apple checks the DMG');
dmgNotary = runAsync(['xcrun', 'notarytool', 'submit', dmg, '--key', notary!.APPLE_API_KEY_PATH!, '--key-id', notary!.APPLE_API_KEY!, '--issuer', notary!.APPLE_API_ISSUER!, '--wait', '--output-format', 'json'], { timeoutMs: 3_600_000 });
}
}
const arch: Record<string, ArchGuard> = { preSign: preSignGuard, app: archGuard(app, 'signed app') };
const checks: Record<string, Gate> = { app: gatekeeper(app, signed, notarized, 'built app') };
const tests: Record<string, SelfTest[]> = { app: selfTest(app, 'built app') };
const unpacked = mkdtempSync(join(tmpdir(), 'sb-upd-'));
run(['tar', '-xzf', updater, '-C', unpacked]);
arch.updaterApp = archGuard(join(unpacked, APP_NAME), 'app in the updater bundle');
checks.updaterApp = gatekeeper(join(unpacked, APP_NAME), signed, notarized, 'app in the updater bundle');
tests.updater = selfTest(join(unpacked, APP_NAME), 'updater bundle');
rmSync(unpacked, { recursive: true, force: true });
const privacyApp = privacyGate([app, updater]);
const updaterSig = signFile(updater);
if (dmgNotary) {
let n = await dmgNotary;
// an upload cut off on the way to Apple (abortedUpload, a reset connection) submitted nothing: send it again, up
// to 3 tries, the later ones without S3 transfer acceleration (the path that broke twice for 0.8.12); the exit
// code is not trusted, only notarytool's own status
const accepted = (o: string) => { try { return (JSON.parse(o) as { status?: string }).status === 'Accepted'; } catch { return false; } };
for (let tries = 1; tries < 3 && !accepted(n.out) && /abortedUpload|connection (was )?reset|timed out|network connection was lost|NSURLErrorDomain/i.test(n.err + n.out); tries++) {
say(`notarytool: the DMG upload was cut off before Apple had it (try ${tries}); submitting again without S3 acceleration`);
n = await runAsync(['xcrun', 'notarytool', 'submit', dmg, '--key', notary!.APPLE_API_KEY_PATH!, '--key-id', notary!.APPLE_API_KEY!, '--issuer', notary!.APPLE_API_ISSUER!, '--no-s3-acceleration', '--wait', '--output-format', 'json'], { timeoutMs: 3_600_000 });
}
const status = (() => { try { return (JSON.parse(n.out) as { status?: string }).status; } catch { return undefined; } })();
if (status !== 'Accepted') fail(`notarization of the DMG: ${status ?? (n.err.trim() || n.out.trim() || `notarytool exited ${n.code} without an answer (killed or timed out): run again; xcrun notarytool history shows the submission`)}`);
say(`DMG notarized in ${(n.ms / 1000).toFixed(0)} s`);
run(['xcrun', 'stapler', 'staple', dmg]);
run(['xcrun', 'stapler', 'validate', dmg]);
}
// the exact artifacts: mount the DMG and unpack the updater bundle, then self-test what is inside
const mnt = mkdtempSync(join(tmpdir(), 'sb-mnt-'));
run(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', mnt, dmg]);
try {
arch.dmgApp = archGuard(join(mnt, APP_NAME), 'app in the DMG');
checks.dmgApp = gatekeeper(join(mnt, APP_NAME), signed, notarized, 'app in the DMG');
tests.dmg = selfTest(join(mnt, APP_NAME), 'DMG');
} finally {
run(['hdiutil', 'detach', mnt, '-force'], { allowFail: true });
}
// the DMG itself, as Gatekeeper sees a downloaded disk image
const dsp = run(['spctl', '-a', '-vvv', '-t', 'open', '--context', 'context:primary-signature', dmg], { allowFail: true });
const dmgSpctl = `${dsp.code === 0 ? 'accepted' : 'rejected'}: ${(dsp.err || dsp.out).trim().split('\n').slice(1, 3).join(' / ')}`;
let dmgStapler = 'not stapled (unsigned)';
if (notarized) {
if (dsp.code !== 0 || !/Notarized Developer ID/.test(dsp.err + dsp.out)) fail(`Gatekeeper does not accept the DMG: ${dmgSpctl}`);
const st = run(['xcrun', 'stapler', 'validate', dmg], { allowFail: true });
if (st.code !== 0) fail(`DMG: stapler validate failed: ${(st.out + st.err).trim()}`);
dmgStapler = 'ticket stapled, validated';
}
say(`DMG: spctl -t open ${dmgSpctl}; ${dmgStapler}`);
// privacy: the app folder and the updater bundle (unpacked) above, the DMG (mounted) now
const privacyDmg = privacyGate([dmg]);
const privacy = { files: privacyApp.files + privacyDmg.files, bytes: privacyApp.bytes + privacyDmg.bytes, hits: 0 };
// signatures with the updater key (the updater bundle's above), latest.json, SHA256SUMS
const dmgSig = signFile(dmg);
const notes = a.get('notes') ?? `storebound desktop ${version}`;
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const manifest = mergeManifest(readManifest(out), version, notes, now, {
platforms: {
'darwin-aarch64': { signature: updaterSig, url: releaseUrl(version, updaterName) },
'darwin-x86_64': { signature: updaterSig, url: releaseUrl(version, updaterName) },
},
downloads: { 'macos-universal-dmg': { file: dmgName, url: releaseUrl(version, dmgName), sha256: sha256(dmg), size: statSync(dmg).size, signature: dmgSig, codeSigned: notarized } },
});
writeVersionFiles(out, manifest);
const artifacts = [dmg, updater].map(f => ({ file: basename(f), size: statSync(f).size, sha256: sha256(f) }));
const evidence = {
product: 'storebound desktop', version, platform: 'macos-universal', builtAt: now, commit: git.commit, dirty: git.dirty,
signing: signed ? { identity: 'Developer ID Application', notarized, hardenedRuntime: true } : { identity: 'ad-hoc', notarized: false, note: 'no Developer ID Application certificate in the keychain' },
tools: {
rustc: run(['rustc', '--version'], { env: { ...process.env, PATH } }).out.trim(), bun: Bun.version,
tauri: run(['bunx', 'tauri', '--version'], { cwd: APP_DIR }).out.trim(), macos: run(['sw_vers', '-productVersion']).out.trim(), darwin: osRelease(),
},
checks: { ...checks, dmg: { spctl: dmgSpctl, stapler: dmgStapler }, architecture: arch, selfTest: tests, bootCheck, privacy },
artifacts,
};
writeFileSync(join(out, `evidence-macos-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`);
console.log(`\nstorebound desktop ${version} for macOS: ${out.replace(homedir(), '~')}`);
for (const x of artifacts) console.log(` ${x.file.padEnd(48)} ${mb(x.size).padStart(9)} ${x.sha256}`);
console.log(' latest.json, SHA256SUMS, *.sig, evidence-macos-*.json (evidence stays local)');
if (!signed) {
console.log(`
Unsigned preview (ad-hoc). To ship a signed, notarized build:
1. The Account Holder of the Apple Developer team creates a "Developer ID Application" certificate
(developer.apple.com > Certificates > +; a CSR from Keychain Access on this Mac) and installs it here.
2. App Store Connect API key for notarytool (Users and Access > Integrations, role Developer):
~/.storebound-keys/notary.json {"keyId": "...", "issuerId": "...", "keyPath": "~/.storebound-keys/AuthKey_<id>.p8"}
3. Run this again in Terminal on the Mac (not over SSH).`);
}
console.log(`\nnext: add Windows / Linux (bun tools/desktop-release.ts add --out ${out.replace(homedir(), '~')} <files>), then publish (docs/desktop-release.md).`);
}
// ---------------------------------------------------------------------------------------------------------------------
// add (Windows / Linux installers from the Forgejo workflow)
/** Every artifact of a version comes from one commit: the commit of the added files must match the commit in the
* folder's other evidence files (pure; `evidence` maps an evidence file name to its commit). */
export function commitProblems(commit: string | undefined, evidence: Record<string, string | undefined>): string[] {
if (!commit) return [];
if (!/^[0-9a-f]{40}$/.test(commit)) return [`--commit: a full 40-character commit hash, got ${commit}`];
return Object.entries(evidence).filter(([, c]) => c && c !== commit).map(([f, c]) => `${f} says commit ${c!.slice(0, 10)}, these files are from ${commit.slice(0, 10)}`);
}
/** The Linux / Windows artifact name of a workflow file (`_<version>_` in it, kind from the extension). */
function addedName(n: string, version: string, codeSigned: boolean): string {
if (!n.includes(`_${version}_`)) fail(`${n} is not version ${version}`);
const kind = /-setup\.exe$/i.test(n) ? 'nsis' : /\.AppImage$/.test(n) ? 'appimage' : /\.deb$/.test(n) ? 'deb' : null;
if (!kind) fail(`${n}: expected an NSIS setup .exe, an .AppImage or a .deb`);
return artifactName(version, kind, kind === 'nsis' ? codeSigned : true);
}
function addPrelude(argv: string[]) {
const a = args(argv);
const { version, drift } = versions();
if (!version || drift.length) fail('desktop version drift (bun tools/desktop-version.ts)');
const out = resolve(a.get('out') ?? join(homedir(), '.storebound-releases', 'desktop', version));
mkdirSync(out, { recursive: true });
const commit = a.get('commit');
const otherEvidence = Object.fromEntries(readdirSync(out).filter(n => /^evidence-.*\.json$/.test(n))
.map(n => [n, (JSON.parse(readFileSync(join(out, n), 'utf8')) as { commit?: string }).commit]));
const commitBad = commitProblems(commit, otherEvidence);
if (commitBad.length) fail(`not one commit: ${commitBad.join('; ')}`);
return { a, version, out, commit, codeSigned: a.has('code-signed') };
}
interface Added { name: string; from: string; sha256: string; size: number; sig: string }
/** latest.json, SHA256SUMS and evidence-<platform>-<version>.json for added Linux / Windows files. */
function recordAdded(p: ReturnType<typeof addPrelude>, added: Added[], checks: Record<string, unknown>): void {
const { a, version, out, commit, codeSigned } = p;
const platforms: Manifest['platforms'] = {};
const downloads: Partial<Record<DownloadKey, Download>> = {};
for (const x of added) {
const d: Download = { file: x.name, url: releaseUrl(version, x.name), sha256: x.sha256, size: x.size, signature: x.sig, codeSigned: x.name.endsWith('.exe') ? codeSigned : false };
if (x.name.endsWith('.exe')) { downloads['windows-x86_64-nsis'] = d; platforms['windows-x86_64'] = { signature: x.sig, url: d.url }; }
else if (x.name.endsWith('.AppImage')) { downloads['linux-x86_64-appimage'] = d; platforms['linux-x86_64'] = { signature: x.sig, url: d.url }; }
else downloads['linux-x86_64-deb'] = d;
say(`added ${x.name} (${mb(x.size)})`);
}
const existing = readManifest(out);
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
writeVersionFiles(out, mergeManifest(existing, version, a.get('notes') ?? existing?.notes ?? `storebound desktop ${version}`, now, { platforms, downloads }));
// evidence per platform (stays local, like the macOS one): where the files came from and what was checked here
for (const platform of ['linux', 'windows'] as const) {
const mine = added.filter(d => (platform === 'windows') === d.name.endsWith('.exe'));
if (!mine.length) continue;
const evidence = {
product: 'storebound desktop', version, platform: `${platform}-x86_64`, addedAt: now, commit: commit ?? null, run: a.get('run') ?? null,
checks: { ...checks, codeSigned: platform === 'windows' ? codeSigned : false },
artifacts: mine.map(d => ({ file: d.name, from: d.from, size: d.size, sha256: d.sha256 })),
};
writeFileSync(join(out, `evidence-${platform}-${version}.json`), `${JSON.stringify(evidence, null, 2)}\n`);
}
say(`latest.json now has ${Object.keys(readManifest(out)!.platforms).join(', ')}${commit ? `; evidence: commit ${commit.slice(0, 10)}` : ''}`);
}
/** The fallback (--sign-local in tools/release.ts): the files are on this Mac and signed with ~/.storebound-keys. */
function add(argv: string[]): void {
const p = addPrelude(argv);
const files = p.a.rest.slice(1);
if (!files.length) fail('usage: add --out <dir> <setup.exe | .AppImage | .deb ...>');
const copied: { dest: string; from: string }[] = [];
for (const f of files.map(x => resolve(x))) {
const dest = join(p.out, addedName(basename(f), p.version, p.codeSigned));
copyFileSync(f, dest);
copied.push({ dest, from: basename(f) });
}
const privacy = privacyGate(copied.map(c => c.dest));
const added = copied.map(c => ({ name: basename(c.dest), from: c.from, sha256: sha256(c.dest), size: statSync(c.dest).size, sig: signFile(c.dest) }));
recordAdded(p, added, { privacy, updaterSignature: 'minisign on the Mac, verified against tauri.conf.json' });
}
/** The VDS signs (owner's decision 21 Oct 2026): `add-remote --out <dir> --host <user@host> --assets <json>` with
* [{name, url, sha256, size}] of the Forgejo release. The VDS fetches each file into its incoming folder, checks
* the SHA-256 and signs it; only the .sig texts come back, each verified here before anything is written. */
async function addRemote(argv: string[]): Promise<void> {
const p = addPrelude(argv);
const host = p.a.get('host') ?? fail('--host <user@host> is required');
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
const assets = JSON.parse(readFileSync(p.a.get('assets') ?? fail('--assets <json file> is required'), 'utf8')) as { name: string; url: string; sha256: string; size: number }[];
if (!assets.length) fail('--assets: no files');
const items: (RemoteItem & { from: string; size: number })[] = assets.map(x => ({ file: addedName(x.name, p.version, p.codeSigned), url: x.url, sha256: x.sha256, from: x.name, size: x.size }));
const dir = `${REMOTE_INCOMING}/${p.version}`;
say(`VDS ${host.split('@')[1]}: fetch ${items.length} files from Forgejo into ${dir}, check SHA-256, sign with ${REMOTE_KEY}`);
const outText = await sshScript(host, remoteSignScript(dir, items), { timeoutMs: 30 * 60_000, what: 'VDS fetch + sign', log: say }).catch((e: Error) => fail(e.message));
const got = parseSigLines(outText);
const problems = remoteSigProblems(items.map(i => i.file), got, confPubkey());
if (problems.length) fail(`signatures from the VDS refused, nothing written: ${problems.join('; ')}`);
const remote: RemoteFile[] = [];
const added: Added[] = items.map(i => {
const g = got.find(x => x.file === i.file)!;
if (i.size && g.size !== i.size) fail(`${i.file}: ${g.size} bytes on the VDS, the release says ${i.size}`);
remote.push({ file: i.file, sha256: i.sha256, size: g.size, blake2b: g.blake2b });
writeFileSync(join(p.out, `${i.file}.sig`), g.signature);
rmSync(join(p.out, i.file), { force: true }); // a stale local copy from a --sign-local run never wins
return { name: i.file, from: i.from, sha256: i.sha256, size: g.size, sig: g.signature };
});
const prev = readRemote(p.out).filter(r => !remote.some(x => x.file === r.file));
writeFileSync(join(p.out, REMOTE_JSON), `${JSON.stringify([...prev, ...remote], null, 2)}\n`);
say(`${added.length} signatures from the VDS verified against tauri.conf.json's key`);
recordAdded(p, added, { privacy: 'scanned in the build jobs (ci.yml, private terms)', updaterSignature: 'minisign on the VDS, verified on the Mac against tauri.conf.json (BLAKE2b-512 digest)', sha256: 'checked on the VDS against the candidate record' });
}
/** `vds-key --host <user@host>`: installs or updates the updater key (and its password file, if
* ~/.storebound-keys/updater.key.password exists) in /root/.storebound-keys on the VDS (dir 0700, files 0600, root),
* over ssh stdin (never printed, never on a command line), then signs a test string there and verifies it here. */
async function vdsKey(argv: string[]): Promise<void> {
const a = args(argv);
const host = a.get('host') ?? process.env.STOREBOUND_PUBLISH_HOST ?? (existsSync(join(KEYS_DIR, 'publish-host')) ? readFileSync(join(KEYS_DIR, 'publish-host'), 'utf8').trim() : '');
if (!/^root@[A-Za-z0-9.-]+$/.test(host)) fail(`the VDS host must be root@<host> (--host, STOREBOUND_PUBLISH_HOST or ~/.storebound-keys/publish-host), got ${host || 'nothing'}`);
const key = updaterKey();
const keyB64 = readFileSync(key).toString('base64');
const pwFile = `${key}.password`;
const pwB64 = existsSync(pwFile) ? readFileSync(pwFile).toString('base64') : '';
const test = `storebound-vds-key-check-${Date.now()}`;
const t = `/tmp/sb-key-check-${process.pid}`;
const script = [
'set -euo pipefail', 'umask 077',
`install -d -m 700 -o root -g root ${REMOTE_KEYS}`,
`base64 -d > ${REMOTE_KEY}.new <<'K'`, keyB64, 'K',
`chmod 600 ${REMOTE_KEY}.new && chown root:root ${REMOTE_KEY}.new && mv -f ${REMOTE_KEY}.new ${REMOTE_KEY}`,
pwB64 ? `base64 -d > ${REMOTE_KEY_PASSWORD}.new <<'K'
${pwB64}
K
chmod 600 ${REMOTE_KEY_PASSWORD}.new && mv -f ${REMOTE_KEY_PASSWORD}.new ${REMOTE_KEY_PASSWORD}` : `rm -f ${REMOTE_KEY_PASSWORD}`,
`chmod 700 ${REMOTE_KEYS}`,
`mkdir -p ${t} && printf '%s' '${test}' > ${t}/check.txt`,
'echo "signing a test string (the first run downloads the tauri CLI)"',
`${signCommand(`${t}/check.txt`)}`,
`printf 'SIG %s %s %s %s\n' check.txt "$(b2sum -l 512 ${t}/check.txt | cut -d' ' -f1)" "$(stat -c %s ${t}/check.txt)" "$(tr -d '\n' < ${t}/check.txt.sig)"`,
`rm -rf ${t}`,
`stat -c 'MODE %a %U %n' ${REMOTE_KEYS} ${REMOTE_KEYS}/*`,
].join('\n');
say(`installing the updater key on ${host.split('@')[1]}:${REMOTE_KEYS} (one ssh connection)`);
const out = await sshScript(host, `${script}\n`, { timeoutMs: 10 * 60_000, what: 'vds-key', log: say }).catch((e: Error) => fail(e.message));
const modes = out.split('\n').filter(l => l.startsWith('MODE '));
const bad = modes.filter(l => !/^MODE (700|600) root /.test(l));
if (!modes.length || bad.length) fail(`unexpected modes on the VDS: ${bad.join('; ') || 'none read'}`);
const [sig] = parseSigLines(out);
const blake = createHash('blake2b512').update(test).digest('hex');
if (!sig || sig.blake2b !== blake) fail('the VDS did not sign the test string');
const v = verifyDigest(blake, sig.signature, confPubkey(), 'check.txt');
if (!v.ok) fail(`the VDS's test signature does not verify against tauri.conf.json: ${v.reason}`);
say(`ok: ${modes.length} paths 0700/0600 root; the VDS signs with the key of tauri.conf.json (test string verified here)`);
}
// ---------------------------------------------------------------------------------------------------------------------
// verify / publish
function verifyFolder(out: string): { manifest: Manifest; files: string[]; remote: RemoteFile[] } {
if (!existsSync(join(out, 'latest.json')) || !existsSync(join(out, 'SHA256SUMS'))) fail(`${out}: no latest.json / SHA256SUMS`);
const manifest = readManifest(out)!;
const problems = manifestProblems(manifest);
if (problems.length) fail(`latest.json: ${problems.join('; ')}`);
const sums = readFileSync(join(out, 'SHA256SUMS'), 'utf8').trim().split('\n').map(l => { const [h, f] = l.split(/\s+/); return { h: h!, f: f! }; });
const remote = readRemote(out);
const remoteOf = (f: string) => remote.find(r => r.file === f);
for (const { h, f } of sums) {
const r = remoteOf(f);
if (r ? r.sha256 !== h : (!existsSync(join(out, f)) || sha256(join(out, f)) !== h)) fail(`SHA256SUMS: ${f} does not match`);
}
const files = [...new Set([...readdirSync(out).filter(uploadable), ...remote.map(r => r.file)])].sort();
for (const f of files) if (f !== 'SHA256SUMS' && !sums.some(s => s.f === f)) fail(`${f} is not in SHA256SUMS`);
const pub = confPubkey();
const check = (url: string, signature: string, what: string) => {
const file = url.split('/').pop()!;
if (!url.startsWith(releaseUrl(manifest.version, ''))) fail(`${what}: ${url} is not in /desktop/dl/${manifest.version}/`);
if (!files.includes(file)) fail(`${what}: ${file} is not in the folder`);
const r = remoteOf(file);
const v = r ? verifyDigest(r.blake2b, signature, pub, file) : verifySig(readFileSync(join(out, file)), signature, pub);
if (!v.ok) fail(`${what}: ${file}: ${v.reason}`);
};
for (const [k, p] of Object.entries(manifest.platforms)) check(p.url, p.signature, `platforms.${k}`);
for (const [k, d] of Object.entries(manifest.downloads ?? {})) {
check(d!.url, d!.signature, `downloads.${k}`);
const r = remoteOf(d!.file);
const [h, size] = r ? [r.sha256, r.size] : [sha256(join(out, d!.file)), statSync(join(out, d!.file)).size];
if (h !== d!.sha256 || size !== d!.size) fail(`downloads.${k}: size or SHA-256 differ from the file`);
}
// the VDS-signed files were scanned in their build jobs (ci.yml, private terms) and are not on this Mac
privacyGate(files.filter(f => !remoteOf(f) && !/\.(sig|json)$|^SHA256SUMS$/.test(f)).map(f => join(out, f)));
if (remote.length) say(`on the VDS only (signed there, SHA-256 checked there against the candidate): ${remote.map(r => r.file).join(', ')}`);
say(`verified ${manifest.version}: ${files.length} files, platforms ${Object.keys(manifest.platforms).join(', ')}, downloads ${Object.keys(manifest.downloads ?? {}).join(', ')}`);
return { manifest, files, remote };
}
/** The npm CLI and the desktop app are one release: the folder must be the version of this checkout's package.json
* (the CLI that `npx storebound desktop` installs it for). Pure. */
export function releaseTogetherProblems(folderVersion: string, pkgVersion: string | null): string[] {
if (!pkgVersion) return ['package.json has no version'];
return folderVersion === pkgVersion ? [] : [`the folder is desktop ${folderVersion} but package.json (the npm CLI) is ${pkgVersion}: the CLI and the app ship with one version (bun tools/desktop-version.ts --set ${folderVersion}, or build the folder from the release commit)`];
}
/**
* The publish commands. rsync by absolute path, never from PATH: Apple's /usr/bin/rsync (openrsync) with only the
* options it supports (no --chmod; the modes are set on the server after the upload), else Homebrew's keg. A tool
* never needs a global rsync linked or installed (pitfall 54: a linked Homebrew rsync broke Xcode's exportArchive).
*/
export function publishSteps(o: { host: string; v: string; channel: string; out: string; files: string[] }, exists: (p: string) => boolean = existsSync): string[][] {
const rsync = ['/usr/bin/rsync', '/opt/homebrew/opt/rsync/bin/rsync', '/usr/local/opt/rsync/bin/rsync'].find(p => exists(p));
if (!rsync) throw new Error('publish needs rsync: /usr/bin/rsync (macOS, most Linux) or the Homebrew keg (brew install rsync; no link needed)');
const dir = `${REMOTE_INCOMING}/${o.v}`;
return [
['ssh', o.host, `mkdir -p ${dir} && chmod 700 ${REMOTE_INCOMING}`],
[rsync, '-a', '--delete', ...o.files.map(f => join(o.out, f)), `${o.host}:${dir}/`],
['ssh', o.host, `find ${dir} -type d -exec chmod 755 {} + && find ${dir} -type f -exec chmod 644 {} + && ${REMOTE_PUBLISH} ${o.v} ${o.channel}`],
];
}
/** The channels of storebound.dev/desktop: stable (latest.json), beta, and rehearsal (tools/release.ts --rehearsal:
* a throwaway prerelease that no app reads). */
export const CHANNELS = ['stable', 'beta', 'rehearsal'] as const;
export const channelUrl = (channel: string): string => (channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/${channel}/latest.json`);
/**
* Files the server can fetch from the Forgejo release instead of the owner's uplink (pure): every file of the folder
* whose SHA-256 is in the release's SHA256SUMS under some asset name (the Windows installer is renamed -unsigned
* here). Each is downloaded on the server into the incoming folder before rsync, which then only compares checksums.
*/
export function seedPlan(local: { file: string; sha256: string }[], releaseSums: string, assets: { name: string; url: string }[]): { file: string; url: string; sha256: string }[] {
const byHash = new Map<string, string>();
for (const l of releaseSums.split('\n')) { const m = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(l.trim()); if (m) byHash.set(m[1]!, m[2]!); }
const out: { file: string; url: string; sha256: string }[] = [];
for (const f of local) {
const name = byHash.get(f.sha256);
const asset = name ? assets.find(x => x.name === name) : undefined;
if (asset) out.push({ file: f.file, url: asset.url, sha256: f.sha256 });
}
return out;
}
async function publish(argv: string[]): Promise<void> {
const a = args(argv);
const out = resolve(a.get('out') ?? fail('--out <version folder> is required'));
const host = a.get('host') ?? process.env.STOREBOUND_PUBLISH_HOST ?? fail('--host <user@host> (or STOREBOUND_PUBLISH_HOST) is required');
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test(host)) fail(`--host: user@host, got ${host}`);
const channel = a.get('channel') ?? 'stable';
if (!(CHANNELS as readonly string[]).includes(channel)) fail(`--channel ${CHANNELS.join(', ')}`);
const { manifest, files: all, remote } = verifyFolder(out);
// files signed on the VDS are already in its incoming folder: never rsynced or seeded, only checked there
const files = all.filter(f => !remote.some(r => r.file === f));
const v = manifest.version;
const together = releaseTogetherProblems(v, versions().version);
if (together.length) fail(together.join('; '));
const unsigned = Object.values(manifest.downloads ?? {}).filter(d => !d!.codeSigned && /\.(dmg|exe)$/.test(d!.file)).map(d => d!.file);
const steps = publishSteps({ host, v, channel, out, files });
const forgejoTag = a.get('forgejo-tag');
if (forgejoTag && forgejoTag !== `v${v}`) fail(`--forgejo-tag ${forgejoTag} is not v${v}`);
// with --forgejo-tag: the server takes the Linux / Windows files from the Forgejo release (its own network), and
// after the rsync attaches the Mac's files to that release from its copy, so every byte crosses the owner's
// uplink once (the DMG and the updater bundle), not three times
const rel = forgejoTag ? (await listReleases()).find(r => r.tag_name === forgejoTag) : undefined;
if (forgejoTag && !rel) fail(`no Forgejo release ${forgejoTag} yet (release.yml promotes the candidate on the tag push)`);
const relSums = rel?.assets.find(x => x.name === 'SHA256SUMS');
const sumsText0 = relSums ? spawnSync('curl', ['-fsSL', relSums.browser_download_url], { encoding: 'utf8' }).stdout ?? '' : '';
const seed = rel ? seedPlan(files.map(f => ({ file: f, sha256: sha256(join(out, f)) })), sumsText0, rel.assets.map(x => ({ name: x.name, url: x.browser_download_url }))) : [];
const macFiles = forgejoTag ? forgejoMacFiles(files, v) : [];
console.log(`\npublish storebound desktop ${v} to the ${channel} channel (${channelUrl(channel)}):`);
for (const f of files) console.log(` ${f}`);
if (unsigned.length) console.log(` note: unsigned preview builds (${unsigned.join(', ')}); the downloads page says so.`);
console.log('\nsteps:');
for (const s of steps) console.log(` ${s[0]} ${s.slice(1).map(x => (x.includes(' ') ? `"${x}"` : x.replace(out, '<out>'))).join(' ')}`);
if (remote.length) console.log(` already on the server (signed there), SHA-256 checked again: ${remote.map(r => r.file).join(', ')}`);
if (seed.length) console.log(` the server fetches from Forgejo ${forgejoTag}: ${seed.map(x => x.file).join(', ')}`);
if (macFiles.length) console.log(` then attaches to Forgejo ${forgejoTag} from its copy: ${macFiles.join(', ')}`);
if (!a.has('yes')) { console.log('\ndry run: nothing uploaded. Every installed app on this channel offers the update once latest.json is live; run again with --yes to publish.'); return; }
const [mkdir, rsync, finish] = steps as [string[], string[], string[]];
run(mkdir);
if (seed.length) {
const dir = `${REMOTE_INCOMING}/${v}`;
const lines = seed.map(x => { if (!/^[A-Za-z0-9._-]+$/.test(x.file) || !/^https:\/\/[A-Za-z0-9./_%-]+$/.test(x.url)) fail(`seed: unexpected ${x.file} ${x.url}`); return `curl -fsSL --max-time 900 -o '${dir}/${x.file}' '${x.url}' && echo '${x.sha256} ${dir}/${x.file}' | sha256sum -c --quiet`; });
say(`server: fetch ${seed.length} files from the Forgejo release`);
// in parallel on the server; any failed download or hash fails the step
// the script goes to bash on stdin: the server's login shell may be zsh, which does not split "$pids"
run(['ssh', host, 'bash', '-s'], { input: `pids=''\n${lines.map(l => `(${l}) & pids="$pids $!"`).join('\n')}\nfor p in $pids; do wait $p || exit 1; done\n`, stdio: ['pipe', 'inherit', 'inherit'] });
}
if (remote.length) {
const dir = `${REMOTE_INCOMING}/${v}`;
say(`server: SHA-256 of the ${remote.length} files signed there`);
await sshScript(host, `set -euo pipefail\n${remote.map(r => `echo '${r.sha256} ${dir}/${r.file}' | sha256sum -c --quiet || { echo "${r.file} on the server is not the signed file"; exit 1; }`).join('\n')}\n`, { timeoutMs: 5 * 60_000, what: 'remote SHA-256 check', log: say })
.catch((e: Error) => fail(e.message));
}
say(`rsync (only what the server does not have yet) ...`);
run(rsync, { stdio: ['ignore', 'inherit', 'inherit'] });
if (macFiles.length) {
say(`Forgejo ${forgejoTag}: attach ${macFiles.join(', ')} from the server's copy`);
await forgejoUpsert({ tag: forgejoTag!, files: macFiles.map(f => join(out, f)), create: false, yes: true, remote: { host, dir: `${REMOTE_INCOMING}/${v}` } });
}
say('publish script ...');
run(finish, { stdio: ['ignore', 'inherit', 'inherit'] });
const live = spawnSync('curl', ['-fsS', channelUrl(channel)], { encoding: 'utf8' });
if (live.status !== 0 || (JSON.parse(live.stdout) as Manifest).version !== v) fail('latest.json on storebound.dev is not the new version yet');
say(`live: ${BASE_URL}/ serves ${v}`);
say(`next, the same release: npm publish of storebound ${v} (CONTRIBUTING.md). \`npx storebound desktop\` of ${v} installs or updates to this app.`);
}
// ---------------------------------------------------------------------------------------------------------------------
// upload-forgejo (the Mac's files join the release the workflow made)
/** The files of a version folder that go to the Forgejo release from the Mac: the DMG and the updater bundle, each
* with its .sig (pure). Linux and Windows files come from the workflow itself. */
export function forgejoMacFiles(names: string[], version: string): string[] {
return names.filter(n => n.startsWith(`${PRODUCT}_${version}_universal`) && /\.(dmg|app\.tar\.gz)(\.sig)?$/.test(n)).sort();
}
async function uploadForgejo(argv: string[]): Promise<void> {
const a = args(argv);
const out = resolve(a.get('out') ?? fail('--out <version folder> is required'));
const { manifest } = verifyFolder(out);
const v = manifest.version;
const together = releaseTogetherProblems(v, versions().version);
if (together.length) fail(together.join('; '));
const tag = a.get('tag') ?? `v${v}`;
if (tag !== `v${v}`) fail(`--tag ${tag} is not v${v}: the folder is version ${v}`);
const files = forgejoMacFiles(readdirSync(out), v);
if (!files.some(f => f.endsWith('.dmg'))) fail(`${out}: no DMG of ${v} (run mac first)`);
if (files.some(f => f.includes('-unsigned')) && !a.has('allow-unsigned')) fail('the DMG is unsigned (-unsigned): only a notarized build goes to the release (--allow-unsigned for a preview)');
await forgejoUpsert({ tag, files: files.map(f => join(out, f)), create: false, yes: a.has('yes') });
if (a.has('yes')) say(`next: publish --out ${out.replace(homedir(), '~')} --host <user@host> (storebound.dev), then npm publish of ${v}`);
}
if (import.meta.main) {
const argv = process.argv.slice(2);
const cmd = argv[0];
if (cmd === 'mac') await mac(argv.slice(1));
else if (cmd === 'add') add(argv);
else if (cmd === 'add-remote') await addRemote(argv);
else if (cmd === 'vds-key') await vdsKey(argv);
else if (cmd === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out <version folder>')));
else if (cmd === 'publish') await publish(argv.slice(1));
else if (cmd === 'upload-forgejo') await uploadForgejo(argv.slice(1));
else if (cmd === 'exec') {
// `exec -- <command...>`: runs a build command with the path-free environment (the Forgejo workflow uses it)
const cmdline = argv.slice(argv.indexOf('--') + 1);
if (argv.indexOf('--') < 0 || !cmdline.length) fail('usage: exec -- <command...>');
const r = spawnSync(cmdline[0]!, cmdline.slice(1), { stdio: 'inherit', env: { ...process.env, ...pathFreeEnv(ROOT, process.env) }, shell: process.platform === 'win32' });
process.exit(r.status ?? 1);
}
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 40).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
}