834 lines
57 KiB
TypeScript
834 lines
57 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 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, 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 { 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. */
|
|
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 });
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
|
|
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', '--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 });
|
|
const tbs = await runAsync(['bunx', 'tauri', 'bundle', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile], { cwd: APP_DIR, env, 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 (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) {
|
|
const n = await dmgNotary;
|
|
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)}`);
|
|
}
|
|
|
|
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 <dir> <setup.exe | .AppImage | .deb ...>');
|
|
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('; ')}`);
|
|
const codeSigned = a.has('code-signed');
|
|
const platforms: Manifest['platforms'] = {};
|
|
const downloads: Partial<Record<DownloadKey, Download>> = {};
|
|
const added: string[] = [];
|
|
const sources: Record<string, 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);
|
|
sources[name] = n;
|
|
}
|
|
const privacy = 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);
|
|
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.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: { privacy, updaterSignature: 'minisign, verified against tauri.conf.json', codeSigned: platform === 'windows' ? codeSigned : false },
|
|
artifacts: mine.map(d => ({ file: basename(d), from: sources[basename(d)], size: statSync(d).size, sha256: sha256(d) })),
|
|
};
|
|
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)}` : ''}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
// 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 };
|
|
}
|
|
|
|
/** 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 } = verifyFolder(out);
|
|
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 (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
|
|
run(['ssh', host, `pids=''; ${lines.map(l => `(${l}) & pids="$pids $!"`).join('; ')}; for p in $pids; do wait $p || exit 1; done`], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
}
|
|
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 === '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, 30).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
|
|
}
|
|
|