|
| 1 | +--- |
| 2 | +name: npm-trusted-publisher-matches-entry-workflow-not-reusable |
| 3 | +description: npm OIDC trusted publishing matches the "Workflow filename" against the ENTRY workflow that initiated the run, NOT the reusable workflow where `npm publish` actually executes. If release.yml is invoked via `workflow_call` from release-please.yml, you must register release-please.yml as the trusted publisher — registering release.yml silently 404s the OIDC token exchange and falls back to an unauthenticated (failing) publish. |
| 4 | +metadata: |
| 5 | + type: convention |
| 6 | + category: conventions |
| 7 | +tags: [release, npm, oidc, trusted-publishing, github-actions, workflow-call, provenance] |
| 8 | +discovered: 2026-05-28 |
| 9 | +session: session-88b46e |
| 10 | +related: |
| 11 | + - release-published-event-needs-pat-or-inline |
| 12 | + - vendored-artifact-bump-must-revendor-in-same-pr |
| 13 | +--- |
| 14 | + |
| 15 | +# npm trusted publisher matches the ENTRY workflow, not the reusable one |
| 16 | + |
| 17 | +## The symptom |
| 18 | + |
| 19 | +npm publish via OIDC trusted publishing fails with: |
| 20 | + |
| 21 | +``` |
| 22 | +WARN Skipped OIDC: ERR_PNPM_AUTH_TOKEN_EXCHANGE: Failed token exchange request ... (status code 404) |
| 23 | +📦 @opencodehub/scanners@0.2.0 → https://registry.npmjs.org/ |
| 24 | +ERR E404 404 Not Found - PUT https://registry.npmjs.org/@opencodehub%2fscanners - Not found |
| 25 | +``` |
| 26 | + |
| 27 | +The 404 on the **token exchange** is the tell: the OIDC claim npm received didn't match any configured trusted publisher, so pnpm got no token, fell back to an unauthenticated publish, and the `PUT` 404'd. |
| 28 | + |
| 29 | +## Root cause |
| 30 | + |
| 31 | +npm matches the trusted-publisher "Workflow filename" against the **workflow that initiated the run** (`workflow_ref` / the entry workflow), NOT the workflow that contains the `npm publish` command. npm's own docs call this out: *"Some workflows use `workflow_call` to invoke other workflows that run `npm publish`... validation checks the calling workflow's name instead of the workflow that actually contains the publish command."* |
| 32 | + |
| 33 | +This repo's release flow is: |
| 34 | + |
| 35 | +``` |
| 36 | +push:main → release-please.yml (ENTRY workflow — what npm sees in the OIDC claim) |
| 37 | + └─ uses: ./.github/workflows/release.yml (workflow_call → reusable) |
| 38 | + └─ npm-publish job runs `pnpm -r publish --provenance` |
| 39 | +``` |
| 40 | + |
| 41 | +So the OIDC claim carries `release-please.yml`. The trusted publisher was registered for `release.yml` (where publish *runs*) → no match → 404. |
| 42 | + |
| 43 | +## Why it was invisible |
| 44 | + |
| 45 | +The ONLY release.yml runs that ever published successfully were `workflow_dispatch` (manual) — because a manual dispatch makes `release.yml` itself the entry workflow, which matched the registration. Every automated `push → release-please → workflow_call` run silently failed to publish. Result: npm sat on `@opencodehub/cli@0.4.0` while the repo had long since tagged 0.5.x. Check this whenever "the tags exist but npm is behind." |
| 46 | + |
| 47 | +## The fix |
| 48 | + |
| 49 | +Register the **entry** workflow as the trusted publisher filename: |
| 50 | + |
| 51 | +``` |
| 52 | +npm package → Settings → Trusted Publisher → Workflow filename: |
| 53 | + release.yml ✗ (where publish runs) |
| 54 | + release-please.yml ✓ (what triggers the run) |
| 55 | +``` |
| 56 | + |
| 57 | +Also required (npm enforces it): `id-token: write` on BOTH the parent job (the `release` job in release-please.yml that does `uses: ./.github/workflows/release.yml`) AND the child (the npm-publish job in release.yml). This repo already had both. |
| 58 | + |
| 59 | +## Operational pain to plan for |
| 60 | + |
| 61 | +- Trusted-publisher config is **web-UI only**, no API/CLI, and **passkey/2FA-gated per save**. With N packages it's N manual saves. This monorepo has **17 publishable packages** (`packages/*` minus `docs`, which is `private: true`), so it's 17 saves. Do them back-to-back to ride the authenticator's warm-credential window. |
| 62 | +- **Each package has exactly one trusted publisher** — no org-level or account-level setting applies to all at once. Changing the filename means re-saving all 17. |
| 63 | +- Tradeoff: registering `release-please.yml` means a manual `workflow_dispatch` of `release.yml` (entry = release.yml) will STOP matching. The automated flow is the one that matters, so that's the right trade; treat manual dispatch as admin-only. |
| 64 | + |
| 65 | +## Why not just add a PAT instead? |
| 66 | + |
| 67 | +A `RELEASE_PLEASE_PAT` would let release-please cut a release that fires `release: published`, making `release.yml` the entry workflow (matching the old registration). But that reintroduces a long-lived token this repo deliberately removed (see [[release-published-event-needs-pat-or-inline]] — the `workflow_call` design exists precisely to avoid the PAT). For an OIDC-only, Sigstore + SLSA-L3 repo, fixing the npm registration is the hardening-preserving choice; the PAT is a regression. |
| 68 | + |
| 69 | +## Linked |
| 70 | + |
| 71 | +- [[release-published-event-needs-pat-or-inline]] — the sibling decision: why the flow uses `workflow_call` (no PAT) in the first place. THIS lesson is the npm-side consequence of that choice. |
| 72 | +- npm docs: https://docs.npmjs.com/trusted-publishers |
| 73 | +- Session 2026-05-28: diagnosed during the v0.6.2 → v0.6.3 release recovery. |
0 commit comments