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..76d8c09 --- /dev/null +++ b/test/release-workflow.test.ts @@ -0,0 +1,198 @@ +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 { + name?: string; + uses?: string; + with?: Record; + env?: Record; + 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, also when it is + * 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_modules|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("publishes with exactly the three write scopes, in a job that needs the build", () => { + const publish = workflow.jobs.publish; + expect(publish?.permissions).toEqual({ + contents: "write", + "id-token": "write", + attestations: "write", + }); + 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([]); + }); + + 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", + ]); + }); + + 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", + ]); + }); + + 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", + ]); + }); +});