From f099fcd1470063d6d6d93fe6ade7e96718063bba Mon Sep 17 00:00:00 2001 From: Ondrej Machek Date: Fri, 24 Jul 2026 22:47:33 +0200 Subject: [PATCH] Drop registry-url so npm actually runs the OIDC exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3.0.0 publish run passed all five gates, signed a provenance statement, and then died with `E403 Forbidden - PUT /simple-builder`. The log contains no OIDC or trusted-publishing lines at all: npm never attempted the token exchange and issued the PUT unauthenticated. npm runs the 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 turns entirely on how the variable expands: - undefined (no `env:` at all) — the line survives as the literal string `${NODE_AUTH_TOKEN}`, which reads as a credential and suppresses OIDC - empty string (`env:` naming a secret that does not exist) — expands to an empty credential, npm falls through to OIDC and publishes The sibling repo await-parallel-limit publishes with provenance and no secrets on the second spelling, which is what confirmed the mechanism. Removing the `env:` block, as the previous commit did, moved us onto the first — so that change is what turned a working configuration into a 403. Omit registry-url instead: it removes the question rather than depending on an expansion rule, and npm defaults to registry.npmjs.org regardless. See actions/setup-node#1551. Add a guard step before publish that strips any _authToken line and asserts no credential is configured, never echoing the value. Without it this class of failure only surfaces as an opaque 403 at the very end, which reads like an npmjs.com permissions problem rather than a runner config one. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yml | 47 +++++++++++++++++++++++++++++------ AGENTS.md | 27 +++++++++++++++++--- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2e370b4..48329cd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 7028b7e..bc6dcc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.