Skip to content
Open
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
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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

Expand Down
1 change: 0 additions & 1 deletion lib/definitions/constants.js
Original file line number Diff line number Diff line change
@@ -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";
24 changes: 5 additions & 19 deletions lib/trusted-publishing/token-exchange.js
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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;
}
44 changes: 31 additions & 13 deletions test/trusted-publishing/token-exchange.test.js
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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",
Expand All @@ -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) => {
Expand Down