Retrofit UI: a fight with npm
The first Retrofit UI post covered the framework itself
— the spec, the two builder implementations, the early prototypes. This one
is about something that turned into its own small project: getting
@retrofit-ui/core, @retrofit-ui/builder-zod, and
@retrofit-ui/spa-solid-shoelace onto npm, versioned automatically, without
hand-publishing every release.
Setting up automated publishing
The plan was Changesets plus GitHub Actions Trusted Publishing — OIDC-based
auth where GitHub’s Actions runner proves its identity to npm directly, no
long-lived NPM_TOKEN to generate, store as a secret, or rotate before it
expires. Each package got real publishing metadata (repository, homepage,
bugs, keywords, license, author), its own README and LICENSE, and a
release.yml workflow driven by changesets/action: push a changeset, the
bot opens a “Version Packages” PR, merge it, the packages publish.
Alongside that setup came a dependency-modeling correction that’s worth
calling out on its own. @retrofit-ui/core started out as a peer dependency
of the other two packages — the instinct you reach for when you don’t want a
consumer ending up with two copies of something. But that instinct comes from
runtime-instance coupling (the React model: two copies of React means two
incompatible component trees). Retrofit UI’s compatibility story is
protocol-level instead — JSON specs on the wire, reconciled into DOM by
whichever renderer receives them. There’s no shared runtime instance to
protect. Moving core to a regular dependency meant no more cascading major
version bumps on consumer packages every time core bumped a minor version,
and no npm install-time peer-dependency warnings for a constraint that was
never real in the first place. (Zod stayed a genuine peer dependency on
core/builder-zod — schema composition and instanceof checks are a
real shared-runtime concern, so that one was correctly modeled from the
start.)
The first release, and the auth saga
The first real release happened almost by accident — a commit polishing npm package listings (sharper descriptions, better discoverability keywords, realistic README examples) needed a version bump, which doubled as the trigger for the first end-to-end automated release. It failed: npm returned E404 on the Trusted Publishing attempt, despite the OIDC configuration checking out on both sides — decoded token claims matched what npm expected.
What followed was a real back-and-forth, not a single fallback:
- Switch to a plain
NPM_TOKEN, unblock the pipeline while investigating. - Revert back to Trusted Publishing — a theory forms that the real problem
is
@retrofit-uibeing an organization scope on npm rather than a user scope, and the Trusted Publisher entries needed org-appropriate permissions that hadn’t been set correctly the first time. - Back to
NPM_TOKENagain — OIDC still 404s. A second theory: some undocumented interaction between the org scope and the account’s auth-and-writes 2FA mode, likely needing an actual npm support ticket to fully resolve. - The actual root cause, finally: recent npm CLI versions, when
NPM_CONFIG_PROVENANCE=trueis set and an OIDC endpoint is reachable, silently prefer Trusted Publishing over whatever’s in.npmrc— even with a valid token sitting right there. Every earlier “fix TP” or “fall back to token” attempt was fighting the CLI’s own silent preference, not the auth mechanism itself. Turning provenance off explicitly forced the CLI down the token-auth path and it just worked. (Cost: no “Built and signed on GitHub Actions” provenance badge on npmjs.com, until Trusted Publishing gets sorted out for this account for real.)
Four attempts, three of which addressed a plausible-but-wrong theory, before the actual mechanism — a silent CLI preference, not a broken credential — got identified. The token-based auth from that point is still what’s live today.
Splitting build from publish
A smaller, security-motivated fix landed alongside the auth saga: the whole
pnpm build && changeset publish sequence originally ran as one step inside
the job where NPM_TOKEN was already in the environment — meaning every dev
dependency executed during the build (tsup, vite, their transitive
dependency trees) could, in principle, read that token. The fix splits it
into two steps: build first, with no token in scope at all, then a second
step running only changeset publish where the token is actually needed.
Small change, meaningfully smaller attack surface.
A bug the pipeline now guards against permanently
Weeks after publishing stabilized, @retrofit-ui/spa-solid-shoelace shipped
to npm with a workspace:^ dependency range instead of a resolved semver
version — the kind of thing that works fine inside the monorepo (where
workspace:^ means “whatever’s in this repo”) and is silently broken for
anyone installing the package for real, since workspace:^ isn’t a valid
range outside a workspace at all. The fix wasn’t just correcting that one
release: the same commit added a verify:publish script that packs each
public package and fails the release before anything reaches npm if a
workspace: range would leak into a published manifest. The bug becomes
structurally impossible to ship again, not just fixed once.
Where it stands
Three packages publish automatically now, on a pipeline that took real iteration to get right — a dependency model corrected before it caused cascading version bumps for consumers, an auth mechanism whose actual failure mode took four attempts to properly diagnose, a token given the smallest scope of trust the pipeline could manage, and a packaging bug that turned into a permanent guard rail instead of a one-off fix. None of this shows up in the framework’s feature set. All of it is the difference between “publish works today” and “publish works, and keeps working, without babysitting.”