Skip to content

Commit e17aa58

Browse files
joshuayoesclaude
andauthored
chore(ci): migrate npm publish to trusted publishing via OIDC (#1609)
## Please verify the following: - [x] `yarn build-and-test:local` passes - [ ] I have added tests for any new features, if relevant - [x] `README.md` (or relevant documentation) has been updated with your changes ## Describe your PR Migrates the reactotron CI publish pipeline from classic `NPM_TOKEN` auth to **npm Trusted Publishing via CircleCI OIDC**. npm GA'd CircleCI support on [2026-04-06](https://github.blog/changelog/2026-04-06-npm-trusted-publishing-now-supports-circleci/); this PR wires us up. The pipeline has been broken since npm revoked classic tokens on 2025-12-09 and #1602 left the renamed `reactotron-npm-context` with an empty `NPM_TOKEN`. Rather than mint a granular replacement (capped at 90 days) and rotate forever, OIDC eliminates the human-managed token entirely. ### Changes - `.circleci/config.yml` (`release_package` job) — replaces the `npm whoami` + `~/.npmrc` token write with a "Mint npm OIDC token" step using `circleci run oidc get --claims '{"aud":"npm:registry.npmjs.org"}'`. - `scripts/release.artifacts.mjs` — accepts either `NPM_TOKEN` or `NPM_ID_TOKEN`, and performs the npm OIDC token exchange directly (`POST /-/npm/v1/oidc/token/exchange/package/<ident>`). This in-script exchange is a workaround for [yarnpkg/berry#7122](yarnpkg/berry#7122): Yarn 4.14.1's `getOidcToken` helper handles CircleCI, but the `allowOidc` gate in `publish.ts` only flips on for `GITHUB_ACTIONS` / `GITLAB_CI`. Once 7122 lands and we bump Yarn, the script-level exchange block can be deleted. - Yarn `4.1.1 → 4.14.1` (4.14 brought the CircleCI OIDC support that 7122 finishes wiring up). - `.yarnrc.yml` — keeps `npmAuthToken: "${NPM_TOKEN-}"` as a soft fallback during cutover. Empty string is falsy in Yarn's auth chain, so it's a no-op when OIDC is in play. Removed in a follow-up PR after first prod publish. - `docs/contributing/releasing.md` — new "OIDC publish flow" section covering the CI flow, how to add a trusted publisher when shipping a new package, and the upstream Yarn workaround. ### Pilot validation Pilot package: `eslint-plugin-reactotron@0.1.10-beta.0`. Pushed tag from branch `test/oidc-pilot-eslint`, published under the `beta` dist-tag (semver prerelease — invisible to `^`/`~`/`*` ranges). Pipeline succeeded; package published. The npm registry metadata for the pilot version records: ```json "_npmUser": { "name": "CircleCI", "email": "npm-oidc-no-reply@github.com", "trustedPublisher": { "id": "circleci", "oidcConfigId": "oidc:71dbce82-a25e-4b17-a0e3-78908cc0e805" } } ``` This is the smoking-gun proof the publish ran via the trusted publisher path, not legacy token auth. `_npmVersion: null` and `_nodeVersion: null` (set when the publisher is OIDC) corroborate. - npm: https://www.npmjs.com/package/eslint-plugin-reactotron/v/0.1.10-beta.0 - GitHub release: https://github.com/infinitered/reactotron/releases/tag/eslint-plugin-reactotron%400.1.10-beta.0 - CircleCI pipeline: https://app.circleci.com/pipelines/gh/infinitered/reactotron/2762 ### Pre-merge prerequisites - ✅ Trusted publisher configured on all 11 npm packages (same Org / Project / Pipeline Definition / Context / VCS Origin tuple, validated by the pilot's successful publish). - ✅ "Publishing access" tightened to **"Require two-factor authentication and disallow tokens (recommended)"** on all 11 packages. Per npm's inline note, this is fully OIDC-compatible — short-lived OIDC publish tokens are a separate auth path. Out of scope: `reactotron-app` (Electron app, GitHub release artifacts only), `reactotron-mcp` (`"private": true`). ### Follow-up PRs After **first successful production OIDC publish**: - Remove `npmAuthToken: "${NPM_TOKEN-}"` from `.yarnrc.yml`. - Tighten `release.artifacts.mjs` to require only `NPM_ID_TOKEN`. - Clear `NPM_TOKEN` from CircleCI context (currently absent; belt-and-suspenders). After **yarnpkg/berry#7122 lands and we bump Yarn**: - Delete the OIDC exchange block in `scripts/release.artifacts.mjs` — Yarn handles the exchange directly via `yarn npm publish`. - Simplify the OIDC docs section. ### Notes - The chore commit only touches infra files (`.circleci/`, `.yarnrc.yml`, root `package.json`, `yarn.lock`, `scripts/release.artifacts.mjs`, `docs/`, `.yarn/releases/`). No `lib/*` or `apps/*` workspaces affected → 0 release tags created on merge → no auto-publish triggered. First real OIDC publish happens on the next legitimate code change inside a `lib/*` workspace. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9dcedf2 commit e17aa58

8 files changed

Lines changed: 1075 additions & 905 deletions

File tree

.circleci/config.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,11 +280,13 @@ jobs:
280280
- checkout
281281
- install-packages
282282
- run:
283-
name: Release to npm and github
283+
name: Mint npm OIDC token
284284
command: |
285-
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
286-
npm whoami
287-
yarn release:artifacts $CIRCLE_TAG
285+
NPM_ID_TOKEN=$(circleci run oidc get --claims '{"aud":"npm:registry.npmjs.org"}')
286+
echo "export NPM_ID_TOKEN=$NPM_ID_TOKEN" >> $BASH_ENV
287+
- run:
288+
name: Release to npm and github
289+
command: yarn release:artifacts $CIRCLE_TAG
288290

289291
build_app_windows:
290292
<<: *defaults

.yarn/releases/yarn-4.1.1.cjs

Lines changed: 0 additions & 893 deletions
This file was deleted.

.yarn/releases/yarn-4.14.1.cjs

Lines changed: 940 additions & 0 deletions
Large diffs are not rendered by default.

.yarnrc.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
approvedGitRepositories:
2+
- "**"
3+
14
compressionLevel: mixed
25

36
enableGlobalCache: false
47

8+
enableScripts: true
9+
510
nodeLinker: node-modules
6-
# allow NPM_TOKEN env to be set, but provide '' as fallback. This allows CI to publish packages, but not require env var.
7-
# See https://yarnpkg.com/configuration/yarnrc
11+
812
npmAuthToken: "${NPM_TOKEN-}"
913

1014
npmPublishAccess: public
1115

12-
yarnPath: .yarn/releases/yarn-4.1.1.cjs
16+
yarnPath: .yarn/releases/yarn-4.14.1.cjs

docs/contributing/releasing.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,36 @@ CircleCi is used to run the release tasks. The `config.yml` file is located in t
6868
CircleCi is configured to check for whether new release commits and tags are needed on every commit to a release branch: `master`, `beta`, and `alpha`.
6969

7070
Once a new release tag is created, CircleCi will run a job to publish the artifacts for the workspace.
71+
72+
## npm Authentication (Trusted Publishing via OIDC)
73+
74+
npm packages are published using [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/) — CircleCI mints a short-lived OIDC token that Yarn 4.14+ exchanges for a single-use publish token. There is no long-lived `NPM_TOKEN` to rotate.
75+
76+
### How it works in CI
77+
78+
The `release_package` job in `.circleci/config.yml` runs two steps:
79+
80+
1. **Mint npm OIDC token** — runs `circleci run oidc get --claims '{"aud":"npm:registry.npmjs.org"}'` and exports the result as `NPM_ID_TOKEN`.
81+
2. **Release to npm and github**`yarn release:artifacts $CIRCLE_TAG` calls `scripts/release.artifacts.mjs`. The script (not Yarn) exchanges `NPM_ID_TOKEN` for a single-use npm publish token via a direct `POST` to `https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/<name>`, then exposes the result as `NPM_TOKEN` so `.yarnrc.yml`'s `npmAuthToken: "${NPM_TOKEN-}"` picks it up at config-load time. Finally it invokes `yarn npm publish`.
82+
83+
The reason the script does the exchange rather than Yarn: Yarn 4.14.1's `yarn npm publish` gates the OIDC code path on `GITHUB_ACTIONS || GITLAB_CI`, even though its `getOidcToken` helper already handles `CIRCLECI`. The companion fix is tracked upstream at [yarnpkg/berry#7122](https://github.com/yarnpkg/berry/pull/7122). Once Yarn ships the one-line gate fix and we bump, the exchange block in `release.artifacts.mjs` can be deleted and `yarn npm publish` will pick up `NPM_ID_TOKEN` directly.
84+
85+
The job still requires the `reactotron-npm-context` CircleCI context for `$GITHUB_TOKEN` (used to create the GitHub release).
86+
87+
### Adding a trusted publisher to a new package
88+
89+
When publishing a new `reactotron-*` package, configure its Trusted Publisher on npm before the first release tag, otherwise the publish will fail with a "no trusted publisher configured" error.
90+
91+
For each package:
92+
93+
1. Navigate to `https://www.npmjs.com/package/<pkg>/access`.
94+
2. Scroll to "Trusted Publisher" and select **CircleCI**.
95+
3. Fill in the org/project/context IDs (ask a maintainer for current values; they live in the CircleCI project settings, not in this repo).
96+
4. Save.
97+
98+
`npm trust` (npm v11.10.0+) supports batch configuration after `npm login`. See [npm bulk trusted publishing config](https://github.blog/changelog/2026-02-18-npm-bulk-trusted-publishing-config-and-script-security-now-generally-available/).
99+
100+
### Out-of-scope packages
101+
102+
- `reactotron-app` is published as GitHub release artifacts, not to npm.
103+
- `reactotron-mcp` is private (`"private": true`) and intentionally not on npm. An unrelated `reactotron-mcp` package by `steve228uk` exists on npm — that is his own project and is not affiliated with Infinite Red.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,5 @@
7979
"build-and-test:local": "yarn build && yarn package:validate && yarn lint && yarn format:check && yarn test && yarn typecheck",
8080
"package:validate": "zx scripts/package.validate.mjs"
8181
},
82-
"packageManager": "yarn@4.1.1"
82+
"packageManager": "yarn@4.14.1"
8383
}

scripts/release.artifacts.mjs

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,99 @@ if (workspacePkg.private) {
6262
}
6363
// #endregion
6464

65-
// #region assert NPM_TOKEN is set
65+
// #region assert npm credentials are set
6666
if (isCi) {
67-
if (typeof process.env.NPM_TOKEN !== "string" || process.env.NPM_TOKEN === "") {
68-
console.error("NPM_TOKEN environment variable is required")
67+
const hasToken = typeof process.env.NPM_TOKEN === "string" && process.env.NPM_TOKEN !== ""
68+
const hasOidcToken =
69+
typeof process.env.NPM_ID_TOKEN === "string" && process.env.NPM_ID_TOKEN !== ""
70+
if (!hasToken && !hasOidcToken) {
71+
console.error("Either NPM_TOKEN or NPM_ID_TOKEN environment variable is required")
6972
process.exit(1)
7073
}
7174
}
7275
// #endregion
7376

77+
// #region npm OIDC exchange (workaround for yarn 4.14.1 CircleCI gating bug)
78+
/**
79+
* Yarn 4.14.1's `yarn npm publish` only enables the OIDC code path when one of
80+
* `GITHUB_ACTIONS` or `GITLAB_CI` is set, even though the underlying
81+
* `getOidcToken` already handles `CIRCLECI` correctly (see PR yarnpkg/berry#7075).
82+
* Until the upstream fix lands (https://github.com/yarnpkg/berry/pull/7122),
83+
* we exchange the OIDC id-token for a single-use npm publish token here and
84+
* surface it via NPM_TOKEN, which `.yarnrc.yml`'s `npmAuthToken: "${NPM_TOKEN-}"`
85+
* picks up at config-load time.
86+
*
87+
* Endpoint reference (from yarn's own implementation):
88+
* https://github.com/yarnpkg/berry/blob/7469b9c/packages/plugin-npm/sources/npmHttpUtils.ts#L629
89+
*
90+
* Skipped when:
91+
* - NPM_TOKEN is already set (caller-supplied; preserves legacy behavior)
92+
* - NPM_ID_TOKEN is missing (the assertion above will have failed in CI)
93+
*/
94+
const hasNpmToken = typeof process.env.NPM_TOKEN === "string" && process.env.NPM_TOKEN !== ""
95+
const hasNpmIdToken =
96+
typeof process.env.NPM_ID_TOKEN === "string" && process.env.NPM_ID_TOKEN !== ""
97+
if (isCi && !hasNpmToken && hasNpmIdToken) {
98+
// npm exchange endpoint expects POST {registry}/-/npm/v1/oidc/token/exchange/package/{name}
99+
// with `Authorization: Bearer <id-token>` and an empty body. All reactotron-* packages
100+
// are unscoped, so the ident path is just `/<name>`.
101+
const npmRegistry = "https://registry.npmjs.org"
102+
const exchangeUrl = `${npmRegistry}/-/npm/v1/oidc/token/exchange/package/${npmWorkspace}`
103+
104+
console.log(`Exchanging NPM_ID_TOKEN for a publish token via ${exchangeUrl}`)
105+
106+
// Disable zx's verbose mode for this call: zx wraps `fetch` and, when verbose,
107+
// logs the full `init` — including the `Authorization: Bearer …` header — to
108+
// stderr. Restore the prior verbosity setting after the call regardless of
109+
// outcome.
110+
const previousVerbose = $.verbose
111+
$.verbose = false
112+
113+
/** @type {Response} */
114+
let exchangeResponse
115+
try {
116+
exchangeResponse = await fetch(exchangeUrl, {
117+
method: "POST",
118+
headers: {
119+
Authorization: `Bearer ${process.env.NPM_ID_TOKEN}`,
120+
},
121+
})
122+
} catch (error) {
123+
$.verbose = previousVerbose
124+
const message = error instanceof Error ? error.message : String(error)
125+
console.error(`npm OIDC token exchange failed: network error: ${message}`)
126+
process.exit(1)
127+
}
128+
$.verbose = previousVerbose
129+
130+
if (!exchangeResponse.ok) {
131+
// Body intentionally omitted from logs — it may include sensitive details.
132+
console.error(
133+
`npm OIDC token exchange failed: HTTP ${exchangeResponse.status} ${exchangeResponse.statusText}`
134+
)
135+
process.exit(1)
136+
}
137+
138+
/** @type {{ token?: string }} */
139+
let exchangeBody
140+
try {
141+
exchangeBody = await exchangeResponse.json()
142+
} catch (error) {
143+
const message = error instanceof Error ? error.message : String(error)
144+
console.error(`npm OIDC token exchange failed: malformed JSON response: ${message}`)
145+
process.exit(1)
146+
}
147+
148+
if (typeof exchangeBody.token !== "string" || exchangeBody.token === "") {
149+
console.error(`npm OIDC token exchange failed: response did not contain a token`)
150+
process.exit(1)
151+
}
152+
153+
process.env.NPM_TOKEN = exchangeBody.token
154+
console.log(`Exchanged NPM_ID_TOKEN for a single-use npm publish token`)
155+
}
156+
// #endregion
157+
74158
// #region extract changelog entry for this version
75159
/**
76160
* Gets the changelog entry for a specific version

yarn.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Manual changes might be lost - proceed with caution!
33

44
__metadata:
5-
version: 8
5+
version: 9
66
cacheKey: 10
77

88
"7zip-bin@npm:~5.2.0":

0 commit comments

Comments
 (0)