0006. Publish to npm without a stored credential
Status: accepted; applies from 0.10.0
Context
Releases authenticated to npm with a granular access token held in the
NPM_TOKEN secret of the protected npm-publish environment. The token was
created on 2026-05-30 with 2FA bypass, write access to @ponchia/ui, and an
expiry of 2026-08-28.
The v0.10.0 release exposed the cost of that design. Every gate passed —
validation, cross-engine end-to-end, packed examples, publish preflight — the
maintainer approved the protected environment, and the publish then failed
with E404 on an existing package. E404 reads as "no such package", so the
first hypothesis was package metadata. The real cause was that the token had
expired eleven days earlier. A read-only whoami request would have returned
401 at any point in those eleven days, but nothing was asking.
Three properties of the old design combined into that outcome:
- The credential's validity was invisible until the most expensive moment in the pipeline, after roughly twenty minutes of gates and a human approval.
- npm's failure mode misattributes an authentication problem to the package, so the error text actively points investigation the wrong way.
- Expiry is silent. Nothing in the repository, the environment, or CI knew the date; it lived only in a note beside the token in the password manager.
The approval gate did not and could not help. A required reviewer releases the job; it does not check that the job can authenticate.
Two further constraints made "rotate the token and continue" unattractive. From August 2026 npm forbids tokens with 2FA bypass from performing token management, so a CI token can no longer mint or rotate its successor — every renewal is a manual browser session with a second factor. And npm has stated that direct token publishing will be reduced further, with trusted publishing as the endorsed path.
Trusted publishing was previously assessed as unavailable to this estate: npm
supports it only on provider-hosted runners, and most repositories here run on
self-hosted arc-vps-* runners because GitHub-hosted minutes are billing
blocked. That assessment does not hold for this repository. bronto-ui is
public, so GitHub-hosted minutes are free, and the release workflow already
runs entirely on ubuntu-latest.
Decision
Publish by trusted publishing (OIDC). No npm credential is stored.
GitHub mints a short-lived token for the workflow run; npm exchanges it for a
publish credential scoped to that run. The publisher is registered on npmjs.com
against a four-part identity: the organization Ponchia, the repository
bronto-ui, the workflow filename release.yml, and the environment
npm-publish. All four must match or the exchange is refused.
Two conditions are enforced by check:release rather than left to review,
because both fail only at publish time:
publish-npmraises the npm CLI past 11.5.1. Node 22 bundles npm 10.x, which predates OIDC support and falls back to looking for a token.publish-npmreferences neitherNODE_AUTH_TOKENnorsecrets.NPM_TOKEN. A reintroduced token takes precedence over OIDC, which would quietly restore the failure mode this decision removes.
Consequences
The expiry class of failure is gone: there is no long-lived credential, so there is no date to miss and no rotation to schedule. The blast radius of a compromised repository secret shrinks to nothing, because the secret does not exist. Provenance is generated automatically instead of depending on a flag.
The identity is now positional, which is the cost. Renaming release.yml,
moving the publish to another environment, or transferring the repository
breaks publishing until the npm-side registration is updated — a rename that
is otherwise routine becomes a release-blocking change. The workflow header
and docs/architecture.md both say so at the point where someone would make
that edit.
Registration itself remains a browser action requiring the maintainer's second factor. That is a one-time cost rather than a recurring one, which is the whole point.
The NPM_TOKEN secret and its password-manager entry are retired after the
first successful trusted publish, not before, so the previous path stays
available if the exchange needs debugging.
Like every other ADR, this record ships in the package. check:pack enforces
that: the published documentation points into docs/adr/, so an ADR that
exists but is not in files leaves a consumer following that pointer at a
404. The decision set is published whole or not at all — a release-mechanics
record is not carved out on the grounds that consumers are unlikely to want
it.