#!/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 ] [--target-dir ] [--identity ""] // [--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, the self-test (sidecar handshake, both CPU // types) 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 [--code-signed] // Adds Windows / Linux installers built by the Forgejo workflow (.forgejo/workflows/desktop.yml) to the version // folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS. // bun tools/desktop-release.ts publish --out --host [--channel stable|beta] [--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). // bun tools/desktop-release.ts verify --out // The checks of publish without uploading. // bun tools/desktop-release.ts exec -- // Runs a build command (tauri build on a runner) with the path-free environment described at pathFreeEnv. // // Version: package.json "desktopVersion" (bun tools/desktop-version.ts). 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, 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 { scan } 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, 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, home = homedir(), platform: NodeJS.Platform = process.platform): Record { 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 = {}; 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> }): 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; } 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 }); run(['bunx', 'tauri', 'signer', 'sign', '-f', key, '-p', '', file], { cwd: APP_DIR, env: { ...process.env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' } }); 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; } function privacyGate(paths: string[]): { files: number; bytes: number; hits: number } { const r = scan(paths); 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 }; } 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 files = readdirSync(out).filter(n => uploadable(n) && n !== 'SHA256SUMS'); writeFileSync(join(out, 'SHA256SUMS'), sumsText(files.map(file => ({ file, sha256: sha256(join(out, file)) })))); } 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'].includes(argv[i - 1]!))) }; } // --------------------------------------------------------------------------------------------------------------------- // mac interface SelfTest { arch: string; result: Record } function selfTest(appPath: string, label: string): SelfTest[] { const bin = join(appPath, 'Contents/MacOS', MAIN_BIN); const out: SelfTest[] = []; const arches = run(['lipo', '-archs', bin]).out.trim().split(/\s+/).sort(); const sideArches = run(['lipo', '-archs', join(appPath, 'Contents/MacOS', PRODUCT)]).out.trim().split(/\s+/).sort(); if (arches.join(' ') !== 'arm64 x86_64' || sideArches.join(' ') !== 'arm64 x86_64') fail(`${label}: not universal (app ${arches}, sidecar ${sideArches})`); const rosetta = run(['arch', '-x86_64', '/usr/bin/true'], { allowFail: true }).code === 0; for (const arch of rosetta ? ['arm64', 'x86_64'] : [process.arch === 'arm64' ? 'arm64' : 'x86_64']) { const r = run(['arch', `-${arch}`, bin, '--self-test'], { allowFail: true, timeout: 90_000 }); if (r.code !== 0) fail(`${label}: self-test (${arch}) failed: ${r.err.trim() || r.out.trim()}`); const result = JSON.parse(r.out.trim().split('\n').at(-1)!) as Record; if (result.ok !== true || result.protocol !== 'storebound-desktop/1') fail(`${label}: self-test (${arch}) answered ${r.out}`); say(`self-test ${label} (${arch}): sidecar ${result.sidecar}, ${result.methods} methods, ${result.ms} ms`); out.push({ arch, result }); } if (!rosetta) say('self-test: Rosetta is not installed, x86_64 slice not run (softwareupdate --install-rosetta to cover it)'); return out; } 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_.p8), or * ~/.storebound-keys/notary.json {keyId, issuerId, keyPath}. Nothing of it is printed. */ function notaryEnv(): Record | 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 { 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]); for (const f of [arm, x64, uni]) { const v = run([f, 'version'], { allowFail: true, timeout: 60_000 }); if (f !== x64 && 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 = { ...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)); say(`tauri build --target universal-apple-darwin --bundles app (target dir ${targetDir}; this takes a while)`); const tb = spawnSync('bunx', ['tauri', 'build', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile], { cwd: APP_DIR, env: env as Record, stdio: ['ignore', 'inherit', 'inherit'] }); if (tb.status !== 0) fail(`tauri build failed (exit ${tb.status})`); // 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('boot check of the built window (empty, D1 with folders gone, D3 saved state; missing bundle panel)'); const boot = spawnSync('bun', [join(ROOT, 'tools/desktop-boot-check.ts')], { cwd: ROOT, encoding: 'utf8', timeout: 600_000 }); process.stdout.write(boot.stdout ?? ''); if (boot.status !== 0) fail(`the built window does not boot cleanly (tools/desktop-boot-check.ts): ${(boot.stderr ?? '').trim().split('\n').slice(-3).join(' / ')}`); const bootCheck = (boot.stdout ?? '').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(', ')}`); const checks: Record = { app: gatekeeper(app, signed, notarized, 'built app') }; const tests: Record = { app: selfTest(app, 'built app') }; // 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 }); if (signed) { run(['codesign', '--force', '--sign', identity!, '--timestamp', dmg]); if (notarized) { say('notarytool submit (DMG), waiting for Apple'); const n = run(['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'], { allowFail: true, timeout: 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()}`); 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 { 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}`); const unpacked = mkdtempSync(join(tmpdir(), 'sb-upd-')); run(['tar', '-xzf', updater, '-C', unpacked]); 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 }); // privacy: the app folder, the DMG (mounted) and the updater bundle (unpacked) const privacy = privacyGate([app, dmg, updater]); // signatures with the updater key, latest.json, SHA256SUMS const updaterSig = signFile(updater); 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 }, 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_.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(), '~')} ), then publish (docs/desktop-release.md).`); } // --------------------------------------------------------------------------------------------------------------------- // add (Windows / Linux installers from the Forgejo workflow) function add(argv: string[]): void { 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)); const files = a.rest.slice(1); if (!files.length) fail('usage: add --out '); mkdirSync(out, { recursive: true }); const codeSigned = a.has('code-signed'); const platforms: Manifest['platforms'] = {}; const downloads: Partial> = {}; const added: string[] = []; for (const f of files.map(x => resolve(x))) { const n = basename(f); 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`); const name = artifactName(version, kind, kind === 'nsis' ? codeSigned : true); const dest = join(out, name); copyFileSync(f, dest); added.push(dest); } privacyGate(added); for (const dest of added) { const name = basename(dest); const sig = signFile(dest); const d: Download = { file: name, url: releaseUrl(version, name), sha256: sha256(dest), size: statSync(dest).size, signature: sig, codeSigned: name.endsWith('.exe') ? codeSigned : false }; if (name.endsWith('.exe')) { downloads['windows-x86_64-nsis'] = d; platforms['windows-x86_64'] = { signature: sig, url: d.url }; } else if (name.endsWith('.AppImage')) { downloads['linux-x86_64-appimage'] = d; platforms['linux-x86_64'] = { signature: sig, url: d.url }; } else downloads['linux-x86_64-deb'] = d; say(`added ${name} (${mb(d.size)})`); } const existing = readManifest(out); writeVersionFiles(out, mergeManifest(existing, version, a.get('notes') ?? existing?.notes ?? `storebound desktop ${version}`, new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), { platforms, downloads })); say(`latest.json now has ${Object.keys(readManifest(out)!.platforms).join(', ')}`); } // --------------------------------------------------------------------------------------------------------------------- // verify / publish function verifyFolder(out: string): { manifest: Manifest; files: string[] } { 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! }; }); for (const { h, f } of sums) if (!existsSync(join(out, f)) || sha256(join(out, f)) !== h) fail(`SHA256SUMS: ${f} does not match`); const files = readdirSync(out).filter(uploadable); 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 v = 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}`); if (sha256(join(out, d!.file)) !== d!.sha256 || statSync(join(out, d!.file)).size !== d!.size) fail(`downloads.${k}: size or SHA-256 differ from the file`); } privacyGate(files.filter(f => !/\.(sig|json)$|^SHA256SUMS$/.test(f)).map(f => join(out, f))); say(`verified ${manifest.version}: ${files.length} files, platforms ${Object.keys(manifest.platforms).join(', ')}, downloads ${Object.keys(manifest.downloads ?? {}).join(', ')}`); return { manifest, files }; } function publish(argv: string[]): void { const a = args(argv); const out = resolve(a.get('out') ?? fail('--out is required')); const host = a.get('host') ?? process.env.STOREBOUND_PUBLISH_HOST ?? fail('--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 (!['stable', 'beta'].includes(channel)) fail('--channel stable or beta'); const { manifest, files } = verifyFolder(out); const v = manifest.version; const unsigned = Object.values(manifest.downloads ?? {}).filter(d => !d!.codeSigned && /\.(dmg|exe)$/.test(d!.file)).map(d => d!.file); const steps = [ ['ssh', host, `mkdir -p ${REMOTE_INCOMING}/${v} && chmod 700 ${REMOTE_INCOMING}`], ['rsync', '-a', '--chmod=F644,D755', '--delete', ...files.map(f => join(out, f)), `${host}:${REMOTE_INCOMING}/${v}/`], ['ssh', host, `${REMOTE_PUBLISH} ${v} ${channel}`], ]; console.log(`\npublish storebound desktop ${v} to the ${channel} channel (${channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`}):`); 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, ''))).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; } for (const s of steps) { say(`${s[0]} ...`); run(s, { stdio: ['ignore', 'inherit', 'inherit'] }); } const live = spawnSync('curl', ['-fsS', channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`], { 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}`); } 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 === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out '))); else if (cmd === 'publish') publish(argv.slice(1)); else if (cmd === 'exec') { // `exec -- `: 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 -- '); 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, 26).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); } }