From e192430a25bfd344a55a470a6bbc1ee1a9b9d1c3 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry Date: Tue, 31 Mar 2026 15:59:16 -0400 Subject: [PATCH] feat: Generic & CircleCI Trusted Publishing - add generic support for trusted publishing via NPM_ID_TOKEN to decouple semantic release from CI platforms. - document trusted publishing with CircleCI --- README.md | 29 ++++++++++-- lib/definitions/constants.js | 1 - lib/trusted-publishing/token-exchange.js | 24 +++------- .../trusted-publishing/token-exchange.test.js | 44 +++++++++++++------ 4 files changed, 62 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 261fca76..70915f51 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,28 @@ id_tokens: See the [npm documentation for more details about configuring pipeline details](https://docs.npmjs.com/trusted-publishers#gitlab-cicd-configuration) +#### Trusted publishing for CircleCI + +To use trusted publishing in [CircleCI](https://docs.npmjs.com/trusted-publishers#circleci-configuration) you need to set the environment variable NPM_ID_TOKEN to an NPM Identity Token + +```yaml +jobs: + semantic-release: + steps: + # ... + - run: + name: Semantic Release with Trusted Publishing via OIDC + command: | + # Fetch the OIDC token with the correct audience for npm + export NPM_ID_TOKEN=$(circleci run oidc get --claims '{"aud": "npm:registry.npmjs.org"}') + # Run semantic release + npx semantic-release +``` + +#### Generic Trusted Publishing + +The generic mechanism for telling semantic release and NPM to use trusted publishing is to set the NPM_ID_TOKEN. This generic approach is meant for use with CI platforms that have implemented trusted publishing support with NPM, but don't have specific support in semantic-release. If token exchange fails it will fall back to using Token authentication. + #### Unsupported CI providers Token authentication is **required** and can be set via [environment variables](#environment-variables). @@ -97,9 +119,10 @@ See the documentation for your registry for details on how to create a token for ### Environment variables -| Variable | Description | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `NPM_TOKEN` | Npm token created via [npm token create](https://docs.npmjs.com/getting-started/working_with_tokens#how-to-create-new-tokens) | +| Variable | Description | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NPM_TOKEN` | Npm token created via [npm token create](https://docs.npmjs.com/getting-started/working_with_tokens#how-to-create-new-tokens) | +| `NPM_ID_TOKEN` | OIDC identity token for [trusted publishing](https://docs.npmjs.com/trusted-publishers). Must be configured in your CI job (see [GitLab](#trusted-publishing-for-gitlab-pipelines), [CircleCI](#trusted-publishing-for-circleci)). Takes priority over CI-specific token retrieval when set. | ### Options diff --git a/lib/definitions/constants.js b/lib/definitions/constants.js index 6c651a62..bb9c5782 100644 --- a/lib/definitions/constants.js +++ b/lib/definitions/constants.js @@ -1,4 +1,3 @@ export const OFFICIAL_REGISTRY = "https://registry.npmjs.org/"; export const GITHUB_ACTIONS_PROVIDER_NAME = "GitHub Actions"; -export const GITLAB_PIPELINES_PROVIDER_NAME = "GitLab CI/CD"; diff --git a/lib/trusted-publishing/token-exchange.js b/lib/trusted-publishing/token-exchange.js index 899560b2..b6d998f0 100644 --- a/lib/trusted-publishing/token-exchange.js +++ b/lib/trusted-publishing/token-exchange.js @@ -1,11 +1,7 @@ import { getIDToken } from "@actions/core"; import envCi from "env-ci"; -import { - OFFICIAL_REGISTRY, - GITHUB_ACTIONS_PROVIDER_NAME, - GITLAB_PIPELINES_PROVIDER_NAME, -} from "../definitions/constants.js"; +import { OFFICIAL_REGISTRY, GITHUB_ACTIONS_PROVIDER_NAME } from "../definitions/constants.js"; async function exchangeIdToken(idToken, packageName, logger) { const response = await fetch( @@ -45,28 +41,18 @@ async function exchangeGithubActionsToken(packageName, logger) { return exchangeIdToken(idToken, packageName, logger); } -async function exchangeGitlabPipelinesToken(packageName, logger) { +export default function exchangeToken(pkg, { logger }) { const idToken = process.env.NPM_ID_TOKEN; - - logger.log("Verifying OIDC context for publishing from GitLab Pipelines"); - - if (!idToken) { - return undefined; + if (idToken) { + logger.log("Verifying OIDC context for publishing from an environment with NPM_ID_TOKEN set"); + return exchangeIdToken(idToken, pkg.name, logger); } - return exchangeIdToken(idToken, packageName, logger); -} - -export default function exchangeToken(pkg, { logger }) { const { name: ciProviderName } = envCi(); if (GITHUB_ACTIONS_PROVIDER_NAME === ciProviderName) { return exchangeGithubActionsToken(pkg.name, logger); } - if (GITLAB_PIPELINES_PROVIDER_NAME === ciProviderName) { - return exchangeGitlabPipelinesToken(pkg.name, logger); - } - return undefined; } diff --git a/test/trusted-publishing/token-exchange.test.js b/test/trusted-publishing/token-exchange.test.js index f9ea19bd..b07be4cb 100644 --- a/test/trusted-publishing/token-exchange.test.js +++ b/test/trusted-publishing/token-exchange.test.js @@ -1,11 +1,7 @@ import test from "ava"; import * as td from "testdouble"; -import { - OFFICIAL_REGISTRY, - GITHUB_ACTIONS_PROVIDER_NAME, - GITLAB_PIPELINES_PROVIDER_NAME, -} from "../../lib/definitions/constants.js"; +import { OFFICIAL_REGISTRY, GITHUB_ACTIONS_PROVIDER_NAME } from "../../lib/definitions/constants.js"; // https://api-docs.npmjs.com/#tag/registry.npmjs.org/operation/exchangeOidcToken @@ -69,9 +65,8 @@ test.serial("that `undefined` is returned when token exchange fails on GitHub Ac t.is(await exchangeToken(pkg, { logger }), undefined); }); -test.serial("that an access token is returned when token exchange succeeds on GitLab Pipelines", async (t) => { +test.serial("that an access token is returned when token exchange succeeds via NPM_ID_TOKEN", async (t) => { process.env.NPM_ID_TOKEN = idToken; - td.when(envCi()).thenReturn({ name: GITLAB_PIPELINES_PROVIDER_NAME }); td.when( fetch(`${OFFICIAL_REGISTRY}-/npm/v1/oidc/token/exchange/package/${encodeURIComponent(packageName)}`, { method: "POST", @@ -84,25 +79,48 @@ test.serial("that an access token is returned when token exchange succeeds on Gi t.is(await exchangeToken(pkg, { logger }), token); }); -test.serial("that `undefined` is returned when ID token is not available on GitLab Pipelines", async (t) => { - td.when(envCi()).thenReturn({ name: GITLAB_PIPELINES_PROVIDER_NAME }); +test.serial("that `undefined` is returned when token exchange fails via NPM_ID_TOKEN", async (t) => { + process.env.NPM_ID_TOKEN = idToken; + td.when( + fetch(`${OFFICIAL_REGISTRY}-/npm/v1/oidc/token/exchange/package/${encodeURIComponent(packageName)}`, { + method: "POST", + headers: { Authorization: `Bearer ${idToken}` }, + }) + ).thenResolve( + new Response(JSON.stringify({ message: "foo" }), { status: 401, headers: { "Content-Type": "application/json" } }) + ); t.is(await exchangeToken(pkg, { logger }), undefined); }); -test.serial("that `undefined` is returned when token exchange fails on GitLab Pipelines", async (t) => { +test.serial("that NPM_ID_TOKEN takes priority over GitHub Actions OIDC", async (t) => { process.env.NPM_ID_TOKEN = idToken; - td.when(envCi()).thenReturn({ name: GITLAB_PIPELINES_PROVIDER_NAME }); + td.when(envCi()).thenReturn({ name: GITHUB_ACTIONS_PROVIDER_NAME }); td.when( fetch(`${OFFICIAL_REGISTRY}-/npm/v1/oidc/token/exchange/package/${encodeURIComponent(packageName)}`, { method: "POST", headers: { Authorization: `Bearer ${idToken}` }, }) ).thenResolve( - new Response(JSON.stringify({ message: "foo" }), { status: 401, headers: { "Content-Type": "application/json" } }) + new Response(JSON.stringify({ token }), { status: 201, headers: { "Content-Type": "application/json" } }) ); - t.is(await exchangeToken(pkg, { logger }), undefined); + t.is(await exchangeToken(pkg, { logger }), token); +}); + +test.serial("that NPM_ID_TOKEN works on unknown CI providers", async (t) => { + process.env.NPM_ID_TOKEN = idToken; + td.when(envCi()).thenReturn({ name: "CircleCI" }); + td.when( + fetch(`${OFFICIAL_REGISTRY}-/npm/v1/oidc/token/exchange/package/${encodeURIComponent(packageName)}`, { + method: "POST", + headers: { Authorization: `Bearer ${idToken}` }, + }) + ).thenResolve( + new Response(JSON.stringify({ token }), { status: 201, headers: { "Content-Type": "application/json" } }) + ); + + t.is(await exchangeToken(pkg, { logger }), token); }); test.serial("that `undefined` is returned when no supported CI provider is detected", async (t) => {