From 9203669d1f479eceb3de545cac4682a97af0bb6f Mon Sep 17 00:00:00 2001 From: Nelson Dominguez Date: Thu, 8 Oct 2026 10:43:26 +0200 Subject: [PATCH 1/6] feat: attest immutable releases and publish production SBOM --- .github/workflows/ci.yml | 4 +++ .github/workflows/release.yml | 49 ++++++++++++++++++++++++++++++++--- CONTRIBUTING.md | 8 +++--- README.md | 2 ++ SECURITY.md | 15 +++++++++++ test/package.test.ts | 32 +++++++++++++++++++++++ 6 files changed, 102 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b7bb7e..9785e59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,10 @@ jobs: run: | git diff --exit-code -- dist test -z "$(git ls-files --others --exclude-standard dist)" + - name: Validate production SBOM + run: | + pnpm sbom --sbom-format cyclonedx --sbom-spec-version 1.6 --prod --out "$RUNNER_TEMP/setup-zig.cdx.json" + jq -e '.bomFormat == "CycloneDX" and (.components | length >= 7)' "$RUNNER_TEMP/setup-zig.cdx.json" - run: pnpm audit --prod --audit-level moderate smoke: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db2960b..ead34c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,39 +24,80 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Validate version + - name: Validate version and release settings env: VERSION: ${{ inputs.version }} + REPOSITORY_PRIVATE: ${{ github.event.repository.private }} GH_TOKEN: ${{ github.token }} run: | [[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] test "v$(jq -r '.version' package.json)" = "$VERSION" + if [[ "$REPOSITORY_PRIVATE" != false ]]; then + echo 'Make the repository public before publishing attestations.' >&2 + exit 1 + fi + if ! gh api "repos/$GITHUB_REPOSITORY/immutable-releases" --silent; then + echo 'Enable immutable releases in repository settings first.' >&2 + exit 1 + fi if gh release view "$VERSION" >/dev/null 2>&1; then echo 'This release already exists.' >&2 exit 1 fi + test: needs: validate uses: ./.github/workflows/ci.yml with: full: true + publish: needs: test runs-on: ubuntu-24.04 - timeout-minutes: 5 + timeout-minutes: 10 permissions: contents: write + id-token: write + attestations: write + artifact-metadata: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Publish release and update the major tag + - uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3 + with: + cache: true + require-lockfile: true + - name: Prepare release assets + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + mkdir -p release + git archive --format=tar --prefix=setup-zig/ "$GITHUB_SHA" | + gzip -n > "release/setup-zig-$VERSION.tar.gz" + pnpm sbom --sbom-format cyclonedx --sbom-spec-version 1.6 --prod --out release/sbom.cdx.json + jq -e '.bomFormat == "CycloneDX" and (.components | length >= 7)' release/sbom.cdx.json + ( + cd release + sha256sum "setup-zig-$VERSION.tar.gz" sbom.cdx.json > SHA256SUMS + ) + - name: Attest release archive + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: release/setup-zig-*.tar.gz + - name: Attest production dependencies + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: release/setup-zig-*.tar.gz + sbom-path: release/sbom.cdx.json + - name: Publish immutable release and update the major tag env: VERSION: ${{ inputs.version }} GH_TOKEN: ${{ github.token }} run: | gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$GITHUB_SHA" - gh release create "$VERSION" --verify-tag --generate-notes --title "$VERSION" + gh release create "$VERSION" release/* --verify-tag --generate-notes --title "$VERSION" major="${VERSION%%.*}" if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$major" >/dev/null 2>&1; then gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$major" -f sha="$GITHUB_SHA" -F force=true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ec362e..d389955 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,8 @@ Keep the input surface small. Add a regression test for changes to version resol ## Releases -1. Update `package.json` and `pnpm-lock.yaml` to the release version. Rebuild `dist/` and merge the change. -2. Run the **Release** workflow from `main`, with a tag such as `v1.0.0`. -3. The workflow runs the full test matrix, creates the version tag and GitHub release, then moves the corresponding major tag (for example, `v1`). Existing version tags are never replaced. +1. Update the version in `package.json`. Refresh the lockfile only if dependencies change; rebuild `dist/` and merge. +2. Make the repository public, enable immutable releases and private vulnerability reporting, then run the **Release** workflow from `main` with a tag such as `v1.0.0`. +3. The workflow runs the full test matrix, generates a release archive, CycloneDX SBOM, checksums, and signed attestations, then publishes the immutable version tag and release. The matching major tag (for example, `v1`) advances to the new version. -Before the first public release, make the repository public and enable private vulnerability reporting in repository settings. Publish the action to GitHub Marketplace from the release page if desired. Release tags identify exact revisions; major tags follow compatible updates. +Verify the release and its assets as described in [Security](SECURITY.md#release-provenance). Publish the action to GitHub Marketplace from the release page if desired. Version tags are immutable; major tags follow compatible updates. diff --git a/README.md b/README.md index b98053b..6dafca8 100644 --- a/README.md +++ b/README.md @@ -93,4 +93,6 @@ Linux, macOS, and Windows are supported on x64 and arm64. Linux and Windows also The action uses Node 24 supplied by the Actions runner. Self-hosted runners need version 2.327.1 or later. Linux and macOS require `tar` with xz support; Windows requires PowerShell. No Node installation step or GitHub token input is needed. +Release archives have signed provenance, a CycloneDX SBOM, and immutable release attestations. See [release verification](SECURITY.md#release-provenance). + [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [License](LICENSE) diff --git a/SECURITY.md b/SECURITY.md index d671d86..6c2bf5d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,6 +12,21 @@ Signed archives are cached rather than extracted executables. Every restored arc The Zig release key and the action revision are trust anchors. Version metadata is fetched over HTTPS and stored within GitHub's cache scope. Pin an exact Zig version to avoid mutable version selection, and pin the action to a full commit SHA. +## Release provenance + +Versioned releases are immutable. GitHub attests the release tag, source commit, and uploaded assets. The release workflow also generates signed SLSA build provenance and a CycloneDX SBOM attestation for the attached archive. The SBOM covers production npm dependencies bundled in the action, not Zig itself or development tools. + +Verify a release and its downloaded archive with the GitHub CLI: + +```sh +gh release verify v1.0.0 -R ekkolon/setup-zig +gh release download v1.0.0 -R ekkolon/setup-zig --pattern 'setup-zig-*.tar.gz' +gh release verify-asset v1.0.0 setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig +gh attestation verify setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig +``` + +The release also provides `sbom.cdx.json` and `SHA256SUMS`. The `v1` major tag intentionally moves to compatible releases; pin a full action commit SHA or an immutable version tag when evaluating a fixed dependency. Attestations identify the publishing workflow and artifacts, but do not certify that the code is vulnerability-free or compliant with a particular standard. + ## Runner and cache trust GitHub's normal cache access rules apply. Build caches contain unsigned compiler output and packages, so treat them as workflow data. Use `cache: false` for jobs that must rebuild without restored build data. `cache-read-only` prevents this action from uploading caches; it does not make restored data trusted. diff --git a/test/package.test.ts b/test/package.test.ts index 8f56b09..61cc1e7 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -43,3 +43,35 @@ test('workflow actions are pinned and untrusted code does not receive write perm } } }); + +test('releases require immutability and attest their archive and SBOM', async () => { + const workflow = parse(await readFile('.github/workflows/release.yml', 'utf8')); + const validate = workflow.jobs.validate.steps.find( + (step: {name?: string}) => step.name === 'Validate version and release settings', + ); + assert.match(validate.run, /immutable-releases/); + assert.match(validate.run, /REPOSITORY_PRIVATE/); + + const publish = workflow.jobs.publish; + assert.equal(publish.permissions['id-token'], 'write'); + assert.equal(publish.permissions.attestations, 'write'); + assert.equal(publish.permissions['artifact-metadata'], 'write'); + + const packageStep = publish.steps.find( + (step: {name?: string}) => step.name === 'Prepare release assets', + ); + assert.match(packageStep.run, /git archive/); + assert.match(packageStep.run, /pnpm sbom[^\n]+--prod/); + assert.match(packageStep.run, /sha256sum/); + + const attestations = publish.steps.filter( + (step: {uses?: string}) => step.uses?.startsWith('actions/attest@'), + ); + assert.equal(attestations.length, 2); + assert.equal(attestations[0].with['subject-path'], 'release/setup-zig-*.tar.gz'); + assert.equal(attestations[1].with['sbom-path'], 'release/sbom.cdx.json'); + assert.match( + publish.steps.at(-1).run, + /gh release create[^\n]+release\/\*/, + ); +}); From 680876f12da25cb294e5bd1a6c79d9af1a98f67a Mon Sep 17 00:00:00 2001 From: Nelson Dominguez Date: Thu, 8 Oct 2026 10:44:53 +0200 Subject: [PATCH 2/6] test: format release assertions --- .github/workflows/release.yml | 2 +- test/package.test.ts | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ead34c1..933c35b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: echo 'Make the repository public before publishing attestations.' >&2 exit 1 fi - if ! gh api "repos/$GITHUB_REPOSITORY/immutable-releases" --silent; then + if ! gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$GITHUB_REPOSITORY/immutable-releases" --silent; then echo 'Enable immutable releases in repository settings first.' >&2 exit 1 fi diff --git a/test/package.test.ts b/test/package.test.ts index 61cc1e7..0f449ef 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -45,9 +45,12 @@ test('workflow actions are pinned and untrusted code does not receive write perm }); test('releases require immutability and attest their archive and SBOM', async () => { - const workflow = parse(await readFile('.github/workflows/release.yml', 'utf8')); + const workflow = parse( + await readFile('.github/workflows/release.yml', 'utf8'), + ); const validate = workflow.jobs.validate.steps.find( - (step: {name?: string}) => step.name === 'Validate version and release settings', + (step: {name?: string}) => + step.name === 'Validate version and release settings', ); assert.match(validate.run, /immutable-releases/); assert.match(validate.run, /REPOSITORY_PRIVATE/); @@ -64,14 +67,14 @@ test('releases require immutability and attest their archive and SBOM', async () assert.match(packageStep.run, /pnpm sbom[^\n]+--prod/); assert.match(packageStep.run, /sha256sum/); - const attestations = publish.steps.filter( - (step: {uses?: string}) => step.uses?.startsWith('actions/attest@'), + const attestations = publish.steps.filter((step: {uses?: string}) => + step.uses?.startsWith('actions/attest@'), ); assert.equal(attestations.length, 2); - assert.equal(attestations[0].with['subject-path'], 'release/setup-zig-*.tar.gz'); - assert.equal(attestations[1].with['sbom-path'], 'release/sbom.cdx.json'); - assert.match( - publish.steps.at(-1).run, - /gh release create[^\n]+release\/\*/, + assert.equal( + attestations[0].with['subject-path'], + 'release/setup-zig-*.tar.gz', ); + assert.equal(attestations[1].with['sbom-path'], 'release/sbom.cdx.json'); + assert.match(publish.steps.at(-1).run, /gh release create[^\n]+release\/\*/); }); From 4a250ca7d1a5ac18d990e8bdd46b90b0d55c932a Mon Sep 17 00:00:00 2001 From: Nelson Dominguez Date: Thu, 8 Oct 2026 10:47:20 +0200 Subject: [PATCH 3/6] docs: show SBOM attestation verification --- SECURITY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/SECURITY.md b/SECURITY.md index 6c2bf5d..0be31f8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,6 +23,7 @@ gh release verify v1.0.0 -R ekkolon/setup-zig gh release download v1.0.0 -R ekkolon/setup-zig --pattern 'setup-zig-*.tar.gz' gh release verify-asset v1.0.0 setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig gh attestation verify setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig +gh attestation verify setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig --predicate-type https://cyclonedx.org/bom ``` The release also provides `sbom.cdx.json` and `SHA256SUMS`. The `v1` major tag intentionally moves to compatible releases; pin a full action commit SHA or an immutable version tag when evaluating a fixed dependency. Attestations identify the publishing workflow and artifacts, but do not certify that the code is vulnerability-free or compliant with a particular standard. From c92e9ce57ff3dffe30583b29b914ea2a48698c10 Mon Sep 17 00:00:00 2001 From: Nelson Dominguez Date: Thu, 8 Oct 2026 10:48:32 +0200 Subject: [PATCH 4/6] ci: audit build dependencies and retry interrupted tags --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9785e59..e703578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: run: | pnpm sbom --sbom-format cyclonedx --sbom-spec-version 1.6 --prod --out "$RUNNER_TEMP/setup-zig.cdx.json" jq -e '.bomFormat == "CycloneDX" and (.components | length >= 7)' "$RUNNER_TEMP/setup-zig.cdx.json" - - run: pnpm audit --prod --audit-level moderate + - run: pnpm audit --audit-level moderate smoke: needs: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 933c35b..6723ca3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,15 @@ jobs: VERSION: ${{ inputs.version }} GH_TOKEN: ${{ github.token }} run: | - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$GITHUB_SHA" + tag_ref="repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION" + if gh api "$tag_ref" >/dev/null 2>&1; then + test "$(gh api "$tag_ref" --jq '.object.sha')" = "$GITHUB_SHA" || { + echo 'The release tag already points to another commit.' >&2 + exit 1 + } + else + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$GITHUB_SHA" + fi gh release create "$VERSION" release/* --verify-tag --generate-notes --title "$VERSION" major="${VERSION%%.*}" if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$major" >/dev/null 2>&1; then From 15e50657bcde1e462ed8a00032ab25861691f36a Mon Sep 17 00:00:00 2001 From: Nelson Dominguez Date: Thu, 8 Oct 2026 10:50:21 +0200 Subject: [PATCH 5/6] docs: verify release attestation signer identity --- SECURITY.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 0be31f8..d78b27d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,8 +22,11 @@ Verify a release and its downloaded archive with the GitHub CLI: gh release verify v1.0.0 -R ekkolon/setup-zig gh release download v1.0.0 -R ekkolon/setup-zig --pattern 'setup-zig-*.tar.gz' gh release verify-asset v1.0.0 setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig -gh attestation verify setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig -gh attestation verify setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig --predicate-type https://cyclonedx.org/bom +gh attestation verify setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig \ + --signer-workflow ekkolon/setup-zig/.github/workflows/release.yml +gh attestation verify setup-zig-v1.0.0.tar.gz -R ekkolon/setup-zig \ + --signer-workflow ekkolon/setup-zig/.github/workflows/release.yml \ + --predicate-type https://cyclonedx.org/bom ``` The release also provides `sbom.cdx.json` and `SHA256SUMS`. The `v1` major tag intentionally moves to compatible releases; pin a full action commit SHA or an immutable version tag when evaluating a fixed dependency. Attestations identify the publishing workflow and artifacts, but do not certify that the code is vulnerability-free or compliant with a particular standard. From 2e47133e51dac3ed9d90bac967caecd710aa5e78 Mon Sep 17 00:00:00 2001 From: Nelson Dominguez Date: Thu, 8 Oct 2026 10:55:43 +0200 Subject: [PATCH 6/6] test: assert expected recovery warnings without CI annotations --- dist/main.js | 15 +++++++++------ src/install.ts | 13 +++++++++---- src/metadata.ts | 3 ++- test/cache.test.ts | 13 +++++++++++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/dist/main.js b/dist/main.js index 785f028..b4a9dd6 100644 --- a/dist/main.js +++ b/dist/main.js @@ -781,7 +781,7 @@ async function restoreToolArchive(cachedDirectory, directory, release) { ); return void 0; } -async function restoreActionsArchive(directory, release, cache) { +async function restoreActionsArchive(directory, release, cache, warn) { for (const filename of release.filenames) { const archive = path3.join(directory, filename); const signature = `${archive}.minisig`; @@ -797,9 +797,7 @@ async function restoreActionsArchive(directory, release, cache) { const sha256 = await verifyArchive(archive, signature, filename, release); return { filename, sha256 }; } catch (error) { - warning( - `Ignoring invalid Zig archive cache: ${getErrorMessage(error)}` - ); + warn(`Ignoring invalid Zig archive cache: ${getErrorMessage(error)}`); await resetDirectory(directory); } } @@ -860,7 +858,12 @@ async function installZig(options) { } } if (!verified) { - verified = await restoreActionsArchive(directory, release, options.cache); + verified = await restoreActionsArchive( + directory, + release, + options.cache, + options.onWarning ?? warning + ); if (verified) { source = "actions-cache"; } @@ -964,7 +967,7 @@ async function readCachedMetadata(options) { return result; } catch (error) { if (saved && options.maxStaleAgeMs && now - saved.fetchedAt < options.maxStaleAgeMs) { - warning( + (options.onWarning ?? warning)( `Could not refresh ${options.name}; using the cached list: ${getErrorMessage(error)}` ); return options.parse(saved.body); diff --git a/src/install.ts b/src/install.ts index a7e9bf6..bc2f53e 100644 --- a/src/install.ts +++ b/src/install.ts @@ -38,6 +38,7 @@ interface InstallOptions { release: Release; target: Platform; cache: CacheStore; + onWarning?: (message: string) => void; /** Called only after both archive caches miss. */ getMirrors: () => Promise; } @@ -141,6 +142,7 @@ async function restoreActionsArchive( directory: string, release: Release, cache: CacheStore, + warn: (message: string) => void, ): Promise { for (const filename of release.filenames) { const archive = path.join(directory, filename); @@ -157,9 +159,7 @@ async function restoreActionsArchive( const sha256 = await verifyArchive(archive, signature, filename, release); return {filename, sha256}; } catch (error) { - core.warning( - `Ignoring invalid Zig archive cache: ${getErrorMessage(error)}`, - ); + warn(`Ignoring invalid Zig archive cache: ${getErrorMessage(error)}`); await resetDirectory(directory); } } @@ -235,7 +235,12 @@ export async function installZig( } } if (!verified) { - verified = await restoreActionsArchive(directory, release, options.cache); + verified = await restoreActionsArchive( + directory, + release, + options.cache, + options.onWarning ?? core.warning, + ); if (verified) { source = 'actions-cache'; } diff --git a/src/metadata.ts b/src/metadata.ts index bc357c0..8e193a3 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -23,6 +23,7 @@ interface MetadataOptions { cache: CacheStore; parse: (body: string) => T; forceRefresh?: boolean; + onWarning?: (message: string) => void; /** Maximum age allowed after a failed refresh; disabled when omitted. */ maxStaleAgeMs?: number; now?: number; @@ -121,7 +122,7 @@ export async function readCachedMetadata( options.maxStaleAgeMs && now - saved.fetchedAt < options.maxStaleAgeMs ) { - core.warning( + (options.onWarning ?? core.warning)( `Could not refresh ${options.name}; using the cached list: ${getErrorMessage(error)}`, ); return options.parse(saved.body); diff --git a/test/cache.test.ts b/test/cache.test.ts index 5a34023..dc60d83 100644 --- a/test/cache.test.ts +++ b/test/cache.test.ts @@ -132,6 +132,7 @@ test('a remote metadata hit avoids contacting ziglang.org', async (context) => { test('expired version metadata fails closed; mirrors have a bounded stale fallback', async (context) => { const root = await mkdtemp(path.join(os.tmpdir(), 'setup-zig-stale-')); context.after(() => rm(root, {recursive: true, force: true})); + const warnings: string[] = []; const options = { root, cache: memoryCache().store, @@ -140,6 +141,7 @@ test('expired version metadata fails closed; mirrors have a bounded stale fallba ttlMs: 1000, now: 5000, parse: JSON.parse, + onWarning: (message: string) => warnings.push(message), fetchText: async () => { throw new Error('HTTP 429'); }, @@ -158,6 +160,9 @@ test('expired version metadata fails closed; mirrors have a bounded stale fallba readCachedMetadata({...options, maxStaleAgeMs: 2000}), /HTTP 429/, ); + assert.deepEqual(warnings, [ + 'Could not refresh index; using the cached list: HTTP 429', + ]); }); test('malformed, future-dated, and wrong-origin metadata cannot suppress a refresh', async (context) => { @@ -356,6 +361,7 @@ for (const cacheResult of ['partial', 'invalid']) { }); let archiveDirectory = ''; let saved = false; + const warnings: string[] = []; const cache: CacheStore = { restore: async (paths, key) => { const archive = paths[0] ?? ''; @@ -377,6 +383,7 @@ for (const cacheResult of ['partial', 'invalid']) { }, target: {os: 'linux', arch: 'x86_64'}, cache, + onWarning: (message: string) => warnings.push(message), getMirrors: async () => { assert.deepEqual(await readdir(archiveDirectory), []); throw new Error('download boundary reached'); @@ -385,5 +392,11 @@ for (const cacheResult of ['partial', 'invalid']) { /download boundary reached/, ); assert.equal(saved, false); + assert.deepEqual( + warnings, + cacheResult === 'invalid' + ? ['Ignoring invalid Zig archive cache: Malformed minisign signature.'] + : [], + ); }); }