Skip to content

ci(release): publish to npm with trusted publishing (OIDC) - #410

Merged
bahdotsh merged 3 commits into
mainfrom
ci/npm-trusted-publishing
Aug 24, 2026
Merged

ci(release): publish to npm with trusted publishing (OIDC)#410
bahdotsh merged 3 commits into
mainfrom
ci/npm-trusted-publishing

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Aug 24, 2026

Copy link
Copy Markdown
Member

Summary

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.
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", no
NODE_AUTH_TOKEN, no --provenance). Most of the diff is the part that makes a silent
failure 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 --provenance 422
reached production in v0.20.1.

Step The failure it exists to prevent
Node '20''24' The OIDC exchange lives in the npm CLI and needs npm >= 11.5.1. Node 20 ships 10.8.2 and Node 22 LTS still ships 10.9.8; neither carries the code path at all. Node 24 ships 11.17.0
Assert npm >= 11.5.1 oidc.js is documented as "intended to never throw": every failure branch returns undefined and publishing continues with whatever .npmrc holds. On an older npm, trusted publishing does not fail, it never happens. A future node-version downgrade would reopen that with nothing in the diff to review
Strip _authToken from .npmrc setup-node writes _authToken=${NODE_AUTH_TOKEN} whenever registry-url is 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 leaves ENEEDAUTH
Rehearse the exchange on dry runs The first pre-release coverage this workflow has ever had
Verify provenance from the registry A green publish step proves the tarball uploaded, never that it carried provenance

--provenance is deleted rather than ported, along with the whole
Resolve npm provenance eligibility step. It was resolved from
github.event.repository.visibility because the registry answers HTTP 422 to a
provenance 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 changes

Checklist

  • Commits follow Conventional Commits
  • Docs / CHANGELOG.md updated (the 0.24.0### Security subsection, since 0.24.0 is cut but not yet tagged, so this ships in it rather than after it; plus CONTRIBUTING.md)
  • cargo fmt / clippy / test / cargo-denynot run: this PR changes no Rust. Only .github/workflows/, CHANGELOG.md and CONTRIBUTING.md are touched
  • No new unsafe; no UDL change, so no binding regeneration

Breaking changes

None for consumers. Two operational notes:

  • The filename release.yml is now load-bearing. npm matches a trusted publisher on
    the repository plus the workflow filename, so renaming it revokes publishing
    silently. Factoring the publish step into a reusable workflow_call workflow breaks it
    the same way, because npm validates the calling workflow's name. Both are called out
    in comments at the publish step.
  • Do not delete the NPM_TOKEN secret until one real trusted publish has succeeded.
    It is the rollback path, and per CONTRIBUTING.md force-moving a released tag stopped
    being 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:

  1. npm leaves an unresolvable ${VAR} in a config file literal, not empty (tested:
    npm config get returns the string ${...}). So the key holds a non-empty garbage
    credential, not an empty one.
  2. In lib/commands/publish.js at v11.17.0, await oidc(...) is line 147 and
    getCredentialsByURI is line 149. OIDC runs first and overwrites the token. The
    stale 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: true and an explicit version.
publish.js calls oidc() before it branches on --dry-run, so the token request, the
exchange 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 version is
required, because a dry run without one resolves 0.0.0-dev and prepare-npm.sh's
AUTOLINK_MIN_VERSION gate rejects it before the rehearsal is reached.

Verification already done locally, since CI cannot reach any of this:

  • shellcheck --severity=warning (the repo's CI setting) clean on all four new run:
    blocks, extracted out of the YAML.
  • All four blocks executed against mocks with negative controls: the version gate is
    exact at the boundary (11.5.1 passes; 11.5.0, 10.9.8 and 10.8.2 fail); the strip step
    removes _authToken, keeps registry=, and exits 0 when no .npmrc exists; the
    rehearsal is correct across all five branches (exchange OK, npm too old, exchange
    rejected, unrelated npm error, and a prepublishOnly packaging failure); the provenance
    check is correct on present / appears-on-retry / never-appears.
  • npm ci + tsc --noEmit clean under npm 11.12.1 against the real lockfile, which
    de-risks the npm 10 → 11 half of the Node bump. npm pack --dry-run --json keeps the
    same shape under npm 11, so scripts/prepare-npm.sh's packlist assertion is unaffected.
  • The Node 20 → 24 half is not verified locally (no Node 24 on this machine). Bumping
    the react-native-typecheck job in ci.yml is what makes PRs catch it; that job already
    states 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:

  • No GitHub Environment gate on the publish job. It would be a second field that has to
    match npm's config exactly, and there are no environments configured on this repo today.
  • The publish still runs after Create GitHub Release, so a publish failure leaves an
    orphan 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.

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.
@bahdotsh
bahdotsh merged commit 849e93b into main Aug 24, 2026
18 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 24, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant