release candidates: draft assets are read by their attachment uuid (the tag URL is 404 for drafts: the record job could not merge SHA256SUMS); the installers are scanned with the private terms in their build jobs, so the record job needs no apt tools (it spent 5 minutes installing them)

This commit is contained in:
CRTkafa 2026-10-15 01:18:55 +08:00
parent cdaecc2afb
commit 6873f2cc68
5 changed files with 34 additions and 23 deletions

View file

@ -6,7 +6,7 @@
# tarball with real piped stdin, Linux and Windows x Node 20 / Node 22 / Bun.
# linux AppImage + deb (path-free build), self-tests, both started under Xvfb, privacy scan.
# windows NSIS installer, silent per-user install, self-test, uninstall, the Node prompt smoke, privacy scan.
# record when all are green: the draft release rc-<sha> with the installers, the acceptance runs and matrix and
# record when all are green (the installers already scanned with the private terms): the draft release rc-<sha> with the installers, the acceptance runs and matrix and
# green.json (tools/release-candidate.ts record). A v* tag on this commit only promotes it (release.yml).
# gate decides first: a commit that differs from its green parent only in version copies + CHANGELOG (a release's
# version bump, proven file by file: tools/release-candidate.ts gate) reuses the parent's ci and acceptance results
@ -200,8 +200,14 @@ jobs:
}
smoke AppImage "$GITHUB_WORKSPACE/out/storebound_${V}_amd64.AppImage"
smoke deb /tmp/deb/usr/bin/storebound-desktop
- name: Privacy scan
run: bun tools/privacy-scan.ts out/
- name: Privacy scan (with the private terms; never printed)
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
[ -n "$FORBIDDEN_TERMS" ] || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
rc=0; STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts out/ || rc=$?
rm -f "$RUNNER_TEMP/terms.txt"; exit $rc
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-linux
@ -254,9 +260,19 @@ jobs:
& $exe --self-test | Tee-Object -Variable st
if ($LASTEXITCODE -ne 0) { throw "self-test failed" }
Start-Process -Wait -FilePath "$env:LOCALAPPDATA\storebound\uninstall.exe" -ArgumentList '/S'
- name: Privacy scan (no .pdb, no paths)
- name: Privacy scan (no .pdb, no paths; with the private terms, never printed)
shell: pwsh
run: bun tools/privacy-scan.ts out
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
if (-not $env:FORBIDDEN_TERMS) { throw "secret FORBIDDEN_TERMS is missing" }
$t = Join-Path $env:RUNNER_TEMP "terms-$([guid]::NewGuid()).txt"
Set-Content -Path $t -Value $env:FORBIDDEN_TERMS -NoNewline:$false
$env:STOREBOUND_FORBIDDEN_TERMS = $t
bun tools/privacy-scan.ts out
$rc = $LASTEXITCODE
Remove-Item -Force $t
if ($rc -ne 0) { throw "privacy scan failed" }
- uses: actions/upload-artifact@v3
with:
name: storebound-desktop-windows
@ -278,10 +294,9 @@ jobs:
FROM: ${{ needs.gate.outputs.from }}
steps:
- uses: actions/checkout@v4
# no apt: the installers were scanned with the private terms in their own jobs; this job only records
- name: Tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends squashfs-tools p7zip-full xz-utils binutils unzip file >/dev/null
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
@ -308,15 +323,8 @@ jobs:
ls cand/acceptance
V=$(bun tools/desktop-version.ts --print)
bun tools/acceptance.ts report cand/acceptance --md "cand/storebound_${V}_acceptance.md" --expect 6 --strict
- name: Privacy scan (private terms) of the installers
env:
FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
run: |
[ -n "$FORBIDDEN_TERMS" ] || { echo "secret FORBIDDEN_TERMS is missing" >&2; exit 1; }
umask 077; printf '%s\n' "$FORBIDDEN_TERMS" > "$RUNNER_TEMP/terms.txt"
rm -f cand/SHA256SUMS
STOREBOUND_FORBIDDEN_TERMS="$RUNNER_TEMP/terms.txt" bun tools/privacy-scan.ts cand/*.AppImage cand/*.deb cand/*.exe
rm -f "$RUNNER_TEMP/terms.txt"
- name: Checksums file of the build jobs is not shipped as is
run: rm -f cand/SHA256SUMS
- name: Record the candidate (draft rc-<sha>, green.json last)
run: |
bun tools/release-candidate.ts record --sha "$SHA" --run "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_NUMBER" --dir cand ${FROM:+--reused-from "$FROM"} --yes

View file

@ -15,7 +15,7 @@
# Not here: macOS. tools/release.ts on the owner's Mac builds, signs and notarizes the DMG in parallel and adds it to
# this release, then publishes storebound.dev (docs/desktop-release.md). The updater signing key never reaches a runner.
# Secrets: RELEASE_TOKEN (scope write:repository), FORGEJO_ORIGIN (the server's IP: uploads over 100 MB bypass
# Cloudflare). The private terms scan of the installers ran in ci.yml's record job (FORBIDDEN_TERMS).
# Cloudflare). The private terms scan of the installers ran in ci.yml's build jobs (FORBIDDEN_TERMS).
name: release
on:

View file

@ -56,7 +56,7 @@ tested twice: every push to main is already a release candidate.
cached, so only changed crates compile);
- `windows`: the NSIS installer, silent per-user install, self-test, uninstall, the Node prompt smoke, privacy scan.
When all are green, the `record` job scans the installers with the private terms (secret `FORBIDDEN_TERMS`), builds
The build jobs scan the installers with the private terms (secret `FORBIDDEN_TERMS`). When all are green, the `record` job builds
the 6-run acceptance matrix, and stores everything as the **draft release `rc-<full sha>`** on git.crt.fyi:
installers, `SHA256SUMS`, the acceptance run files, `storebound_<version>_acceptance.md` and `green.json` (sha,
version, CLI key, run link, every file's SHA-256). `green.json` is uploaded last: a candidate without it is not

View file

@ -101,7 +101,10 @@ export function mergeSums(existing: string, add: { file: string; sha256: string
return [...m].sort((a, b) => a[0].localeCompare(b[0])).map(([f, h]) => `${h} ${f}`).join('\n') + '\n';
}
export interface Release { id: number; tag_name: string; name: string; draft: boolean; prerelease: boolean; body: string; assets: { id: number; name: string; size: number; browser_download_url: string }[] }
export interface Release { id: number; tag_name: string; name: string; draft: boolean; prerelease: boolean; body: string; assets: { id: number; name: string; size: number; uuid?: string; browser_download_url: string }[] }
/** Where an asset downloads: drafts (release candidates) only by their attachment uuid; the tag URL is 404 there. */
export const assetUrl = (a: { uuid?: string; browser_download_url: string }): string => (a.uuid ? `${(process.env.FORGEJO_URL ?? "https://git.crt.fyi").replace(/\/+$/, "")}/attachments/${a.uuid}` : a.browser_download_url);
/** Release entries `prune --keep <tag>` deletes: every other entry, the kept one must exist and be published.
* Release candidates (rc-<sha> drafts of ci.yml, tools/release-candidate.ts) are not entries: they have their own
@ -241,7 +244,7 @@ export async function upsert(o: UpsertOptions): Promise<Release | null> {
const files = o.files.filter(f => basename(f) !== SUMS);
let rel = (await listReleases()).find(r => r.tag_name === o.tag) ?? null;
const sumsBefore = rel?.assets.find(a => a.name === SUMS);
const existingSums = sumsBefore ? await api<string>('GET', sumsBefore.browser_download_url, undefined, true) : '';
const existingSums = sumsBefore ? await api<string>('GET', assetUrl(sumsBefore), undefined, true) : '';
const sums = mergeSums(existingSums, files.map(f => ({ file: basename(f), sha256: sha256(f) })));
console.log(`${rel ? `update release ${rel.id}` : 'create release'} ${o.tag}${o.draft ? ' (draft)' : ''}: ${o.title ?? rel?.name ?? o.tag}`);
for (const f of files) console.log(` ${rel?.assets.some(a => a.name === basename(f)) ? 'replace' : 'attach '} ${basename(f)} (${(statSync(f).size / 1e6).toFixed(1)} MB)`);

View file

@ -35,7 +35,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSyn
import { basename, join, resolve } from 'node:path';
import { cliKey } from './cli-key';
import { COPIES, SEMVER } from './desktop-version';
import { api, BASE, candidateTag, deleteTest, downloadAsset, isCandidateTag, listReleases, type Release, REPO, shortNotes, SUMS, upsert } from './forgejo-release';
import { api, assetUrl, BASE, candidateTag, deleteTest, downloadAsset, isCandidateTag, listReleases, type Release, REPO, shortNotes, SUMS, upsert } from './forgejo-release';
const ROOT = resolve(import.meta.dir, '..');
export const GREEN = 'green.json';
@ -147,7 +147,7 @@ async function tasks(): Promise<Task[]> {
async function record(release: Release): Promise<GreenRecord | null> {
const a = release.assets.find(x => x.name === GREEN);
if (!a) return null;
return JSON.parse(await api<string>('GET', a.browser_download_url, undefined, true)) as GreenRecord;
return JSON.parse(await api<string>('GET', assetUrl(a), undefined, true)) as GreenRecord;
}
export async function status(sha: string): Promise<Status> {
@ -180,7 +180,7 @@ export async function fetchCandidate(s: Extract<Status, { state: 'green' }>, dir
await Promise.all(assets.map(async a => {
const dest = join(dir, a.name);
if (existsSync(dest) && s.record.files[a.name] && sha256(dest) === s.record.files[a.name]) { got.push(dest); return; }
await downloadAsset(a.browser_download_url, dest);
await downloadAsset(assetUrl(a), dest);
const want = s.record.files[a.name];
if (want && sha256(dest) !== want) fail(`${a.name}: SHA-256 differs from the candidate's record`);
got.push(dest);