diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 6966fcd..a125b4e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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- 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- 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-, 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 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 5ee278c..ee88ef0 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -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: diff --git a/docs/desktop-release.md b/docs/desktop-release.md index 05a0931..81eccac 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -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-`** on git.crt.fyi: installers, `SHA256SUMS`, the acceptance run files, `storebound__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 diff --git a/tools/forgejo-release.ts b/tools/forgejo-release.ts index 64ebec1..352e376 100644 --- a/tools/forgejo-release.ts +++ b/tools/forgejo-release.ts @@ -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 ` deletes: every other entry, the kept one must exist and be published. * Release candidates (rc- 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 { 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('GET', sumsBefore.browser_download_url, undefined, true) : ''; + const existingSums = sumsBefore ? await api('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)`); diff --git a/tools/release-candidate.ts b/tools/release-candidate.ts index 488b9e9..e955429 100644 --- a/tools/release-candidate.ts +++ b/tools/release-candidate.ts @@ -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 { async function record(release: Release): Promise { const a = release.assets.find(x => x.name === GREEN); if (!a) return null; - return JSON.parse(await api('GET', a.browser_download_url, undefined, true)) as GreenRecord; + return JSON.parse(await api('GET', assetUrl(a), undefined, true)) as GreenRecord; } export async function status(sha: string): Promise { @@ -180,7 +180,7 @@ export async function fetchCandidate(s: Extract, 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);