diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 9c3590792..b88d6a20d 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -408,6 +408,10 @@ jobs: - name: Generate SHA256SUMS # Covers the POSIX tarballs AND the win32 zip: winget, Chocolatey, # and the npx launcher all verify against this one checksum file. + # Those are the only payload assets that exist this early - the snap, + # .deb/.rpm/.AppImage and SBOM assets are built by later jobs, so + # publish-release widens this file over the whole draft asset set + # (issue #913) before the release is published. run: | cd dist sha256sum libredb-studio-standalone-*.tar.gz libredb-studio-standalone-*.zip > SHA256SUMS @@ -1121,6 +1125,37 @@ jobs: permissions: contents: write steps: + # The widening step below runs a repo script, so the job needs the tree. + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + + # Close the checksum gap (issue #913). The publish job can only cover the + # tarballs and the win32 zip, because nothing else exists when it runs; + # by now every payload asset is on the draft, so rebuild the one combined + # file over the whole set - the two snaps and the CycloneDX SBOM were the + # assets left with no checksum of any kind. This has to happen before the + # publish below: from there the asset set is frozen (issue #154), and + # dist/SHA256SUMS is the file the widening rewrites. The script reads + # each asset's recorded sha256 from the releases API and refuses to emit + # a file in which an entry the release already carried has changed, so + # the npx launcher, Homebrew and Chocolatey/winget lookups stay valid - + # a rebuilt file only ever widens. + - name: Widen SHA256SUMS to cover every payload asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.guard.outputs.version }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" > /tmp/release.json + mkdir -p dist + gh release download "$TAG" --pattern SHA256SUMS --dir dist --clobber --repo "$GITHUB_REPOSITORY" + node scripts/release-sums.mjs /tmp/release.json \ + --existing dist/SHA256SUMS --allow-download > dist/SHA256SUMS.new + mv dist/SHA256SUMS.new dist/SHA256SUMS + cat dist/SHA256SUMS + gh release upload "$TAG" dist/SHA256SUMS --clobber --repo "$GITHUB_REPOSITORY" + - name: Verify the draft carries the full asset set env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 79bdaf3ad..f8b98e519 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -196,15 +196,19 @@ Release tags carry **no `v` prefix** (tag `0.9.41` == package.json version). Eac |---|---|---| | Standalone server tarball | `libredb-studio-standalone---.tar.gz` | `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64` | | Standalone server zip (Windows) | `libredb-studio-standalone--win32-x64.zip` | `win32-x64` (bundled Node runtime + `libredb-studio.exe` launcher) | -| Checksums | `SHA256SUMS` | covers all standalone tarballs and the win32 zip | +| Checksums | `SHA256SUMS` | covers every payload asset except the `.sha256` sidecars | | Debian package | `libredb-studio__.deb` (+ `.sha256` sidecar) | `amd64`, `arm64` | | RPM package | `libredb-studio-..rpm` (+ `.sha256` sidecar) | `x86_64`, `aarch64` | | Snap | `libredb-studio__.snap` | `amd64`, `arm64` (also published to the Snap Store) | | Desktop AppImage | `libredb-studio-desktop--linux-.AppImage` (+ `.sha256` sidecar) | `x64`, `arm64` (also the artifact the in-repo Flatpak manifest repacks) | | Desktop Debian package | `libredb-studio-desktop__.deb` (+ `.sha256` sidecar) | `amd64`, `arm64` (from 0.9.62; the artifact FlatPark pins as extra-data) | -`SHA256SUMS` covers the standalone tarballs and the win32 zip; each `.deb`/`.rpm`/`.AppImage` -ships its own per-file `.sha256` sidecar instead (those are built in separate jobs). +`SHA256SUMS` covers every payload asset in the release — the standalone tarballs, the win32 zip, +both `.snap` files and the CycloneDX SBOM — so one file answers "what is the hash of what I just +downloaded?", whichever artifact that was. The `.deb`/`.rpm` packages and the desktop AppImage +additionally ship a per-file `.sha256` sidecar, written by the job that builds them; the +combined file covers them too, so a reader no longer has to know which of the two mechanisms +applies to the artifact they picked. **Two different `.deb`s ship per release and they are not interchangeable.** `libredb-studio__.deb` is the headless server: it installs a systemd unit and is @@ -1281,11 +1285,13 @@ All channels have now had their first live run (the Snap publish completed its f ### Artifact provenance roadmap -Standalone tarballs and `.deb`/`.rpm` packages are checksum-verified against `SHA256SUMS` / -per-package `.sha256` sidecars (see [Release artifact naming](#release-artifact-naming)) — both -from the same GitHub release. That pairing detects corruption, not substitution: whoever can -replace an asset can replace its checksum line too. The `.snap` release asset ships no sidecar -(`--dangerous` installs skip the Snap Store's own verification). +Every payload asset is checksum-verified against `SHA256SUMS`, and the `.deb`/`.rpm` packages and +the desktop AppImage ship their own `.sha256` sidecar as well +(see [Release artifact naming](#release-artifact-naming)) — all from the same GitHub release. That +pairing detects corruption, not substitution: whoever can replace an asset can replace its +checksum line too, which is what the signed provenance below is for. The `.snap` assets are covered +by `SHA256SUMS` like every other payload asset, which matters for `snap install --dangerous`: the +Snap Store's own verification is skipped there, so the release checksum is the only one a user has. Signed provenance moves the trust root out of the release — the signer is the workflow's GitHub OIDC identity (repo + workflow + commit), recorded in a public transparency log. All three steps of diff --git a/scripts/release-sums.mjs b/scripts/release-sums.mjs new file mode 100644 index 000000000..7c6032423 --- /dev/null +++ b/scripts/release-sums.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/** + * Rebuild the release's combined SHA256SUMS so it covers every payload asset + * (issue #913). + * + * Why not in the `publish` job: it runs before the snap, SBOM and desktop jobs + * have attached anything, so the file it writes can only cover the standalone + * tarballs and the win32 zip. The .deb/.rpm/.AppImage assets were given + * per-file `.sha256` sidecars as a workaround, and the two snaps and the + * CycloneDX SBOM ended up with neither - so a reader who downloads a .snap + * from the release page has nothing to check it against. + * + * This runs in `publish-release`, immediately before the draft is published, + * when every payload asset exists and the asset set can still be amended. + * + * - every payload asset is covered (anything that is not SHA256SUMS itself + * and not a `.sha256` sidecar); + * - the digests come from the releases API, which records the sha256 of the + * bytes as stored - hashing a fresh download would be a second opinion on + * the same bytes, at the cost of pulling ~1.5 GB in the last job of the + * release chain. An asset with no digest recorded is downloaded and hashed + * locally instead; + * - every entry the file already carried must survive with the same hash. + * The npx launcher, the Homebrew formula and the Chocolatey/winget + * renderers all read this file, so the rebuild may only widen it; + * - the `sha256sum` output format is preserved (hash, two spaces, bare file + * name, newline) and names are sorted, which is the order the `publish` + * job's glob already produced. + * + * Usage: + * gh api "repos///releases/tags/" > release.json + * gh release download --pattern SHA256SUMS --dir dist + * node scripts/release-sums.mjs release.json --existing dist/SHA256SUMS > SHA256SUMS + * + * --existing the SHA256SUMS already on the release; its entries are + * asserted to survive unchanged + * --allow-download hash an asset locally when the API records no digest for + * it (without this flag that is an error) + */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +const SUMS_NAME = "SHA256SUMS"; +const SIDECAR_SUFFIX = ".sha256"; +const DIGEST_PREFIX = "sha256:"; +const HEX64 = /^[0-9a-f]{64}$/; +const SUMS_LINE = /^([0-9a-f]{64}) {2}(.+)$/; + +/** Payload assets are everything a user downloads and runs. */ +export function isPayloadAsset(name) { + return name !== SUMS_NAME && !name.endsWith(SIDECAR_SUFFIX); +} + +/** The sha256 the releases API records for an asset, or null when it has none. */ +export function assetSha256(asset) { + const digest = typeof asset?.digest === "string" ? asset.digest : ""; + if (!digest.startsWith(DIGEST_PREFIX)) return null; + const hex = digest.slice(DIGEST_PREFIX.length); + return HEX64.test(hex) ? hex : null; +} + +/** One `sha256sum` output line. Bare file name: consumers look entries up by name. */ +export function formatSum(hash, name) { + return `${hash} ${name}\n`; +} + +/** Payload assets, sorted by name - the order the release's own glob produces. */ +export function payloadAssets(assets) { + const payload = (assets ?? []).filter((asset) => isPayloadAsset(asset?.name)); + const names = new Set(); + for (const asset of payload) { + if (names.has(asset.name)) throw new Error(`Release asset list carries '${asset.name}' twice`); + names.add(asset.name); + } + return payload.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} + +export function parseSums(text) { + const entries = new Map(); + for (const line of (text ?? "").split("\n")) { + if (line.trim() === "") continue; + const match = SUMS_LINE.exec(line); + if (!match) throw new Error(`Malformed SHA256SUMS line: ${JSON.stringify(line)}`); + entries.set(match[2], match[1]); + } + return entries; +} + +/** Hash a fetch Response body. */ +export async function hashResponse(response) { + if (!response.ok) throw new Error(`Cannot download the asset: HTTP ${response.status}`); + const hash = crypto.createHash("sha256"); + for await (const chunk of Readable.fromWeb(response.body)) hash.update(chunk); + return hash.digest("hex"); +} + +export async function hashUrl(url, fetchImpl = fetch) { + return hashResponse(await fetchImpl(url)); +} + +/** + * Rebuild the combined checksum file from a release's asset list. + * Throws rather than emitting a partial file: this runs on a draft that is + * about to become immutable, so a wrong file is permanent. + */ +export async function buildSums(assets, { allowDownload = false, fetchImpl = fetch } = {}) { + const payload = payloadAssets(assets); + if (payload.length === 0) throw new Error("Release asset list has no payload assets"); + + let sums = ""; + for (const asset of payload) { + let hash = assetSha256(asset); + if (hash === null) { + if (!allowDownload) { + throw new Error( + `Release asset '${asset.name}' has no sha256 digest recorded - re-run with --allow-download to hash it locally`, + ); + } + if (!asset.browser_download_url) { + throw new Error(`Release asset '${asset.name}' has no digest and no download URL`); + } + hash = await hashUrl(asset.browser_download_url, fetchImpl); + } + sums += formatSum(hash, asset.name); + } + return sums; +} + +/** Every entry the previous file carried must survive, unchanged. */ +export function assertSuperset(existingText, rebuiltText) { + const existing = parseSums(existingText); + const rebuilt = parseSums(rebuiltText); + for (const [name, hash] of existing) { + const current = rebuilt.get(name); + if (current === undefined) { + throw new Error(`Rebuilt SHA256SUMS dropped the existing entry for '${name}'`); + } + if (current !== hash) { + throw new Error(`Rebuilt SHA256SUMS changed the hash for '${name}' (was ${hash}, now ${current})`); + } + } +} + +function parseArgs(argv) { + const args = { releasePath: "", existingPath: "", allowDownload: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--allow-download") { + args.allowDownload = true; + } else if (arg === "--existing") { + args.existingPath = argv[i + 1] ?? ""; + if (args.existingPath === "") throw new Error("--existing needs a path"); + i += 1; + } else if (arg.startsWith("--")) { + throw new Error(`Unknown argument: ${arg}`); + } else if (args.releasePath === "") { + args.releasePath = arg; + } else { + throw new Error(`Unexpected argument: ${arg}`); + } + } + if (args.releasePath === "") { + throw new Error("Usage: node scripts/release-sums.mjs [--existing ] [--allow-download]"); + } + return args; +} + +async function main(argv) { + const args = parseArgs(argv); + const release = JSON.parse(fs.readFileSync(args.releasePath, "utf8")); + const rebuilt = await buildSums(release.assets, { allowDownload: args.allowDownload }); + if (args.existingPath !== "") { + assertSuperset(fs.readFileSync(args.existingPath, "utf8"), rebuilt); + } + process.stdout.write(rebuilt); + console.error(`Rebuilt SHA256SUMS over ${rebuilt.trimEnd().split("\n").length} payload assets`); +} + +// CLI entry only when executed directly (the unit test imports this module). +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch((error) => { + console.error(`release-sums: ${error.message}`); + process.exit(1); + }); +} diff --git a/tests/unit/release-sums.test.ts b/tests/unit/release-sums.test.ts new file mode 100644 index 000000000..dfe2df4c3 --- /dev/null +++ b/tests/unit/release-sums.test.ts @@ -0,0 +1,278 @@ +/** + * Unit tests for the release checksum rebuild + * (scripts/release-sums.mjs, issue #913). + * + * Why a test for this one: SHA256SUMS is what a user checks a downloaded + * artifact against, and it is rebuilt on a draft release that is about to + * become immutable - a file that silently drops or changes an entry would be + * permanent and would invalidate the checksums the npx launcher, the Homebrew + * formula and the Chocolatey/winget renderers are already pinned to. The + * no-network paths and the superset assertion are what keep that from + * happening; the CLI is exercised as a child process in the last block so the + * argument handling and the exit codes are covered too. + */ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + assertSuperset, + assetSha256, + buildSums, + formatSum, + hashResponse, + hashUrl, + isPayloadAsset, + parseSums, + payloadAssets, +} from "../../scripts/release-sums.mjs"; + +const SCRIPT = path.join(__dirname, "../../scripts/release-sums.mjs"); +const VERSION = "0.16.0"; +const HEX_A = "a".repeat(64); +const HEX_B = "b".repeat(64); + +function asset(name: string, digest: string | null = HEX_A, url = `https://example.test/${name}`) { + return { name, digest: digest === null ? null : `sha256:${digest}`, browser_download_url: url }; +} + +/** A release asset list shaped like `gh api releases/tags/`. */ +function releaseFixture() { + const assets = [ + asset(`libredb-studio-standalone-${VERSION}-linux-x64.tar.gz`), + asset(`libredb-studio-standalone-${VERSION}-win32-x64.zip`), + asset(`libredb-studio_${VERSION}_amd64.deb`), + asset(`libredb-studio_${VERSION}_amd64.deb.sha256`), + asset(`libredb-studio_${VERSION}_amd64.snap`), + asset(`libredb-studio-${VERSION}.cdx.json`), + asset("SHA256SUMS"), + ]; + return { tag_name: VERSION, assets }; +} + +describe("payload asset selection", () => { + test("treats everything except SHA256SUMS and the .sha256 sidecars as payload", () => { + expect(isPayloadAsset("libredb-studio_0.16.0_amd64.snap")).toBe(true); + expect(isPayloadAsset("libredb-studio-0.16.0.cdx.json")).toBe(true); + expect(isPayloadAsset("libredb-studio_0.16.0_amd64.deb.sha256")).toBe(false); + expect(isPayloadAsset("SHA256SUMS")).toBe(false); + }); + + test("sorts by name, which is the order the publish job's glob already produced", () => { + const names = payloadAssets([ + asset(`libredb-studio-standalone-${VERSION}-win32-x64.zip`), + asset(`libredb-studio-standalone-${VERSION}-darwin-arm64.tar.gz`), + asset("SHA256SUMS"), + ]).map((a) => a.name); + + expect(names).toEqual([ + `libredb-studio-standalone-${VERSION}-darwin-arm64.tar.gz`, + `libredb-studio-standalone-${VERSION}-win32-x64.zip`, + ]); + }); + + test("refuses a list that carries the same asset twice", () => { + expect(() => payloadAssets([asset("a.snap"), asset("a.snap")])).toThrow(/twice/); + }); +}); + +describe("assetSha256", () => { + test("reads the digest the API records", () => { + expect(assetSha256(asset("a.snap", HEX_B))).toBe(HEX_B); + }); + + test("returns null when there is no usable digest", () => { + expect(assetSha256(asset("a.snap", null))).toBeNull(); + expect(assetSha256({ name: "a.snap" })).toBeNull(); + expect(assetSha256({ name: "a.snap", digest: HEX_A })).toBeNull(); // no sha256: prefix + expect(assetSha256({ name: "a.snap", digest: "sha256:not-hex" })).toBeNull(); + expect(assetSha256({ name: "a.snap", digest: `sha256:${"a".repeat(63)}` })).toBeNull(); + expect(assetSha256(undefined)).toBeNull(); + }); +}); + +describe("buildSums", () => { + test("covers every payload asset and leaves the sidecars out", async () => { + const sums = await buildSums(releaseFixture().assets); + + // The three assets that had no checksum of any kind before issue #913. + expect(sums).toContain(`${HEX_A} libredb-studio_${VERSION}_amd64.snap\n`); + expect(sums).toContain(`${HEX_A} libredb-studio-${VERSION}.cdx.json\n`); + expect(sums).toContain(`${HEX_A} libredb-studio_${VERSION}_amd64.deb\n`); + expect(sums).not.toContain(".sha256\n"); + expect(sums).not.toContain("SHA256SUMS"); + expect(sums.endsWith("\n")).toBe(true); + }); + + test("emits sha256sum's exact line format, sorted, and nothing else", async () => { + const sums = await buildSums(releaseFixture().assets); + const lines = sums.trimEnd().split("\n"); + + expect(lines).toEqual([...lines].sort()); + for (const line of lines) { + expect(line).toMatch(/^[0-9a-f]{64} {2}[^/]+$/); + } + expect(lines.length).toBe(5); + }); + + test("hashes locally when an asset has no digest and downloads are allowed", async () => { + const body = "snap payload"; + const sums = await buildSums([asset("a.snap", null)], { + allowDownload: true, + fetchImpl: async () => new Response(body), + }); + + expect(sums).toBe(`${crypto.createHash("sha256").update(body).digest("hex")} a.snap\n`); + }); + + test("refuses to hash locally unless --allow-download was given", async () => { + await expect(buildSums([asset("a.snap", null)])).rejects.toThrow( + /'a\.snap' has no sha256 digest recorded - re-run with --allow-download/, + ); + }); + + test("rejects an asset that has neither a digest nor a download URL", async () => { + await expect(buildSums([{ name: "a.snap" }], { allowDownload: true })).rejects.toThrow( + /no digest and no download URL/, + ); + }); + + test("rejects an empty payload set rather than writing an empty file", async () => { + await expect(buildSums([asset("SHA256SUMS")])).rejects.toThrow(/no payload assets/); + }); +}); + +describe("hashResponse / hashUrl", () => { + test("hashes a downloaded asset", async () => { + expect(await hashResponse(new Response("payload"))).toBe( + crypto.createHash("sha256").update("payload").digest("hex"), + ); + }); + + test("fails on a non-2xx download instead of hashing an error page", async () => { + await expect(hashResponse(new Response("nope", { status: 404 }))).rejects.toThrow(/HTTP 404/); + }); + + test("fetches the asset's own URL", async () => { + let requested: string | undefined; + const hash = await hashUrl("https://example.test/a.snap", async (url) => { + requested = url; + return new Response("payload"); + }); + + expect(requested).toBe("https://example.test/a.snap"); + expect(hash).toBe(crypto.createHash("sha256").update("payload").digest("hex")); + }); +}); + +describe("assertSuperset", () => { + const before = `${HEX_A} libredb-studio-standalone-${VERSION}-linux-x64.tar.gz\n`; + + test("accepts a widened file", () => { + expect(() => assertSuperset(before, before + `${HEX_B} libredb-studio_${VERSION}_amd64.snap\n`)).not.toThrow(); + }); + + test("rejects a dropped entry", () => { + expect(() => assertSuperset(before, `${HEX_B} other.snap\n`)).toThrow(/dropped the existing entry/); + }); + + test("rejects a changed hash for an entry that is still listed", () => { + expect(() => assertSuperset(before, `${HEX_B} libredb-studio-standalone-${VERSION}-linux-x64.tar.gz\n`)).toThrow( + /changed the hash for 'libredb-studio-standalone-0\.16\.0-linux-x64\.tar\.gz'/, + ); + }); +}); + +describe("parseSums", () => { + test("reads entries and skips blank lines", () => { + const entries = parseSums(`${HEX_A} a.tar.gz\n\n${HEX_B} b.snap\n`); + + expect(entries.get("a.tar.gz")).toBe(HEX_A); + expect(entries.get("b.snap")).toBe(HEX_B); + expect(entries.size).toBe(2); + expect(parseSums(undefined).size).toBe(0); + }); + + test("rejects a line it cannot read rather than ignoring it", () => { + expect(() => parseSums("not a checksum line\n")).toThrow(/Malformed SHA256SUMS line/); + }); + + test("formats a line the way sha256sum does", () => { + expect(formatSum(HEX_A, "a.snap")).toBe(`${HEX_A} a.snap\n`); + }); +}); + +describe("the CLI", () => { + function run(args: string[]) { + return spawnSync("node", [SCRIPT, ...args], { encoding: "utf8" }); + } + + function fixtureDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "release-sums-")); + const releasePath = path.join(dir, "release.json"); + fs.writeFileSync(releasePath, JSON.stringify(releaseFixture())); + const existingPath = path.join(dir, "SHA256SUMS"); + fs.writeFileSync( + existingPath, + `${HEX_A} libredb-studio-standalone-${VERSION}-linux-x64.tar.gz\n` + + `${HEX_A} libredb-studio-standalone-${VERSION}-win32-x64.zip\n`, + ); + return { dir, releasePath, existingPath }; + } + + test("rebuilds the file and keeps the entries it already carried", () => { + const { releasePath, existingPath } = fixtureDir(); + const result = run([releasePath, "--existing", existingPath]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(`${HEX_A} libredb-studio_${VERSION}_amd64.snap\n`); + expect(result.stderr).toContain("Rebuilt SHA256SUMS over 5 payload assets"); + }); + + test("fails when an entry the release already carried would change", () => { + const { releasePath, existingPath } = fixtureDir(); + fs.writeFileSync(existingPath, `${HEX_B} libredb-studio-${VERSION}.cdx.json\n`); + const result = run([releasePath, "--existing", existingPath]); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("changed the hash for 'libredb-studio-0.16.0.cdx.json'"); + }); + + test("fails on a missing digest rather than writing a partial file", () => { + const { dir, releasePath } = fixtureDir(); + const release = JSON.parse(fs.readFileSync(releasePath, "utf8")); + release.assets[0].digest = null; + fs.writeFileSync(releasePath, JSON.stringify(release)); + const result = run([releasePath]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(`'${release.assets[0].name}' has no sha256 digest recorded`); + expect(fs.readdirSync(dir).sort()).toEqual(["SHA256SUMS", "release.json"]); + }); + + test("prints the usage line when the release document is missing", () => { + const result = run([]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage: node scripts/release-sums.mjs "); + }); + + test("rejects an --existing flag with no path, an unknown flag and a stray argument", () => { + const { releasePath } = fixtureDir(); + + expect(run([releasePath, "--existing"]).stderr).toContain("--existing needs a path"); + expect(run([releasePath, "--nope"]).stderr).toContain("Unknown argument: --nope"); + expect(run([releasePath, "extra.json"]).stderr).toContain("Unexpected argument: extra.json"); + }); + + test("accepts --allow-download", () => { + const { releasePath } = fixtureDir(); + const result = run([releasePath, "--allow-download"]); + + expect(result.status).toBe(0); + expect(result.stderr).toContain("Rebuilt SHA256SUMS over 5 payload assets"); + }); +});