From 86cd79dd45cb9809054b64227aa7c5f67456b25c Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 13 Oct 2026 05:08:01 +0800 Subject: [PATCH] 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 --- docs/desktop-release.md | 7 ++++++- tests/desktop-release.test.ts | 11 ++++++++-- tools/desktop-release.ts | 39 ++++++++++++++++++++++++++++++----- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/docs/desktop-release.md b/docs/desktop-release.md index 256a9d2..f6ccd0b 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -163,11 +163,16 @@ Then on the Mac: ```sh bun tools/desktop-release.ts add --out ~/.storebound-releases/desktop/0.1.0 \ + --commit --run \ storebound_0.1.0_amd64.AppImage storebound_0.1.0_amd64.deb storebound_0.1.0_x64-setup.exe ``` It scans and signs each file, renames the unsigned Windows installer to `-unsigned`, and updates latest.json -(`windows-x86_64`, `linux-x86_64`) and SHA256SUMS. For the scan, install `squashfs` and `sevenzip` from Homebrew. +(`windows-x86_64`, `linux-x86_64`) and SHA256SUMS. It writes `evidence-linux-.json` and +`evidence-windows-.json` (run, commit, sizes, hashes, the scan; they stay local like the macOS one) and +refuses a `--commit` other than the one in the folder's macOS evidence: every artifact of a version comes from one +commit. Before `add`, check the downloads against the run: the Linux artifact carries SHA256SUMS; for the Windows +installer compare with `Get-FileHash` of `C:\storebound-target\release\bundle\nsis\` on the runner. For the scan, install `squashfs` and `sevenzip` from Homebrew. ### Runner setup diff --git a/tests/desktop-release.test.ts b/tests/desktop-release.test.ts index 84b1545..f6662ee 100644 --- a/tests/desktop-release.test.ts +++ b/tests/desktop-release.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test'; import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable, archProblems } from '../tools/desktop-release'; +import { artifactName, developerIdIdentity, mergeManifest, pathFreeEnv, releaseUrl, remoteSessionReason, sliceProblems, sumsText, uploadable, archProblems, commitProblems } from '../tools/desktop-release'; import { COPIES, setVersion, versions } from '../tools/desktop-version'; describe('desktop version authority', () => { @@ -83,7 +83,14 @@ describe('desktop release', () => { expect(sumsText([{ file: 'b', sha256: '2' }, { file: 'a', sha256: '1' }])).toBe('1 a\n2 b\n'); expect(uploadable('storebound_0.1.0_universal-unsigned.dmg')).toBe(true); expect(uploadable('latest.json')).toBe(true); - for (const n of ['evidence-macos-0.1.0.json', '.DS_Store', 'latest.json.part', 'a b.dmg']) expect(uploadable(n)).toBe(false); + for (const n of ['evidence-macos-0.1.0.json', 'evidence-linux-0.1.0.json', '.DS_Store', 'latest.json.part', 'a b.dmg']) expect(uploadable(n)).toBe(false); + }); + test('add: every artifact of a version comes from one commit', () => { + const a = 'a'.repeat(40), b = 'b'.repeat(40); + expect(commitProblems(a, { 'evidence-macos-0.1.0.json': a, 'evidence-linux-0.1.0.json': undefined })).toEqual([]); + expect(commitProblems(undefined, { 'evidence-macos-0.1.0.json': a })).toEqual([]); + expect(commitProblems(b, { 'evidence-macos-0.1.0.json': a })[0]).toContain('evidence-macos-0.1.0.json says commit aaaaaaaaaa'); + expect(commitProblems('25258a8', {})[0]).toContain('40-character'); }); }); diff --git a/tools/desktop-release.ts b/tools/desktop-release.ts index 2872790..0b3be37 100644 --- a/tools/desktop-release.ts +++ b/tools/desktop-release.ts @@ -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 [--code-signed] +// bun tools/desktop-release.ts add --out [--commit ] [--run ] [--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--.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 --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[] { + 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 '); 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> = {}; const added: string[] = []; + const sources: Record = {}; 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)}` : ''}`); } // ---------------------------------------------------------------------------------------------------------------------