Skip to content

ci: publish to npmjs.com via OIDC trusted publishing - #3

Merged
Mearman merged 2 commits into
mainfrom
ci/npm-oidc-trusted-publishing
Sep 20, 2026
Merged

Mearman merged 2 commits into
mainfrom
ci/npm-oidc-trusted-publishing

Conversation

@Mearman

@Mearman Mearman commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

Moves the npmjs.com publish off the org-level NPM_TOKEN secret and onto npm trusted publishing, so the job authenticates with its own OIDC identity and a short-lived registry token instead of a stored credential.

Registration for this (done by the owner, needed before merge): @exadev/breadboard-client, organisation ExaDev, repository breadboard-client, workflow filename ci.yml, no environment.

What changed in the job:

  • id-token: write added to the release job's permissions, which is what lets npm request the OIDC token.
  • NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} replaced with blank NODE_AUTH_TOKEN/NPM_TOKEN. Blank rather than removed, because setup-node exports a dummy NODE_AUTH_TOKEN when none is given; blanking it means a missing or mismatched trusted publisher fails the publish loudly rather than falling back to some other credential.
  • Node 20 to 22 on that step plus npm install -g npm@latest. npm's docs require npm 11.5.1 or later on Node 22.14.0 or higher for trusted publishing, and Node 22 still ships npm 10.x, so the upgrade is not optional.
  • The npmjs.com publish is now conditional on semantic-release having actually released. See below.

The publish step was unconditional, and would have failed on this very merge

The second commit fixes a real bug that this PR would otherwise have tripped on immediately.

npm publish ran on every push to the default branch, whether or not semantic-release had released anything. When the commits since the last tag warrant no release, @semantic-release/npm leaves package.json alone, so npm publish re-publishes the version that is already on the registry and gets rejected as a duplicate.

That is exactly what a merge of this PR would have done. Every commit since v1.0.0 is ci: or build:, and @semantic-release/commit-analyzer's default release rules (lib/default-release-rules.js) only release on feat, fix, perf, a breaking change or a revert. So semantic-release would have released nothing, package.json would have stayed at 1.0.0, @exadev/breadboard-client@1.0.0 is already published, and the job would have gone red on main.

Worth being clear that this is pre-existing rather than something the OIDC change introduced: the step has been unconditional since it was added, and with the old token it would have failed the same way. It has just never actually run. The step was added in f8b5a7b, which is the current tip of main, one day after the v1.0.0 release commit, and there has been no push to main since.

The fix: the Release step now records package.json's version before and after semantic-release and exposes a released output, and the three npmjs steps are conditional on it. package.json is the signal because semantic-release exposes no step output of its own, and @semantic-release/npm's prepare step writes the next version into it exactly when there is a release to make. This mirrors the GitHub Packages leg, which is already conditional in the same way, because semantic-release only reaches its publish phase when it has something to publish.

Practical consequence: merging this publishes nothing to either registry. The trusted publisher gets exercised on the first feat/fix/perf commit that lands afterwards.

One thing I deliberately left alone

registry-url and scope stay on that setup-node step, which is different from how trilean/wire-mesh/documents.js do it. Those repos publish to one registry and can omit registry-url entirely. This job publishes twice: semantic-release pushes to GitHub Packages first, which maps @exadev to npm.pkg.github.com in the same .npmrc. Drop registry-url here and npm publish would push to GitHub Packages a second time instead of npmjs.com. The _authToken line setup-node writes alongside it does not get in the way: npm publish runs the OIDC exchange before it reads any credential (lib/commands/publish.js calls oidc() before getCredentialsByURI) and writes the exchanged token over the same user-level config key (config.set(authTokenKey, token, 'user') in lib/utils/oidc.js). setup-node's own README says the same thing: "npm Trusted Publishing (OIDC) is not affected, since it does not use NODE_AUTH_TOKEN."

The GitHub Packages publish is untouched and still uses GITHUB_TOKEN.

Provenance will be attached automatically once a publish does happen, since npm enables it by default for trusted publishing from a public repo. No --provenance flag needed.

…ublishing

The npmjs.com publish authenticated with a long-lived NPM_TOKEN read from an
org-level Actions secret. It now exchanges the release job's OIDC identity for
a short-lived registry token instead, so no npm credential is stored anywhere.

The job gains id-token: write, and NODE_AUTH_TOKEN is blanked rather than
dropped: setup-node exports a dummy value when none is supplied, and a blank
one makes a missing or misconfigured trusted publisher fail the publish
outright instead of silently falling back to token auth.

Node moves from 20 to 22 on that step and npm is upgraded to latest, because
trusted publishing needs npm 11.5.1 or later on Node 22.14.0 or higher and
Node 22 still ships npm 10.x.

registry-url and scope stay in place. The GitHub Packages publish earlier in
the same job maps @ExaDev to npm.pkg.github.com in the same .npmrc, so
remapping the scope here is what keeps this step pointed at npmjs.com. The
GitHub Packages publish itself is unchanged and still uses GITHUB_TOKEN.
The npmjs.com publish step ran on every push to the default branch,
regardless of whether semantic-release had released anything. When the
commits since the last tag warrant no release, package.json keeps its
current version and `npm publish` re-publishes that version, which the
registry rejects as a duplicate and which fails the job.

The Release step now compares package.json's version before and after
semantic-release and exposes a `released` output, and the three npmjs steps
are conditional on it. package.json is the signal because semantic-release
exposes no output of its own, and @semantic-release/npm's prepare step
writes the next version into it exactly when there is a release to make.

This matches the GitHub Packages leg, which is already conditional in the
same way: semantic-release only reaches its publish phase when it has
something to publish.
@Mearman
Mearman marked this pull request as ready for review September 20, 2026 08:39
@Mearman
Mearman merged commit c10d1ec into main Sep 20, 2026
3 checks passed
@Mearman
Mearman deleted the ci/npm-oidc-trusted-publishing branch September 20, 2026 08:39
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ✅ Completed 2026-09-20T08:46:28.818775Z 23cd8d6 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant