ci(release): publish to npm with trusted publishing (OIDC) - #410
Merged
Conversation
The npm publish authenticated with a long-lived NPM_TOKEN repository
secret. It now exchanges this workflow's own OIDC identity for a
short-lived, workflow-scoped registry token, so there is no npm
credential to exfiltrate from a build log, replay, or rotate.
Three things make that safe to land on a workflow with no PR-time
coverage, which is how the --provenance 422 reached production in
v0.20.1 and is no longer recoverable by force-moving the tag:
- Node 24, because the OIDC exchange lives in the npm CLI and needs
npm >= 11.5.1. Node 20 ships 10.8.2 and Node 22 LTS ships 10.9.8;
neither carries the code path at all. A hard assertion guards it,
because oidc.js never throws: on an npm without it, trusted
publishing does not fail, it silently never happens.
- A dry-run rehearsal that performs the real exchange. publish.js
calls oidc() before it branches on --dry-run, and npm matches a
trusted publisher on repository and workflow filename rather than
on the ref, so a dispatch from a branch answers for the tag before
the tag exists.
- The published version is read back from the registry. A green
publish step proves the tarball uploaded, never that it carried
provenance.
--provenance is deleted rather than ported. It was resolved from
github.event.repository.visibility because the registry answers 422 to
a provenance publish from a private source repo; npm now applies that
rule itself by declining rather than rejecting, and passing the flag
would opt back out of the check and reinstate the 422.
setup-node still writes _authToken=${NODE_AUTH_TOKEN}, and npm leaves
an unresolvable ${...} literal rather than empty, so the key would hold
a non-empty garbage credential. That does not block the exchange, which
overwrites it, but it turns any exchange failure into a 404 that reads
as "no such package". Stripping the line leaves ENEEDAUTH instead.
This job states that its Node version and install/build pair mirror the npm-publish job in release.yml, and it exists so a tsc or tsconfig break surfaces on the PR rather than during a release. release.yml moved to Node 24 for the npm that trusted publishing requires, so leaving this on 20 would falsify the comment and reopen the gap the job was added to close, now across an npm major as well.
v0.24.0 is cut but not released: there is no v0.24.0 tag locally or on the remote, the newest GitHub release is v0.23.0, and npm's newest published version is 0.23.0. The entries this PR added under [Unreleased] therefore describe changes that ship *in* 0.24.0 rather than after it, and belong in that section. The text moves verbatim into 0.24.0's existing "### Security" subsection, after the key-package-lifetime and control-frame-freshness entries. Those two are the protocol-level items the release blockquote narrates; these are release infrastructure, so they read as the tail rather than displacing the narrative. The emptied [Unreleased] header goes with them, per CONTRIBUTING's cut recipe, which leaves no empty [Unreleased] behind. It also makes the entry true of the release it sits in: the tag is cut after this PR merges, so v0.24.0 is itself the first version published over OIDC.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The npm publish authenticated with a long-lived
NPM_TOKENrepository secret. It nowexchanges this workflow's own OIDC identity for a short-lived, workflow-scoped registry
token, so there is no npm credential to exfiltrate from a build log, replay, or rotate.
The trusted publisher is already configured at the registry against this repository and
the workflow filename
release.yml.The publish itself becomes shorter (
npm publish --access public --tag "$NPM_TAG", noNODE_AUTH_TOKEN, no--provenance). Most of the diff is the part that makes a silentfailure loud, because this workflow runs only on a
v*tag and has no PR-time coverage:its first real execution is a real release, which is exactly how the
--provenance422reached production in v0.20.1.
'20'→'24'oidc.jsis documented as "intended to never throw": every failure branch returns undefined and publishing continues with whatever.npmrcholds. On an older npm, trusted publishing does not fail, it never happens. A futurenode-versiondowngrade would reopen that with nothing in the diff to review_authTokenfrom.npmrcsetup-nodewrites_authToken=${NODE_AUTH_TOKEN}wheneverregistry-urlis set. It does not block the exchange (see below), but if the exchange ever fails npm falls back to it and dies with a 404 that reads as "no such package". Stripping leavesENEEDAUTH--provenanceis deleted rather than ported, along with the wholeResolve npm provenance eligibilitystep. It was resolved fromgithub.event.repository.visibilitybecause the registry answers HTTP 422 to aprovenance publish from a private source repo. npm now applies that rule itself, by
declining before publishing rather than by rejecting, so passing the flag explicitly
would opt back out of npm's check and reinstate the 422.
Related issues
None.
Type of change
chore— build, tooling, or dependency changesChecklist
CHANGELOG.mdupdated (the0.24.0→### Securitysubsection, since 0.24.0 is cut but not yet tagged, so this ships in it rather than after it; plusCONTRIBUTING.md)cargo fmt/clippy/test/cargo-deny— not run: this PR changes no Rust. Only.github/workflows/,CHANGELOG.mdandCONTRIBUTING.mdare touchedunsafe; no UDL change, so no binding regenerationBreaking changes
None for consumers. Two operational notes:
release.ymlis now load-bearing. npm matches a trusted publisher onthe repository plus the workflow filename, so renaming it revokes publishing
silently. Factoring the publish step into a reusable
workflow_callworkflow breaks itthe same way, because npm validates the calling workflow's name. Both are called out
in comments at the publish step.
NPM_TOKENsecret until one real trusted publish has succeeded.It is the rollback path, and per
CONTRIBUTING.mdforce-moving a released tag stoppedbeing a valid recovery once crates.io publishing landed, so a failed publish costs a
patch version.
Notes for reviewers
Two corrections to the commonly-cited root cause, both verified rather than recalled,
since they are why the strip step is worded the way it is:
${VAR}in a config file literal, not empty (tested:npm config getreturns the string${...}). So the key holds a non-empty garbagecredential, not an empty one.
lib/commands/publish.jsat v11.17.0,await oidc(...)is line 147 andgetCredentialsByURIis line 149. OIDC runs first and overwrites the token. Thestale line therefore masks failures rather than blocking the exchange, which is the
opposite of what registry-url writes _authToken line that breaks npm Trusted Publisher OIDC when no NODE_AUTH_TOKEN is set actions/setup-node#1551 and actions/setup-node registry-url interferes with OIDC trigger — example workflow fails with ENEEDAUTH when no NODE_AUTH_TOKEN npm/documentation#1960 both claim.
How to validate this before merging (the point of the rehearsal step): dispatch
Release & Publish from this branch with
dry_run: trueand an explicitversion.publish.jscallsoidc()before it branches on--dry-run, so the token request, theexchange and the registry's verdict on this workflow's identity all really happen; only
the upload is skipped. npm matches on repository and workflow filename not on the ref,
so a branch dispatch answers for the tag before the tag exists. An explicit
versionisrequired, because a dry run without one resolves
0.0.0-devandprepare-npm.sh'sAUTOLINK_MIN_VERSIONgate rejects it before the rehearsal is reached.Verification already done locally, since CI cannot reach any of this:
--severity=warning(the repo's CI setting) clean on all four newrun:blocks, extracted out of the YAML.
exact at the boundary (11.5.1 passes; 11.5.0, 10.9.8 and 10.8.2 fail); the strip step
removes
_authToken, keepsregistry=, and exits 0 when no.npmrcexists; therehearsal is correct across all five branches (exchange OK, npm too old, exchange
rejected, unrelated npm error, and a
prepublishOnlypackaging failure); the provenancecheck is correct on present / appears-on-retry / never-appears.
npm ci+tsc --noEmitclean under npm 11.12.1 against the real lockfile, whichde-risks the npm 10 → 11 half of the Node bump.
npm pack --dry-run --jsonkeeps thesame shape under npm 11, so
scripts/prepare-npm.sh's packlist assertion is unaffected.the
react-native-typecheckjob inci.ymlis what makes PRs catch it; that job alreadystates it mirrors release.yml's Node version, so leaving it on 20 would have falsified
the comment.
Deliberately out of scope, happy to do either as a follow-up:
match npm's config exactly, and there are no environments configured on this repo today.
Create GitHub Release, so a publish failure leaves anorphan release, as in v0.20.1. Worth fixing, but it changes release semantics and does
not belong in the same PR as the credential swap.