Switching to npm trusted publishers: mind the Node version

Moving GitProxy's npm publish from a token to OIDC trusted publishers took a few lines, except for one thing that kept failing.

GitProxy’s release-candidate publishes to npm quietly stopped working. The Publish to NPM GitHub Actions job had been pushing @finos/git-proxy pre-releases for a while, then one day a release-candidate run failed at the publish step with what looked like a permissions error. The workflow file itself had not changed, which is the confusing part: the same YAML that worked last month suddenly could not authenticate. The short version is that our NPM_TOKEN had aged out, and instead of minting another long-lived token I moved the whole thing to npm trusted publishers. That switch is small on paper, but it has one trap that cost me a second pull request, and that trap is the real reason for this post.

What the token-based workflow looked like

The old setup is the one most npm publish workflows still use. actions/setup-node writes an .npmrc pointed at the registry, and the publish step authenticates with a token stored in repository secrets:

- run: npm publish --provenance --access=public --tag rc
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

For a pre-release we publish under the rc dist-tag so it does not become latest, and because @finos/git-proxy is a scoped package we pass --access=public so the scope publishes publicly instead of defaulting to restricted. --provenance attaches a signed statement about where and how the package was built.

The weak point is NPM_TOKEN. A long-lived write token sitting in CI is exactly what the recent npm supply-chain incidents have gone after, and npm has been tightening this for a while: granular tokens now expire, and classic tokens have been retired entirely. So a token that “expired” is not a one-off annoyance to paper over with a fresh one. It is the registry telling you to stop using this pattern.

What trusted publishers change

Trusted publishing lets a specific GitHub Actions workflow publish to npm using a short-lived OpenID Connect (OIDC) credential instead of a stored token. You register the workflow once on npmjs.com as a trusted publisher, and at publish time GitHub hands npm a signed token that proves “this is that workflow, in that repo,” which npm exchanges for a temporary publish credential. Nothing long-lived is stored anywhere. The npm docs on trusted publishing and GitHub’s general-availability announcement both cover the registry-side setup, which is the part you do once in the package settings.

Two things have to be true in the workflow for OIDC to engage. The job needs id-token: write permission so GitHub will issue the token, and the publish step must have no token of its own, because npm only falls back to OIDC when it finds no NODE_AUTH_TOKEN. Our workflow already had id-token: write (we were using it for provenance), so the first change was simply to delete the token. That went out as the first PR, removing the token and the provenance flags:

- run: npm publish --access=public --tag rc

Dropping --provenance is not a regression. When you publish through trusted publishing, npm generates and attaches provenance for you, so the flag is redundant. If you ever want to turn provenance off, you set NPM_CONFIG_PROVENANCE=false rather than removing a flag.

Why removing the token was not enough

Here is the part that cost me the second PR. With the token gone and id-token: write in place, I expected the next release candidate to publish over OIDC. It did not. The publish step still failed to authenticate, the same way a publish fails when it has no credentials at all, with npm reporting something like:

npm error code ENEEDAUTH
npm error need auth This command requires you to be logged in to https://registry.npmjs.org/

The cause is a version gate that is easy to miss: trusted publishing needs npm CLI 11.5.1 or newer. Our workflow pinned node-version: '22.x', and Node 22 ships npm 10. npm 10 does not know how to do the OIDC exchange, so it never even attempts trusted publishing. It looks for a token, finds none, and gives up. Removing the token and enabling OIDC support are two separate layers, and I had only changed one of them.

The fix was to give the runner a newer npm. The cleanest way is to bump Node, since Node 24 ships an npm 11 that supports OIDC out of the box. That was the core of the follow-up PR, bumping the runner to Node 24, which came down to one line:

- uses: actions/setup-node@v5
  with:
    node-version: '24'
    registry-url: 'https://registry.npmjs.org'

If you have to keep a job on an older Node for some other reason, the alternative is to add a run: npm install -g npm@latest step before publishing, which gets npm 11 onto Node 22. Bumping Node is simpler when nothing is stopping you, so that is what we did. The same PR also dropped a leftover IS_PUBLISHING: 'YES' env var from the build step. Nothing in the build actually read it, and our Vite config injects process.env into the client bundle wholesale, so a stray flag like that is just noise. It went out as cleanup in the same change.

The other gotchas worth knowing

A few more things can bite you on this migration, even the ones that did not bite us:

An empty token is not the same as no token. If you leave NODE_AUTH_TOKEN set to an empty string, npm treats the empty string as a value and tries to use it instead of falling back to OIDC. The env has to be gone, not blank.

Provenance validation checks your repository field. npm verifies that repository.url in package.json matches the GitHub repository the publish came from, and if it does not you get a 422 Unprocessable Entity with Error verifying sigstore provenance bundle: Failed to validate repository information. We did not hit this because @finos/git-proxy already declares "url": "https://github.com/finos/git-proxy", but if your repository field is missing or stale, fix it before you switch.

You cannot publish the very first version of a package over OIDC. The trusted-publisher setting lives in the package’s settings on npmjs.com, and the package has to exist before you can configure it, so the initial publish still needs a manual or token-based push. This only matters for brand-new packages.

Trusted publishing currently works only on cloud-hosted runners, not self-hosted ones. If your release job runs on a self-hosted runner, this approach will not work yet.

The final workflow

Stripped to the parts that matter, the publish job ended up like this, with no token anywhere:

permissions:
  contents: read
  id-token: write            # lets GitHub issue the OIDC token
jobs:
  build:
    runs-on: ubuntu-latest   # cloud-hosted; OIDC needs this
    steps:
      - uses: actions/setup-node@v5
        with:
          node-version: '24'         # ships npm 11.5.1+, which OIDC requires
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm run build
      - name: Check if pre-release and publish to NPM
        run: |
          VERSION=$(node -p "require('./package.json').version")
          if [[ "$VERSION" == *"-"* ]]; then
            npm publish --access=public --tag rc
          else
            npm publish --access=public
          fi
        # no env block: with no NODE_AUTH_TOKEN, npm uses OIDC

The next release candidate published without a token, and npm attached provenance on its own.

The takeaway

The token and the OIDC support are separate layers, and the feature gate lives on the npm CLI, not on Node. If you go tokenless and the publish still cannot authenticate, check npm --version on the runner before you touch anything else, because Node 22 quietly ships an npm that is too old for trusted publishing. Sorting all of this out is also what got me writing up Docker build-and-publish guidance for the FINOS community docs, where the same habit applies: pin the thing that actually gates the feature, not the thing next to it.