diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 13390a2..a682669 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,15 +10,26 @@ # that failed on something transient; it is safe because a version already on # the registry is skipped rather than attempted. # -# Authenticates with a stored npm automation token, in the repository secret -# `NPM_TOKEN`. Trusted publishing (OIDC) would avoid the stored credential, but -# it needs a trusted publisher registered against this workflow's filename on -# npmjs.com, which can only be done through the web UI — and until that exists -# npm rejects the publish as E404/no-permission, which is how v0.24.1 failed. +# Authenticates by trusted publishing (OIDC) rather than a stored token: npm +# trades the short-lived token GitHub mints for this specific workflow run for +# permission to publish, so there is no long-lived credential in the repository +# to leak, rotate or forget. # -# Worth knowing when this is next revisited: npm is restricting tokens that -# bypass 2FA for direct publishing, so the token path has a horizon. -# https://gh.io/npm-gat-bypass2fa-deprecation +# The other half of that trust lives on npmjs.com, under the package's Trusted +# Publisher settings, and it is pinned to the *filename* of this workflow. +# Renaming this file silently breaks publishing — npm will refuse the exchange +# because the run no longer matches what was configured. +# +# What that looks like when it goes wrong, since the error names neither OIDC +# nor trusted publishing: +# +# npm error code E404 +# npm error 404 Not Found - PUT https://registry.npmjs.org/moshcode +# npm error 404 ... could not be found or you do not have permission +# +# There is no credential at all in that case, and npm reports it as if the +# package did not exist. If you see it, the publisher is not registered, or is +# registered against a different workflow filename. name: publish on: @@ -28,9 +39,9 @@ on: permissions: contents: read - # Still needed with token auth: provenance is signed with a short-lived OIDC - # token even though the publish itself authenticates with NPM_TOKEN. Without - # it `--provenance` fails. + # The whole basis of the exchange: this is what lets the run mint the OIDC + # token npm authenticates against. Without it there is no credential at all + # and publishing fails outright. id-token: write jobs: @@ -51,6 +62,22 @@ jobs: cache: pnpm registry-url: https://registry.npmjs.org + # Node 22 bundles npm 10, which predates trusted publishing and would fall + # back to looking for a token that no longer exists — an auth failure that + # reads as a credential problem rather than a version one. 11.5.1 is the + # floor; the check below says so plainly if that ever regresses. + - name: Install an npm that understands trusted publishing + run: | + npm install -g npm@latest + VERSION="$(npm --version)" + MINIMUM=11.5.1 + echo "npm $VERSION" + # Lowest of the two must be the minimum, or this npm is older than it. + if [ "$(printf '%s\n%s\n' "$MINIMUM" "$VERSION" | sort -V | head -n1)" != "$MINIMUM" ]; then + echo "::error::npm $VERSION cannot use trusted publishing — $MINIMUM or later is required" + exit 1 + fi + - run: pnpm install --frozen-lockfile # Publishing is the one action here that cannot be taken back — npm will @@ -95,24 +122,11 @@ jobs: echo "already=false" >> "$GITHUB_OUTPUT" fi - # Said plainly here, rather than as the E404/no-permission npm otherwise - # returns partway through a release — an error that reads as "the package - # does not exist" rather than "there is no credential". - - name: Require an npm token - if: steps.published.outputs.already == 'false' - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - if [ -z "$NPM_TOKEN" ]; then - echo "::error::NPM_TOKEN is not set — add an npm automation token as a repository secret named NPM_TOKEN" - exit 1 - fi - + # No token, and no `--provenance` either: publishing through trusted + # publishing generates and attaches the attestation on its own. - name: Publish if: steps.published.outputs.already == 'false' - run: npm publish --access public --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public - name: Confirm the registry has it if: steps.published.outputs.already == 'false'