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

@ -163,11 +163,16 @@ Then on the Mac:
```sh
bun tools/desktop-release.ts add --out ~/.storebound-releases/desktop/0.1.0 \
--commit <the run's full commit hash> --run <the run's URL> \
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-<version>.json` and
`evidence-windows-<version>.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

View file

@ -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');
});
});

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)}` : ''}`);
}
// ---------------------------------------------------------------------------------------------------------------------