Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,13 @@ jobs:
- checkout
- install-packages
- run:
name: Release to npm and github
name: Mint npm OIDC token
command: |
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
npm whoami
yarn release:artifacts $CIRCLE_TAG
NPM_ID_TOKEN=$(circleci run oidc get --claims '{"aud":"npm:registry.npmjs.org"}')
echo "export NPM_ID_TOKEN=$NPM_ID_TOKEN" >> $BASH_ENV
- run:
name: Release to npm and github
command: yarn release:artifacts $CIRCLE_TAG

build_app_windows:
<<: *defaults
Expand Down
893 changes: 0 additions & 893 deletions .yarn/releases/yarn-4.1.1.cjs

This file was deleted.

940 changes: 940 additions & 0 deletions .yarn/releases/yarn-4.14.1.cjs

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
approvedGitRepositories:
- "**"

compressionLevel: mixed

enableGlobalCache: false

enableScripts: true

nodeLinker: node-modules
# allow NPM_TOKEN env to be set, but provide '' as fallback. This allows CI to publish packages, but not require env var.
# See https://yarnpkg.com/configuration/yarnrc

npmAuthToken: "${NPM_TOKEN-}"

npmPublishAccess: public

yarnPath: .yarn/releases/yarn-4.1.1.cjs
yarnPath: .yarn/releases/yarn-4.14.1.cjs
33 changes: 33 additions & 0 deletions docs/contributing/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,36 @@ CircleCi is used to run the release tasks. The `config.yml` file is located in t
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`.

Once a new release tag is created, CircleCi will run a job to publish the artifacts for the workspace.

## npm Authentication (Trusted Publishing via OIDC)

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.

### How it works in CI

The `release_package` job in `.circleci/config.yml` runs two steps:

1. **Mint npm OIDC token** — runs `circleci run oidc get --claims '{"aud":"npm:registry.npmjs.org"}'` and exports the result as `NPM_ID_TOKEN`.
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`.

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.

The job still requires the `reactotron-npm-context` CircleCI context for `$GITHUB_TOKEN` (used to create the GitHub release).

### Adding a trusted publisher to a new package

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.

For each package:

1. Navigate to `https://www.npmjs.com/package/<pkg>/access`.
2. Scroll to "Trusted Publisher" and select **CircleCI**.
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).
4. Save.

`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/).

### Out-of-scope packages

- `reactotron-app` is published as GitHub release artifacts, not to npm.
- `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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,5 @@
"build-and-test:local": "yarn build && yarn package:validate && yarn lint && yarn format:check && yarn test && yarn typecheck",
"package:validate": "zx scripts/package.validate.mjs"
},
"packageManager": "yarn@4.1.1"
"packageManager": "yarn@4.14.1"
}
90 changes: 87 additions & 3 deletions scripts/release.artifacts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,99 @@ if (workspacePkg.private) {
}
// #endregion

// #region assert NPM_TOKEN is set
// #region assert npm credentials are set
if (isCi) {
if (typeof process.env.NPM_TOKEN !== "string" || process.env.NPM_TOKEN === "") {
console.error("NPM_TOKEN environment variable is required")
const hasToken = typeof process.env.NPM_TOKEN === "string" && process.env.NPM_TOKEN !== ""
const hasOidcToken =
typeof process.env.NPM_ID_TOKEN === "string" && process.env.NPM_ID_TOKEN !== ""
if (!hasToken && !hasOidcToken) {
console.error("Either NPM_TOKEN or NPM_ID_TOKEN environment variable is required")
process.exit(1)
}
}
// #endregion

// #region npm OIDC exchange (workaround for yarn 4.14.1 CircleCI gating bug)
/**
* Yarn 4.14.1's `yarn npm publish` only enables the OIDC code path when one of
* `GITHUB_ACTIONS` or `GITLAB_CI` is set, even though the underlying
* `getOidcToken` already handles `CIRCLECI` correctly (see PR yarnpkg/berry#7075).
* Until the upstream fix lands (https://github.com/yarnpkg/berry/pull/7122),
* we exchange the OIDC id-token for a single-use npm publish token here and
* surface it via NPM_TOKEN, which `.yarnrc.yml`'s `npmAuthToken: "${NPM_TOKEN-}"`
* picks up at config-load time.
*
* Endpoint reference (from yarn's own implementation):
* https://github.com/yarnpkg/berry/blob/7469b9c/packages/plugin-npm/sources/npmHttpUtils.ts#L629
*
* Skipped when:
* - NPM_TOKEN is already set (caller-supplied; preserves legacy behavior)
* - NPM_ID_TOKEN is missing (the assertion above will have failed in CI)
*/
const hasNpmToken = typeof process.env.NPM_TOKEN === "string" && process.env.NPM_TOKEN !== ""
const hasNpmIdToken =
typeof process.env.NPM_ID_TOKEN === "string" && process.env.NPM_ID_TOKEN !== ""
if (isCi && !hasNpmToken && hasNpmIdToken) {
// npm exchange endpoint expects POST {registry}/-/npm/v1/oidc/token/exchange/package/{name}
// with `Authorization: Bearer <id-token>` and an empty body. All reactotron-* packages
// are unscoped, so the ident path is just `/<name>`.
const npmRegistry = "https://registry.npmjs.org"
const exchangeUrl = `${npmRegistry}/-/npm/v1/oidc/token/exchange/package/${npmWorkspace}`

console.log(`Exchanging NPM_ID_TOKEN for a publish token via ${exchangeUrl}`)

// Disable zx's verbose mode for this call: zx wraps `fetch` and, when verbose,
// logs the full `init` — including the `Authorization: Bearer …` header — to
// stderr. Restore the prior verbosity setting after the call regardless of
// outcome.
const previousVerbose = $.verbose
$.verbose = false

/** @type {Response} */
let exchangeResponse
try {
exchangeResponse = await fetch(exchangeUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NPM_ID_TOKEN}`,
},
})
} catch (error) {
$.verbose = previousVerbose
const message = error instanceof Error ? error.message : String(error)
console.error(`npm OIDC token exchange failed: network error: ${message}`)
process.exit(1)
}
$.verbose = previousVerbose

if (!exchangeResponse.ok) {
// Body intentionally omitted from logs — it may include sensitive details.
console.error(
`npm OIDC token exchange failed: HTTP ${exchangeResponse.status} ${exchangeResponse.statusText}`
)
process.exit(1)
}

/** @type {{ token?: string }} */
let exchangeBody
try {
exchangeBody = await exchangeResponse.json()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`npm OIDC token exchange failed: malformed JSON response: ${message}`)
process.exit(1)
}

if (typeof exchangeBody.token !== "string" || exchangeBody.token === "") {
console.error(`npm OIDC token exchange failed: response did not contain a token`)
process.exit(1)
}

process.env.NPM_TOKEN = exchangeBody.token
console.log(`Exchanged NPM_ID_TOKEN for a single-use npm publish token`)
}
// #endregion

// #region extract changelog entry for this version
/**
* Gets the changelog entry for a specific version
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Manual changes might be lost - proceed with caution!

__metadata:
version: 8
version: 9
cacheKey: 10

"7zip-bin@npm:~5.2.0":
Expand Down
Loading