desktop-release add: evidence-linux / evidence-windows files (run, commit, sizes, hashes, scan) and a check that every artifact of a version comes from one commit (--commit against the folder's other evidence); docs: check the downloads against the run before add

This commit is contained in:
CRTkafa 2026-10-13 05:08:01 +08:00
parent 0b6c8cd7a0
commit 86cd79dd45
3 changed files with 49 additions and 8 deletions

View file

@ -11,9 +11,11 @@
// 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> <files...> [--code-signed]
// 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/desktop.yml) to the version
// folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS.
// 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]
// 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).
@ -538,6 +540,14 @@ Unsigned preview (ad-hoc). To ship a signed, notarized build:
// ---------------------------------------------------------------------------------------------------------------------
// 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();
@ -546,10 +556,16 @@ function add(argv: string[]): void {
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}`);
@ -559,8 +575,9 @@ function add(argv: string[]): void {
const dest = join(out, name);
copyFileSync(f, dest);
added.push(dest);
sources[name] = n;
}
privacyGate(added);
const privacy = privacyGate(added);
for (const dest of added) {
const name = basename(dest);
const sig = signFile(dest);
@ -571,8 +588,20 @@ function add(argv: string[]): void {
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(', ')}`);
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)}` : ''}`);
}
// ---------------------------------------------------------------------------------------------------------------------