Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,20 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: 'https://registry.npmjs.org'
# No registry-url, deliberately: it makes setup-node write
# `_authToken=${NODE_AUTH_TOKEN}` into an .npmrc unconditionally, and
# npm runs the OIDC exchange only when it finds no usable credential
# for the registry. Whether that line is harmless comes down to how
# the variable expands, which is far too subtle to leave standing:
# - undefined (no `env:` at all) → the line stays literally
# `${NODE_AUTH_TOKEN}`, reads as a credential, OIDC is skipped,
# and the run 403s on PUT after every gate has passed
# - empty string (`env:` naming a secret that does not exist) →
# expands to an empty credential, OIDC runs, publish succeeds
# The sibling repo await-parallel-limit publishes fine on the second
# spelling. Omitting registry-url removes the question entirely.
# See actions/setup-node#1551. npm defaults to registry.npmjs.org.
node-version: 22.x # trusted publishing needs >= 22.14.0

- run: npm install

Expand Down Expand Up @@ -71,13 +83,32 @@ jobs:
node --unhandled-rejections=strict smoke.cjs
node --unhandled-rejections=strict smoke.mjs

# Fails fast and legibly if anything has put a credential back in npm's
# config: without this, a suppressed OIDC exchange only shows up as an
# opaque 403 at the very end, after ~5 minutes of gates.
- name: Guard — no registry credential (a token would suppress OIDC)
run: |
for f in "${NPM_CONFIG_USERCONFIG:-}" "$HOME/.npmrc" "$GITHUB_WORKSPACE/.npmrc"; do
if [ -n "$f" ] && [ -f "$f" ]; then
sed -i '/_authToken/d' "$f"
echo "stripped any _authToken line from $f"
fi
done
# Never echo the value itself — it would land in the public log.
TOKEN="$(npm config get //registry.npmjs.org/:_authToken 2>/dev/null || true)"
case "$TOKEN" in
''|undefined|null)
echo "No registry credential configured — npm will run the OIDC exchange." ;;
*)
echo "::error::A registry auth token is configured; npm would skip the OIDC exchange and 403."
exit 1 ;;
esac

- name: Publish with provenance
# No NODE_AUTH_TOKEN on purpose. setup-node's registry-url writes an
# .npmrc containing `_authToken=${NODE_AUTH_TOKEN}`; with no token
# secret that expands to an EMPTY credential, which npm can read as
# "auth already configured" and skip the OIDC exchange — a 401 after
# every gate above has run. Trusted publishing wants the token absent.
# npm >= 11.5.1 is what implements the exchange; 22.x ships older.
# npm >= 11.5.1 implements the OIDC exchange; Node 22.x ships npm 10.
# Provenance is automatic under trusted publishing, but --provenance is
# kept explicit so the run fails loudly rather than silently publishing
# an unattested tarball if the exchange ever stops happening.
run: |
npm install -g npm@latest
npm --version
Expand Down
27 changes: 23 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,29 @@ short-lived credential at publish time. Consequences worth knowing before you
touch the release path:

- There is no npm token, in repo secrets or anywhere else, and the package is
set to *disallow* tokens — this workflow is the only way to publish. Do not
add `NODE_AUTH_TOKEN` back: `setup-node`'s `registry-url` writes
`_authToken=${NODE_AUTH_TOKEN}` into `.npmrc`, and an empty value there can
read as configured-auth and suppress the OIDC exchange.
set to *disallow* tokens — this workflow is the only way to publish.
- **Do not add `registry-url` to the `setup-node` step.** npm runs the OIDC
exchange only when it finds no usable credential for the registry, and
`registry-url` makes setup-node write `_authToken=${NODE_AUTH_TOKEN}` into an
`.npmrc` unconditionally. Whether that line is harmless then turns on how the
variable expands, which is much too subtle to rely on:
- **undefined** (no `env:` at all) — the line survives as the literal string
`${NODE_AUTH_TOKEN}`, npm reads it as a credential, skips OIDC, and the run
dies with `E403` on `PUT` *after* all five gates have passed. That reads
like a permissions problem on npmjs.com and is not one. It cost us a failed
3.0.0 release.
- **empty string** (an `env:` naming a secret that does not exist) — expands
to an empty credential, npm falls through to OIDC, publish succeeds. This
is what the sibling repo `await-parallel-limit` does.

Omitting `registry-url` removes the question; npm defaults to
registry.npmjs.org regardless. See actions/setup-node#1551. The guard step
immediately before publish asserts no credential is configured and fails
fast, so this can never again surface as a late 403.
- `npm install -g npm@latest` before publishing is load-bearing, not hygiene:
Node 22 ships npm 10, which has no OIDC support at all and fails with
`ENEEDAUTH`. That is the error `await-parallel-limit` hit on its first
release attempt.
- Renaming `publish.yml`, or moving the publish step into a different workflow
file, breaks publishing until the trusted publisher is updated to match.
- The tag must agree with `package.json` — a guard step fails the run otherwise.
Expand Down
Loading