From f20cf191f2074063f262a3ec4d3178f8a77463ff Mon Sep 17 00:00:00 2001 From: Holger Jensen Date: Tue, 20 Oct 2026 15:35:47 -0400 Subject: [PATCH 1/3] ci: build the release without the token that signs its attestation Split the release workflow into a build job with read access only and a publish job that installs nothing from the lockfile. Build-time code no longer runs where an OIDC token can be minted, so it cannot attest other blobs as this workflow's output. The publish job holds the downloaded tarball to the sha512 the build packed before it attests and releases it. The release notes now give the verify command the catalog runs, with --signer-workflow, and say the catalog requires the attested commit to be on main. A test holds the workflow to the split. --- .github/workflows/release.yml | 129 +++++++++++++++++++++++++++------- README.md | 10 ++- package.json | 1 + pnpm-lock.yaml | 39 ++++++---- test/release-workflow.test.ts | 122 ++++++++++++++++++++++++++++++++ 5 files changed, 258 insertions(+), 43 deletions(-) create mode 100644 test/release-workflow.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af0ff32..a2fbe03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,32 @@ name: Release # # The catalog lists a plugin only from an immutable release of the repository # its source lives in, with a build attestation made by that repository's own -# workflow, on a GitHub-hosted runner, for the release's tag -# (`gh attestation verify --repo openbrf/example-plugin -# --source-ref refs/tags/v --deny-self-hosted-runners`). So this runs -# on the tag push and nowhere else. Do not publish the release from the GitHub -# UI first: that creates the tag, the tag push then starts this workflow after -# the release is already immutable, and the upload fails. +# release workflow, at this path, on a GitHub-hosted runner, in a run on the +# release's tag: +# +# gh attestation verify --repo openbrf/example-plugin +# --signer-workflow openbrf/example-plugin/.github/workflows/release.yml +# --source-ref refs/tags/v --deny-self-hosted-runners +# +# So this runs on the tag push and nowhere else, and keeps its path. The +# catalog also asks GitHub whether the commit the attestation names is on +# `main`: the check below that the tag is on `main` runs as the tagged commit +# has it, so it can be edited away on a branch, and the catalog does not rely +# on it. +# +# Do not publish the release from the GitHub UI first: that creates the tag, +# the tag push then starts this workflow after the release is already +# immutable, and the upload fails. +# +# Two jobs, so that nothing installed from the lockfile runs where an +# attestation can be signed. `build` installs, tests and packs with read +# access only, and hands the tarball and its sha512 to `publish`. `publish` +# installs and runs none of the lockfile's packages: it holds the downloaded +# tarball to that sha512, attests it and creates the release. Code that runs at build time +# can still change what it builds, which the tests and the catalog's checks +# are there for, but it never holds the token that signs an attestation, so +# it cannot attest anything else as this workflow's output. +# `test/release-workflow.test.ts` holds the workflow to this split. on: push: tags: ["v*"] @@ -22,16 +42,15 @@ concurrency: permissions: {} jobs: - release: - name: Pack, attest and publish + build: + name: Build, test, pack and check runs-on: ubuntu-latest timeout-minutes: 20 permissions: - # Create the release and upload the tarball. - contents: write - # The signing certificate for the attestation, and its record. - id-token: write - attestations: write + contents: read + outputs: + tarball: ${{ steps.pack.outputs.tarball }} + sha512: ${{ steps.pack.outputs.sha512 }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -50,7 +69,6 @@ jobs: echo "::error::v${version} points at a commit that is not on main. Release only what main has merged." exit 1 fi - echo "VERSION=${version}" >> "${GITHUB_ENV}" - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 @@ -59,6 +77,7 @@ jobs: node-version-file: .nvmrc # No dependency cache: what goes into a release is installed from # the lockfile alone. + package-manager-cache: false - run: pnpm install --frozen-lockfile - run: pnpm typecheck @@ -66,17 +85,72 @@ jobs: - run: pnpm test - name: Pack the tarball + id: pack run: | mkdir release npm pack --ignore-scripts --json --pack-destination release > release/pack.json filename="$(node -p 'require("./release/pack.json")[0].filename')" + rm release/pack.json + sha512="sha512-$(openssl dgst -sha512 -binary "release/${filename}" | base64 -w0)" echo "TARBALL=release/${filename}" >> "${GITHUB_ENV}" + echo "tarball=${filename}" >> "${GITHUB_OUTPUT}" + echo "sha512=${sha512}" >> "${GITHUB_OUTPUT}" - # The same file is checked, attested and uploaded: what the catalog - # verifies is byte for byte what passed here. + # The file checked here is the one `publish` attests and uploads, held + # to the sha512 above: what the catalog verifies is byte for byte what + # passed. - name: Check the tarball as the catalog will run: node scripts/check-package.mjs "${TARBALL}" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: tarball + path: ${{ env.TARBALL }} + if-no-files-found: error + retention-days: 1 + + publish: + name: Attest and publish + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # Create the release and upload the tarball. + contents: write + # The signing certificate for the attestation, and its record. + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: tarball + path: release + + # The build's outputs come from a job that ran the dependencies, so they + # are read as data: through the environment, and held to their shape. + - name: Check the tarball is the one the build checked + env: + BUILT_TARBALL: ${{ needs.build.outputs.tarball }} + BUILT_SHA512: ${{ needs.build.outputs.sha512 }} + run: | + if ! [[ "${BUILT_TARBALL}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$ ]]; then + echo "::error::The build named the tarball '${BUILT_TARBALL}', which is not a tarball's file name." + exit 1 + fi + files="$(ls -A release)" + if [ "${files}" != "${BUILT_TARBALL}" ]; then + echo "::error::The build's artifact holds other files than ${BUILT_TARBALL} alone." + exit 1 + fi + tarball="release/${BUILT_TARBALL}" + sha512="sha512-$(openssl dgst -sha512 -binary "${tarball}" | base64 -w0)" + if [ "${sha512}" != "${BUILT_SHA512}" ]; then + echo "::error::The downloaded tarball's sha512 is ${sha512}, but the build packed ${BUILT_SHA512}." + exit 1 + fi + echo "TARBALL=${tarball}" >> "${GITHUB_ENV}" + echo "SHA512=${sha512}" >> "${GITHUB_ENV}" + - name: Attest the tarball uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: @@ -86,31 +160,34 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - sha512="sha512-$(openssl dgst -sha512 -binary "${TARBALL}" | base64 -w0)" + # The build checked that the tag names the version in package.json. + tag="${GITHUB_REF#refs/tags/}" bytes="$(stat -c %s "${TARBALL}")" name="$(basename "${TARBALL}")" { - echo "The plugin tarball for the catalog, built and attested by this repository's release workflow for \`v${VERSION}\`." + echo "The plugin tarball for the catalog, built and attested by this repository's release workflow for \`${tag}\`." echo echo '```' echo "file ${name}" - echo "sha512 ${sha512}" + echo "sha512 ${SHA512}" echo "bytes ${bytes}" echo '```' echo - echo "Verify it with:" + echo "Verify it as the catalog does:" echo echo '```' - echo "gh attestation verify ${name} --repo ${GITHUB_REPOSITORY} --source-ref refs/tags/v${VERSION} --deny-self-hosted-runners" + echo "gh attestation verify ${name} --repo ${GITHUB_REPOSITORY} --signer-workflow ${GITHUB_REPOSITORY}/.github/workflows/release.yml --source-ref refs/tags/${tag} --deny-self-hosted-runners" echo '```' echo - echo "What changed is in [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/v${VERSION}/CHANGELOG.md)." - } > release/notes.md + echo "The catalog also requires the commit the attestation names to be on \`main\`." + echo + echo "What changed is in [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/CHANGELOG.md)." + } > notes.md # With an asset to attach, gh creates the release as a draft, # uploads the asset and only then publishes it, so the immutable # release is never seen without its tarball. - gh release create "v${VERSION}" "${TARBALL}" \ + gh release create "${tag}" "${TARBALL}" \ --repo "${GITHUB_REPOSITORY}" \ --verify-tag \ - --title "v${VERSION}" \ - --notes-file release/notes.md + --title "${tag}" \ + --notes-file notes.md diff --git a/README.md b/README.md index bc79f45..3bf131a 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,13 @@ The licence is MIT-0, so you can copy any of it without keeping a notice. ``` 3. The release workflow checks that the tag matches the version and points at - a commit on `main`. It then builds, tests, packs, checks and attests the - tarball, and publishes the release with the tarball attached. The release - notes give the tarball's `sha512` and `bytes`. + a commit on `main`. Its `build` job then builds, tests, packs and checks + the tarball with read access only. Its `publish` job, which installs and + runs none of the lockfile's packages, holds the tarball to the `sha512` the + build packed, attests it and publishes the release with the tarball + attached. Code that runs at build time therefore never holds the token that + signs an attestation. The release notes give the tarball's `sha512` and + `bytes`, and the `gh attestation verify` command the catalog runs. Do not create the release in the GitHub interface. That creates the tag first, so the workflow starts after the release is already immutable, and the diff --git a/package.json b/package.json index bbff7a9..73204f6 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "typescript": "7.0.2", "vite": "8.3.4", "vitest": "5.0.5", + "yaml": "2.9.1", "zod": "4.6.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 248cc67..b3af337 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,7 +168,7 @@ importers: devDependencies: '@module-federation/vite': specifier: 1.23.5 - version: 1.23.5(typescript@7.0.2)(vite@8.3.4(@types/node@26.6.5)) + version: 1.23.5(typescript@7.0.2)(vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1)) '@nestjs/common': specifier: 12.2.0 version: 12.2.0(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -198,7 +198,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: 6.1.2 - version: 6.1.2(vite@8.3.4(@types/node@26.6.5)) + version: 6.1.2(vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1)) i18next: specifier: 26.5.2 version: 26.5.2(typescript@7.0.2) @@ -225,10 +225,13 @@ importers: version: 7.0.2 vite: specifier: 8.3.4 - version: 8.3.4(@types/node@26.6.5) + version: 8.3.4(@types/node@26.6.5)(yaml@2.9.1) vitest: specifier: 5.0.5 - version: 5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.4(@types/node@26.6.5)) + version: 5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1)) + yaml: + specifier: 2.9.1 + version: 2.9.1 zod: specifier: 4.6.7 version: 4.6.7 @@ -1230,6 +1233,11 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + zod@4.6.7: resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} @@ -1339,12 +1347,12 @@ snapshots: '@module-federation/third-party-dts-extractor@2.9.2': {} - '@module-federation/vite@1.23.5(typescript@7.0.2)(vite@8.3.4(@types/node@26.6.5))': + '@module-federation/vite@1.23.5(typescript@7.0.2)(vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1))': dependencies: '@module-federation/dts-plugin': 2.9.2(typescript@7.0.2) '@module-federation/runtime': 2.9.2 '@module-federation/sdk': 2.9.2 - vite: 8.3.4(@types/node@26.6.5) + vite: 8.3.4(@types/node@26.6.5)(yaml@2.9.1) transitivePeerDependencies: - bufferutil - typescript @@ -1558,19 +1566,19 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@26.6.5))': + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(@types/node@26.6.5) + vite: 8.3.4(@types/node@26.6.5)(yaml@2.9.1) - '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@26.6.5))': + '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.5 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - vite: 8.3.4(@types/node@26.6.5) + vite: 8.3.4(@types/node@26.6.5)(yaml@2.9.1) '@vitest/spy@5.0.5': {} @@ -1919,7 +1927,7 @@ snapshots: dependencies: react: 19.3.1 - vite@8.3.4(@types/node@26.6.5): + vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1929,11 +1937,12 @@ snapshots: optionalDependencies: '@types/node': 26.6.5 fsevents: 2.3.3 + yaml: 2.9.1 - vitest@5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.4(@types/node@26.6.5)): + vitest@5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@26.6.5)) + '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@26.6.5)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -1944,7 +1953,7 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@26.6.5) + vite: 8.3.4(@types/node@26.6.5)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 26.6.5 @@ -1976,4 +1985,6 @@ snapshots: xmlchars@2.2.0: {} + yaml@2.9.1: {} + zod@4.6.7: {} diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts new file mode 100644 index 0000000..fe3b157 --- /dev/null +++ b/test/release-workflow.test.ts @@ -0,0 +1,122 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +/** + * Holds the release workflow to its split: a job that can sign an + * attestation (`id-token: write`) installs and runs none of the lockfile's + * packages, so code that runs at build time never holds the token that signs. + */ + +type Permissions = string | Record | undefined; + +interface Step { + uses?: string; + run?: string; +} + +interface Job { + needs?: string | string[]; + permissions?: Permissions; + steps?: Step[]; +} + +interface Workflow { + permissions?: Permissions; + jobs: Record; +} + +/** Actions that install a package manager or a Node to run dependencies with. */ +const SETUP_ACTIONS = ["pnpm/action-setup", "actions/setup-node"]; + +/** A command that installs or runs what the lockfile holds. */ +const DEPENDENCY_COMMAND = /(^|[\s;&|(`$])(pnpm|npm|npx|yarn|corepack|node)\b/m; + +function mintsTokens(permissions: Permissions): boolean { + if (typeof permissions === "string") { + return permissions === "write-all"; + } + return permissions?.["id-token"] === "write"; +} + +/** Every sentence a workflow fails the split with; none when it holds. */ +function splitProblems(workflow: Workflow): string[] { + const problems: string[] = []; + for (const [id, job] of Object.entries(workflow.jobs)) { + // A job without its own permissions takes the workflow's. + if (!mintsTokens(job.permissions ?? workflow.permissions)) { + continue; + } + for (const step of job.steps ?? []) { + const action = step.uses?.split("@")[0]; + if (action !== undefined && SETUP_ACTIONS.includes(action)) { + problems.push(`job ${id} can sign attestations and uses ${action}`); + } + const command = step.run?.match(DEPENDENCY_COMMAND)?.[2]; + if (command !== undefined) { + problems.push(`job ${id} can sign attestations and runs ${command}`); + } + } + } + return problems; +} + +const workflow = parse( + readFileSync(".github/workflows/release.yml", "utf8"), +) as Workflow; + +describe("the release workflow", () => { + it("grants nothing at the top, so each job states what it needs", () => { + expect(workflow.permissions).toEqual({}); + }); + + it("builds with read access only", () => { + expect(workflow.jobs.build?.permissions).toEqual({ contents: "read" }); + }); + + it("attests in a job that needs the build", () => { + const publish = workflow.jobs.publish; + expect(mintsTokens(publish?.permissions)).toBe(true); + expect(publish).toMatchObject({ needs: "build" }); + }); + + it("installs and runs none of the lockfile's packages in a job that can sign attestations", () => { + expect(splitProblems(workflow)).toEqual([]); + }); + + it("refuses a single job that builds where it signs", () => { + const single: Workflow = { + permissions: {}, + jobs: { + release: { + permissions: { contents: "write", "id-token": "write" }, + steps: [ + { + uses: "pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413", + }, + { run: "pnpm install --frozen-lockfile" }, + { run: "mkdir release\nnpm pack --pack-destination release" }, + { uses: "actions/attest-build-provenance@4d101475" }, + ], + }, + }, + }; + + expect(splitProblems(single)).toEqual([ + "job release can sign attestations and uses pnpm/action-setup", + "job release can sign attestations and runs pnpm", + "job release can sign attestations and runs npm", + ]); + }); + + it("refuses a job that takes write-all from the workflow and runs node", () => { + const inherited: Workflow = { + permissions: "write-all", + jobs: { publish: { steps: [{ run: "node scripts/check-package.mjs" }] } }, + }; + + expect(splitProblems(inherited)).toEqual([ + "job publish can sign attestations and runs node", + ]); + }); +}); From 3189e0126d8fa7b600542ac4211da307e2ad1af2 Mon Sep 17 00:00:00 2001 From: Holger Jensen Date: Tue, 20 Oct 2026 16:22:40 -0400 Subject: [PATCH 2/3] fix: hold the release workflow test to path-named node and exact publish scopes --- test/release-workflow.test.ts | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts index fe3b157..a9d0496 100644 --- a/test/release-workflow.test.ts +++ b/test/release-workflow.test.ts @@ -29,8 +29,11 @@ interface Workflow { /** Actions that install a package manager or a Node to run dependencies with. */ const SETUP_ACTIONS = ["pnpm/action-setup", "actions/setup-node"]; -/** A command that installs or runs what the lockfile holds. */ -const DEPENDENCY_COMMAND = /(^|[\s;&|(`$])(pnpm|npm|npx|yarn|corepack|node)\b/m; +/** + * A command that installs or runs what the lockfile holds, also when it is + * named by its path (`/usr/bin/node`). + */ +const DEPENDENCY_COMMAND = /(^|[\s;&|(`$/])(pnpm|npm|npx|yarn|corepack|node)\b/m; function mintsTokens(permissions: Permissions): boolean { if (typeof permissions === "string") { @@ -74,9 +77,13 @@ describe("the release workflow", () => { expect(workflow.jobs.build?.permissions).toEqual({ contents: "read" }); }); - it("attests in a job that needs the build", () => { + it("publishes with exactly the three write scopes, in a job that needs the build", () => { const publish = workflow.jobs.publish; - expect(mintsTokens(publish?.permissions)).toBe(true); + expect(publish?.permissions).toEqual({ + contents: "write", + "id-token": "write", + attestations: "write", + }); expect(publish).toMatchObject({ needs: "build" }); }); @@ -119,4 +126,20 @@ describe("the release workflow", () => { "job publish can sign attestations and runs node", ]); }); + + it("refuses a job that runs node by its path", () => { + const pathed: Workflow = { + permissions: {}, + jobs: { + publish: { + permissions: { "id-token": "write" }, + steps: [{ run: "/usr/bin/node scripts/check-package.mjs" }], + }, + }, + }; + + expect(splitProblems(pathed)).toEqual([ + "job publish can sign attestations and runs node", + ]); + }); }); From d2a082d2e1f1c42724e37895bd0f7663e70b1db7 Mon Sep 17 00:00:00 2001 From: Holger Jensen Date: Tue, 20 Oct 2026 16:36:14 -0400 Subject: [PATCH 3/3] fix: hold the release workflow test to local executables and the publish job's tarball check --- test/release-workflow.test.ts | 57 +++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts index a9d0496..76d8c09 100644 --- a/test/release-workflow.test.ts +++ b/test/release-workflow.test.ts @@ -11,7 +11,10 @@ import { parse } from "yaml"; type Permissions = string | Record | undefined; interface Step { + name?: string; uses?: string; + with?: Record; + env?: Record; run?: string; } @@ -31,9 +34,11 @@ const SETUP_ACTIONS = ["pnpm/action-setup", "actions/setup-node"]; /** * A command that installs or runs what the lockfile holds, also when it is - * named by its path (`/usr/bin/node`). + * named by its path (`/usr/bin/node`) or is an executable of the installed + * packages (`./node_modules/.bin/vitest`). */ -const DEPENDENCY_COMMAND = /(^|[\s;&|(`$/])(pnpm|npm|npx|yarn|corepack|node)\b/m; +const DEPENDENCY_COMMAND = + /(^|[\s;&|(`$/])(pnpm|npm|npx|yarn|corepack|node_modules|node)\b/m; function mintsTokens(permissions: Permissions): boolean { if (typeof permissions === "string") { @@ -87,6 +92,34 @@ describe("the release workflow", () => { expect(publish).toMatchObject({ needs: "build" }); }); + it("attests only the tarball the build checked, and refuses any other", () => { + const steps = workflow.jobs.publish?.steps ?? []; + const download = steps.find((step) => + step.uses?.startsWith("actions/download-artifact@"), + ); + expect(download?.with).toMatchObject({ name: "tarball", path: "release" }); + + const check = steps.find((step) => step.env?.BUILT_SHA512 !== undefined); + expect(check?.env).toEqual({ + BUILT_TARBALL: "${{ needs.build.outputs.tarball }}", + BUILT_SHA512: "${{ needs.build.outputs.sha512 }}", + }); + // The outputs are read as data, never interpolated into the script. + expect(check?.run).not.toContain("${{"); + expect(check?.run).toContain("openssl dgst -sha512"); + expect(check?.run).toContain('!= "${BUILT_SHA512}"'); + expect(check?.run).toContain('!= "${BUILT_TARBALL}"'); + + const attest = steps.find((step) => + step.uses?.startsWith("actions/attest-build-provenance@"), + ); + expect(attest?.with).toEqual({ "subject-path": "${{ env.TARBALL }}" }); + // The check runs before the tarball is attested. + expect(steps.indexOf(check as Step)).toBeLessThan( + steps.indexOf(attest as Step), + ); + }); + it("installs and runs none of the lockfile's packages in a job that can sign attestations", () => { expect(splitProblems(workflow)).toEqual([]); }); @@ -142,4 +175,24 @@ describe("the release workflow", () => { "job publish can sign attestations and runs node", ]); }); + + it("refuses a job that runs an executable of the installed packages", () => { + const local: Workflow = { + permissions: {}, + jobs: { + publish: { + permissions: { "id-token": "write" }, + steps: [ + { run: "./node_modules/.bin/vitest run" }, + { run: "node_modules/.bin/tsc" }, + ], + }, + }, + }; + + expect(splitProblems(local)).toEqual([ + "job publish can sign attestations and runs node_modules", + "job publish can sign attestations and runs node_modules", + ]); + }); });