release candidates: every push to main runs the full CI, the acceptance matrix and the Linux / Windows installer builds in parallel (ci.yml) and records the green result per commit as the draft rc-<sha> (green.json last); a version-only bump commit (proven file by file with the version writers) reuses its parent's CI and matrix and rebuilds only the installers; a v* tag only promotes the candidate (release.yml), fails fast on a red or missing commit; acceptance runs carry a CLI key; tools/release.ts does the whole release on the Mac (bump, CHANGELOG cut, fast gate, push, tag, Mac build + notarize in parallel with Forgejo, cached macOS acceptance, add, verify, publish with one upload of the Mac files, /Applications update, npm commands) and --rehearsal; DMG notarization runs while the app and updater bundle are checked; rehearsal channel on the VDS; docs
This commit is contained in:
parent
89d94d6993
commit
c3b1e8c370
14 changed files with 1438 additions and 526 deletions
|
|
@ -16,9 +16,12 @@
|
|||
// 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] [--yes]
|
||||
// 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).
|
||||
// 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]
|
||||
|
|
@ -40,7 +43,7 @@ import { type Download, type DownloadKey, type Manifest, manifestProblems } from
|
|||
import { verify as verifySig } from '../cli/lib/minisign';
|
||||
import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
|
||||
import { versions } from './desktop-version';
|
||||
import { upsert as forgejoUpsert } from './forgejo-release';
|
||||
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';
|
||||
|
||||
|
|
@ -165,6 +168,19 @@ function run(cmd: string[], opts: SpawnSyncOptions & { quiet?: boolean; allowFai
|
|||
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;
|
||||
|
|
@ -442,27 +458,25 @@ async function mac(argv: string[]): Promise<void> {
|
|||
{ 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)');
|
||||
say(`tauri bundle --target universal-apple-darwin --bundles app (${signed ? `sign${notarized ? ', notarize, staple' : ''}` : 'ad-hoc sign'})`);
|
||||
const tbs = spawnSync('bunx', ['tauri', 'bundle', '--target', 'universal-apple-darwin', '--bundles', 'app', '--ci', '--config', overrideFile],
|
||||
{ cwd: APP_DIR, env: env as Record<string, string>, stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
if (tbs.status !== 0) fail(`tauri bundle (signing) failed (exit ${tbs.status})`);
|
||||
// the frontend tauri just embedded (apps/desktop/dist): it must draw under the release CSP and the frozen
|
||||
// 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('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';
|
||||
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(', ')}`);
|
||||
|
||||
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') };
|
||||
|
||||
// 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);
|
||||
|
|
@ -479,18 +493,38 @@ async function mac(argv: string[]): Promise<void> {
|
|||
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), 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() || n.out.trim() || `notarytool exited ${n.code} without an answer (killed or timed out): run again; xcrun notarytool history shows the submission`)}`);
|
||||
run(['xcrun', 'stapler', 'staple', dmg]);
|
||||
run(['xcrun', 'stapler', 'validate', dmg]);
|
||||
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]);
|
||||
|
|
@ -512,18 +546,12 @@ async function mac(argv: string[]): Promise<void> {
|
|||
dmgStapler = 'ticket stapled, validated';
|
||||
}
|
||||
say(`DMG: spctl -t open ${dmgSpctl}; ${dmgStapler}`);
|
||||
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 });
|
||||
|
||||
// privacy: the app folder, the DMG (mounted) and the updater bundle (unpacked)
|
||||
const privacy = privacyGate([app, dmg, updater]);
|
||||
// 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, latest.json, SHA256SUMS
|
||||
const updaterSig = signFile(updater);
|
||||
// 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');
|
||||
|
|
@ -684,27 +712,78 @@ export function publishSteps(o: { host: string; v: string; channel: string; out:
|
|||
];
|
||||
}
|
||||
|
||||
function publish(argv: string[]): void {
|
||||
/** 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 (!['stable', 'beta'].includes(channel)) fail('--channel stable or beta');
|
||||
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 });
|
||||
console.log(`\npublish storebound desktop ${v} to the ${channel} channel (${channel === 'stable' ? `${BASE_URL}/latest.json` : `${BASE_URL}/beta/latest.json`}):`);
|
||||
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; }
|
||||
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' });
|
||||
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.`);
|
||||
|
|
@ -741,7 +820,7 @@ if (import.meta.main) {
|
|||
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') publish(argv.slice(1));
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue