From 67530b4aed59ff652edabaa7c18770bac142601e Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 18 Aug 2026 21:22:41 -0400 Subject: [PATCH] chore: keep the publish credential away from the code it publishes The publish job held an OIDC id-token while it installed dependencies and ran tests, so any postinstall in either tree could mint the credential npm exchanges for a publish token. The job now splits: a build job runs the release gates, the install, the tests, and packs the tarball holding no credential, and the publish job holds the id-token alone beside that tarball. A tag-on-main gate is what the approval now rests on, spelled refs/remotes/origin/main because git prefers a tag of that name over the remote-tracking ref. The release-notes composer also stops printing the origin URL when it refuses a non-GitHub remote, which can carry a credential. --- .github/workflows/publish.yml | 93 ++++++++++++++++++++++++++++------- RELEASING.md | 16 +++--- scripts/release-notes.sh | 2 +- 3 files changed, 86 insertions(+), 25 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 90586c8..82b81e2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,38 +1,49 @@ name: Publish -# Tag-triggered publish to npm. A pushed v* tag starts the run, but the -# publish step is gated on maintainer sign-off through the npm-publish -# environment and authenticates through npm trusted publishing (OIDC). +# Tag-triggered publish to npm, split into a build job and a publish job so no +# credential is ever present while this repository's own code runs. The build +# job checks the release gates, installs, builds, tests and packs a tarball; it +# holds no id-token and no environment, so it runs on the tag push, before +# anyone approves, and its result is what the approver judges. The publish job +# holds the OIDC id-token alone, beside the prebuilt tarball and nothing else. +# Authentication is npm trusted publishing rather than a stored token, so +# publishing needs that publisher registered on npmjs.com against this +# repository, this workflow filename and the npm-publish environment; no +# NPM_TOKEN is involved. on: push: tags: ['v*'] jobs: - publish: + build: runs-on: ubuntu-latest - # Required-reviewer environment: a pushed tag queues the publish until a - # maintainer approves the run. The job holds no npm credential, so it - # writes no .npmrc auth entry and npm exchanges the id-token below for a - # short-lived one; that same token signs the provenance attestation. - environment: npm-publish + # No id-token and no environment on this job: a lifecycle script anywhere + # in the dependency tree runs here, so there is nothing for it to read. permissions: contents: read - id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history, so the tag-on-main gate below can decide ancestry. + fetch-depth: 0 + + - name: Verify the tag is on main + # Fails a tag pushed on a commit that is not on main, so an approval + # can never cover a commit off the release line. The ref is spelled + # refs/remotes/origin/main because git resolves the ambiguous + # origin/main as refs/tags/origin/main first, so a tag named + # origin/main would otherwise defeat this gate. + run: | + if ! git merge-base --is-ancestor HEAD refs/remotes/origin/main; then + echo "Tag $GITHUB_REF_NAME is not on main" >&2 + exit 1 + fi - # No registry-url: it writes an .npmrc _authToken line, and npm stops at - # that unresolvable credential instead of falling through to OIDC. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: yarn - - name: Upgrade npm - # Trusted publishing (OIDC) needs npm >= 11.5.1; the Node 22 image - # ships npm 10.x. - run: npm install -g npm@latest - - name: Verify tag matches package version run: | version="$(node -p "require('./package.json').version")" @@ -50,5 +61,51 @@ jobs: - name: Test run: yarn run test + - name: Pack + # prepack runs here, where no credential exists. The publish job ships + # this tarball as packed. + run: npm pack + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: package-tarball + path: '*.tgz' + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + # Required-reviewer environment: the packed tarball waits here until a + # maintainer approves the run, and npm's trusted publisher pins this + # repository, this workflow file and this environment name, so no other + # workflow can mint a publish credential. + environment: npm-publish + # id-token alone, and no checkout and no dependency install in this job, so + # the credential sits beside a prebuilt tarball and nothing else. + permissions: + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: package-tarball + + # No registry-url: it writes an .npmrc _authToken line, and npm stops at + # that unresolvable credential instead of falling through to OIDC. No + # cache either, because this job has no checkout and so no lockfile to + # hash. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - name: Install npm + # Trusted publishing needs npm >= 11.5.1, and the Node 22 image ships + # an older npm. The version is pinned exact so no floating executable + # runs beside the credential. + run: npm install -g --ignore-scripts npm@12.0.2 + - name: Publish - run: npm publish --access public --provenance + # The tarball came from the build job, and a tarball publish runs no + # lifecycle scripts; --ignore-scripts holds that whatever npm does + # later. No NODE_AUTH_TOKEN: npm exchanges this job's OIDC token for a + # short-lived credential, which also signs the provenance attestation. + run: npm publish ./*.tgz --access public --provenance --ignore-scripts diff --git a/RELEASING.md b/RELEASING.md index 6a61b06..8162edc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -20,7 +20,9 @@ rendered GitHub Release, not at the npm publish. git tag vX.Y.Z && git push origin vX.Y.Z ``` - `.github/workflows/publish.yml` starts and waits on the `npm-publish` + `.github/workflows/publish.yml` starts; its build job runs the release + gates (tag matches version, tag on main), the install, the tests, and + packs the tarball; its publish job waits on the `npm-publish` environment. Push the tag before creating the Release, because `gh release create` resolves the tag rather than creating it. The tag is the release: consumers pin or float on it, so push it only once the entry @@ -38,9 +40,11 @@ rendered GitHub Release, not at the npm publish. flight, create them in ascending version order, so that marker stays monotonic. -5. Approve the `npm-publish` environment for the tag. With more than one - release waiting, approve in ascending version order, so the npm `latest` - tag stays monotonic. +5. Approve the `npm-publish` environment for the tag once the run is green + through the build gates, the tag-on-main check included, which proves + the tagged commit sits on `main`. With more than one release waiting, + approve in ascending version order, so the npm `latest` tag stays + monotonic. 6. Verify the published version and the rendered Release: @@ -55,8 +59,8 @@ The publish job authenticates through npm trusted publishing (OIDC). It holds no npm token and sets no registry URL on the setup step, so nothing writes an `.npmrc` auth entry and npm 11.5.1 or later exchanges the job's OIDC identity for a short-lived credential of its own. The `npm-publish` environment is the -gate on that identity: a pushed tag queues the run until a maintainer approves -it. +gate on that identity: the build job runs immediately on the pushed tag, and +the publish job waits until a maintainer approves it. `publishConfig.provenance` in `package.json` makes a default local npm publish fail, because no OIDC identity is available outside CI to satisfy it. It is diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh index 90aaaa9..d1305bd 100755 --- a/scripts/release-notes.sh +++ b/scripts/release-notes.sh @@ -59,7 +59,7 @@ case "$ORIGIN" in ssh://git@github.com/*) SLUG="${ORIGIN#ssh://git@github.com/}" ;; https://github.com/*) SLUG="${ORIGIN#https://github.com/}" ;; https://*@github.com/*) SLUG="${ORIGIN#https://*@github.com/}" ;; - *) die "the origin remote is not a GitHub URL: $ORIGIN" ;; + *) die "the origin remote is not a GitHub URL" ;; esac SLUG="${SLUG%/}" SLUG="${SLUG%.git}"