diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21ca27c5..0e23e4f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ name: Release - master - next - beta + - alpha - "*.x" permissions: contents: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b3057c01..e5db92bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: node-version: - 22.14.0 - 22 - - 24 + # - 24 os: - ubuntu-latest runs-on: "${{ matrix.os }}" diff --git a/README.md b/README.md index b10f390a..261fca76 100644 --- a/README.md +++ b/README.md @@ -38,29 +38,28 @@ The plugin can be configured in the [**semantic-release** configuration file](ht ### npm registry authentication -The npm [token](https://docs.npmjs.com/about-access-tokens) authentication configuration is **required** and can be set via [environment variables](#environment-variables). +### Official Registry -Automation tokens are recommended since they can be used for an automated workflow, even when your account is configured to use the [`auth-and-writes` level of 2FA](https://docs.npmjs.com/about-two-factor-authentication#authorization-and-writes). +When publishing to the [official registry](https://registry.npmjs.org/), it is recommended to publish with authentication intended for automation: -### npm provenance +- For improved security, and since access tokens have recently had their [maximum lifetimes restricted](https://github.blog/changelog/2025-09-29-strengthening-npm-security-important-changes-to-authentication-and-token-management/), + [trusted publishing](https://docs.npmjs.com/trusted-publishers) is recommended when publishing from a [supported CI provider](https://docs.npmjs.com/trusted-publishers#supported-cicd-providers) +- [Granular access tokens](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-granular-access-tokens-on-the-website) are recommended when publishing from a CI provider that is not supported by npm for trusted publishing, and can be set via [environment variables](#environment-variables). + Because these access tokens expire, rotation will need to be accounted for in this scenario. -If you are publishing to the official registry and your pipeline is on a [provider that is supported by npm for provenance](https://docs.npmjs.com/generating-provenance-statements#provenance-limitations), npm can be configured to [publish with provenance](https://docs.npmjs.com/generating-provenance-statements). +> [!NOTE] +> When using trusted publishing, provenance attestations are automatically generated for your packages without requiring provenance to be explicitly enabled. -Since semantic-release wraps the npm publish command, configuring provenance is not exposed directly. -Instead, provenance can be configured through the [other configuration options exposed by npm](https://docs.npmjs.com/generating-provenance-statements#using-third-party-package-publishing-tools). -Provenance applies specifically to publishing, so our recommendation is to configure under `publishConfig` within the `package.json`. +#### Trusted publishing from GitHub Actions -#### npm provenance on GitHub Actions - -For package provenance to be signed on the GitHub Actions CI the following permission is required -to be enabled on the job: +To leverage trusted publishing and publish with provenance from GitHub Actions, the `id-token: write` permission is required to be enabled on the job: ```yaml permissions: - id-token: write # to enable use of OIDC for npm provenance + id-token: write # to enable use of OIDC for trusted publishing and npm provenance ``` -It's worth noting that if you are using semantic-release to its fullest with a GitHub release, GitHub comments, +It's also worth noting that if you are using semantic-release to its fullest with a GitHub release, GitHub comments, and other features, then [more permissions are required](https://github.com/semantic-release/github#github-authentication) to be enabled on this job: ```yaml @@ -68,11 +67,34 @@ permissions: contents: write # to be able to publish a GitHub release issues: write # to be able to comment on released issues pull-requests: write # to be able to comment on released pull requests - id-token: write # to enable use of OIDC for npm provenance + id-token: write # to enable use of OIDC for trusted publishing and npm provenance ``` Refer to the [GitHub Actions recipe for npm package provenance](https://semantic-release.gitbook.io/semantic-release/recipes/ci-configurations/github-actions#.github-workflows-release.yml-configuration-for-node-projects) for the full CI job's YAML code example. +#### Trusted publishing for GitLab Pipelines + +To leverage trusted publishing and publish with provenance from GitLab Pipelines, `NPM_ID_TOKEN` needs to be added as an entry under `id_tokens` in the job definition with an audience of `npm:registry.npmjs.org`: + +```yaml +id_tokens: + NPM_ID_TOKEN: + aud: "npm:registry.npmjs.org" +``` + +See the [npm documentation for more details about configuring pipeline details](https://docs.npmjs.com/trusted-publishers#gitlab-cicd-configuration) + +#### Unsupported CI providers + +Token authentication is **required** and can be set via [environment variables](#environment-variables). +[Granular access tokens](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-granular-access-tokens-on-the-website) are recommended in this scenario, since trusted publishing is not available from all CI providers. +Because these access tokens expire, rotation will need to be accounted for in your process. + +### Alternative Registries + +Token authentication is **required** and can be set via [environment variables](#environment-variables). +See the documentation for your registry for details on how to create a token for automation. + ### Environment variables | Variable | Description | @@ -97,13 +119,14 @@ The plugin uses the [`npm` CLI](https://github.com/npm/cli) which will read the The [`registry`](https://docs.npmjs.com/misc/registry) can be configured via the npm environment variable `NPM_CONFIG_REGISTRY` and will take precedence over the configuration in `.npmrc`. -The [`registry`](https://docs.npmjs.com/misc/registry) and [`dist-tag`](https://docs.npmjs.com/cli/dist-tag) can be configured under `publishConfig` in the `package.json`: +The [`registry`](https://docs.npmjs.com/misc/registry), [`dist-tag`](https://docs.npmjs.com/cli/dist-tag), and [`provenance`](https://docs.npmjs.com/generating-provenance-statements#using-third-party-package-publishing-tools) can be configured under `publishConfig` in the `package.json`: ```json { "publishConfig": { "registry": "https://registry.npmjs.org/", - "tag": "latest" + "tag": "latest", + "provenance": true } } ``` diff --git a/index.js b/index.js index 590eace3..d43ada7e 100644 --- a/index.js +++ b/index.js @@ -30,7 +30,7 @@ export async function verifyConditions(pluginConfig, context) { // Verify the npm authentication only if `npmPublish` is not false and `pkg.private` is not `true` if (pluginConfig.npmPublish !== false && pkg.private !== true) { - await verifyNpmAuth(npmrc, pkg, context); + await verifyNpmAuth(npmrc, pkg, pluginConfig, context); } } catch (error) { errors.push(...error.errors); @@ -50,7 +50,7 @@ export async function prepare(pluginConfig, context) { // Reload package.json in case a previous external step updated it const pkg = await getPkg(pluginConfig, context); if (!verified && pluginConfig.npmPublish !== false && pkg.private !== true) { - await verifyNpmAuth(npmrc, pkg, context); + await verifyNpmAuth(npmrc, pkg, pluginConfig, context); } } catch (error) { errors.push(...error.errors); @@ -72,7 +72,7 @@ export async function publish(pluginConfig, context) { // Reload package.json in case a previous external step updated it pkg = await getPkg(pluginConfig, context); if (!verified && pluginConfig.npmPublish !== false && pkg.private !== true) { - await verifyNpmAuth(npmrc, pkg, context); + await verifyNpmAuth(npmrc, pkg, pluginConfig, context); } } catch (error) { errors.push(...error.errors); @@ -97,7 +97,7 @@ export async function addChannel(pluginConfig, context) { // Reload package.json in case a previous external step updated it pkg = await getPkg(pluginConfig, context); if (!verified && pluginConfig.npmPublish !== false && pkg.private !== true) { - await verifyNpmAuth(npmrc, pkg, context); + await verifyNpmAuth(npmrc, pkg, pluginConfig, context); } } catch (error) { errors.push(...error.errors); diff --git a/lib/definitions/constants.js b/lib/definitions/constants.js new file mode 100644 index 00000000..6c651a62 --- /dev/null +++ b/lib/definitions/constants.js @@ -0,0 +1,4 @@ +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/definitions/errors.js b/lib/definitions/errors.js index aaa5056c..6458f6cd 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -37,11 +37,22 @@ Your configuration for the \`pkgRoot\` option is \`${pkgRoot}\`.`, export function ENONPMTOKEN({ registry }) { return { message: "No npm token specified.", - details: `An [npm token](${linkify( + details: `When not publishing through [trusted publishing](https://docs.npmjs.com/trusted-publishers), an [npm token](${linkify( "README.md#npm-registry-authentication" )}) must be created and set in the \`NPM_TOKEN\` environment variable on your CI environment. -Please make sure to create an [npm token](https://docs.npmjs.com/getting-started/working_with_tokens#how-to-create-new-tokens) and to set it in the \`NPM_TOKEN\` environment variable on your CI environment. The token must allow to publish to the registry \`${registry}\`.`, +Please make sure to create an [npm token](https://docs.npmjs.com/getting-started/working_with_tokens#how-to-create-new-tokens) and set it in the \`NPM_TOKEN\` environment variable on your CI environment. The token must allow publishing to the registry \`${registry}\`.`, + }; +} + +export function EINVALIDNPMAUTH({ registry }) { + return { + message: "Invalid npm authentication.", + details: `The [authentication required to publish](${linkify( + "README.md#npm-registry-authentication" + )}) configured in the \`NPM_TOKEN\` environment variable must be a valid [token](https://docs.npmjs.com/getting-started/working_with_tokens) allowed to publish to the registry \`${registry}\`. + +Please make sure to set the \`NPM_TOKEN\` environment variable in your CI with the exact value of the npm token.`, }; } @@ -52,10 +63,7 @@ export function EINVALIDNPMTOKEN({ registry }) { "README.md#npm-registry-authentication" )}) configured in the \`NPM_TOKEN\` environment variable must be a valid [token](https://docs.npmjs.com/getting-started/working_with_tokens) allowing to publish to the registry \`${registry}\`. -If you are using Two Factor Authentication for your account, set its level to ["Authorization only"](https://docs.npmjs.com/getting-started/using-two-factor-authentication#levels-of-authentication) in your account settings. **semantic-release** cannot publish with the default " -Authorization and writes" level. - -Please make sure to set the \`NPM_TOKEN\` environment variable in your CI with the exact value of the npm token.`, +Please verify your authentication configuration.`, }; } diff --git a/lib/get-registry.js b/lib/get-registry.js index 4cb42dea..360621eb 100644 --- a/lib/get-registry.js +++ b/lib/get-registry.js @@ -1,6 +1,7 @@ import path from "path"; import rc from "rc"; import getRegistryUrl from "registry-auth-token/registry-url.js"; +import { OFFICIAL_REGISTRY } from "./definitions/constants.js"; export default function ({ publishConfig: { registry } = {}, name }, { cwd, env }) { return ( @@ -8,11 +9,7 @@ export default function ({ publishConfig: { registry } = {}, name }, { cwd, env env.NPM_CONFIG_REGISTRY || getRegistryUrl( name.split("/")[0], - rc( - "npm", - { registry: "https://registry.npmjs.org/" }, - { config: env.NPM_CONFIG_USERCONFIG || path.resolve(cwd, ".npmrc") } - ) + rc("npm", { registry: OFFICIAL_REGISTRY }, { config: env.NPM_CONFIG_USERCONFIG || path.resolve(cwd, ".npmrc") }) ) ); } diff --git a/lib/get-release-info.js b/lib/get-release-info.js index 84546ac3..80a4e08c 100644 --- a/lib/get-release-info.js +++ b/lib/get-release-info.js @@ -1,8 +1,9 @@ import normalizeUrl from "normalize-url"; +import { OFFICIAL_REGISTRY } from "./definitions/constants.js"; export default function ( { name }, - { env: { DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/" }, nextRelease: { version } }, + { env: { DEFAULT_NPM_REGISTRY = OFFICIAL_REGISTRY }, nextRelease: { version } }, distTag, registry ) { diff --git a/lib/set-npmrc-auth.js b/lib/set-npmrc-auth.js index 6e92d080..a8534ca7 100644 --- a/lib/set-npmrc-auth.js +++ b/lib/set-npmrc-auth.js @@ -5,12 +5,13 @@ import getAuthToken from "registry-auth-token"; import nerfDart from "nerf-dart"; import AggregateError from "aggregate-error"; import getError from "./get-error.js"; +import { OFFICIAL_REGISTRY } from "./definitions/constants.js"; export default async function (npmrc, registry, { cwd, env: { NPM_TOKEN, NPM_CONFIG_USERCONFIG }, logger }) { logger.log("Verify authentication for registry %s", registry); - const { configs, ...rcConfig } = rc( + const { configs, config, ...rcConfig } = rc( "npm", - { registry: "https://registry.npmjs.org/" }, + { registry: OFFICIAL_REGISTRY }, { config: NPM_CONFIG_USERCONFIG || path.resolve(cwd, ".npmrc") } ); diff --git a/lib/trusted-publishing/oidc-context.js b/lib/trusted-publishing/oidc-context.js new file mode 100644 index 00000000..0427ae9d --- /dev/null +++ b/lib/trusted-publishing/oidc-context.js @@ -0,0 +1,6 @@ +import { OFFICIAL_REGISTRY } from "../definitions/constants.js"; +import exchangeToken from "./token-exchange.js"; + +export default async function oidcContextEstablished(registry, pkg, context) { + return OFFICIAL_REGISTRY === registry && !!(await exchangeToken(pkg, context)); +} diff --git a/lib/trusted-publishing/token-exchange.js b/lib/trusted-publishing/token-exchange.js new file mode 100644 index 00000000..899560b2 --- /dev/null +++ b/lib/trusted-publishing/token-exchange.js @@ -0,0 +1,72 @@ +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"; + +async function exchangeIdToken(idToken, packageName, logger) { + const response = await fetch( + `${OFFICIAL_REGISTRY}-/npm/v1/oidc/token/exchange/package/${encodeURIComponent(packageName)}`, + { + method: "POST", + headers: { Authorization: `Bearer ${idToken}` }, + } + ); + const responseBody = await response.json(); + + if (response.ok) { + logger.log("OIDC token exchange with the npm registry succeeded"); + + return responseBody.token; + } + + logger.log(`OIDC token exchange with the npm registry failed: ${response.status} ${responseBody.message}`); + + return undefined; +} + +async function exchangeGithubActionsToken(packageName, logger) { + let idToken; + + logger.log("Verifying OIDC context for publishing from GitHub Actions"); + + try { + idToken = await getIDToken("npm:registry.npmjs.org"); + } catch (e) { + logger.log(`Retrieval of GitHub Actions OIDC token failed: ${e.message}`); + logger.log("Have you granted the `id-token: write` permission to this workflow?"); + + return undefined; + } + + return exchangeIdToken(idToken, packageName, logger); +} + +async function exchangeGitlabPipelinesToken(packageName, logger) { + const idToken = process.env.NPM_ID_TOKEN; + + logger.log("Verifying OIDC context for publishing from GitLab Pipelines"); + + if (!idToken) { + return undefined; + } + + 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/lib/verify-auth.js b/lib/verify-auth.js index 99e138e9..7bb44ecb 100644 --- a/lib/verify-auth.js +++ b/lib/verify-auth.js @@ -1,33 +1,95 @@ import { execa } from "execa"; import normalizeUrl from "normalize-url"; import AggregateError from "aggregate-error"; -import getError from "./get-error.js"; import getRegistry from "./get-registry.js"; import setNpmrcAuth from "./set-npmrc-auth.js"; +import getError from "./get-error.js"; +import oidcContextEstablished from "./trusted-publishing/oidc-context.js"; +import { OFFICIAL_REGISTRY } from "./definitions/constants.js"; +import path from "path"; + +function registryIsDefault(registry, DEFAULT_NPM_REGISTRY) { + return normalizeUrl(registry) === normalizeUrl(DEFAULT_NPM_REGISTRY); +} -export default async function (npmrc, pkg, context) { +async function verifyAuthContextAgainstRegistry(npmrc, registry, context) { const { cwd, - env: { DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/", ...env }, + env: { DEFAULT_NPM_REGISTRY = OFFICIAL_REGISTRY, ...env }, stdout, stderr, } = context; - const registry = getRegistry(pkg, context); - await setNpmrcAuth(npmrc, registry, context); + try { + const whoamiResult = execa("npm", ["whoami", "--userconfig", npmrc, "--registry", registry], { + cwd, + env, + preferLocal: true, + }); + + whoamiResult.stdout.pipe(stdout, { end: false }); + whoamiResult.stderr.pipe(stderr, { end: false }); + + await whoamiResult; + } catch { + throw new AggregateError([getError("EINVALIDNPMTOKEN", { registry })]); + } +} + +async function attemptPublishDryRun(npmrc, registry, context, pkgRoot) { + const { + cwd, + env: { DEFAULT_NPM_REGISTRY = OFFICIAL_REGISTRY, ...env }, + stdout, + stderr, + } = context; + const basePath = pkgRoot ? path.resolve(cwd, pkgRoot) : cwd; - if (normalizeUrl(registry) === normalizeUrl(DEFAULT_NPM_REGISTRY)) { - try { - const whoamiResult = execa("npm", ["whoami", "--userconfig", npmrc, "--registry", registry], { - cwd, - env, - preferLocal: true, - }); - whoamiResult.stdout.pipe(stdout, { end: false }); - whoamiResult.stderr.pipe(stderr, { end: false }); - await whoamiResult; - } catch { - throw new AggregateError([getError("EINVALIDNPMTOKEN", { registry })]); + const publishDryRunResult = execa( + "npm", + [ + "publish", + basePath, + "--dry-run", + "--tag=semantic-release-auth-check", + "--userconfig", + npmrc, + "--registry", + registry, + ], + { cwd, env, preferLocal: true, lines: true } + ); + + publishDryRunResult.stdout.pipe(stdout, { end: false }); + publishDryRunResult.stderr.pipe(stderr, { end: false }); + + (await publishDryRunResult).stderr.forEach((line) => { + if (line.includes("This command requires you to be logged in to ")) { + throw new AggregateError([getError("EINVALIDNPMAUTH", { registry })]); } + }); +} + +async function verifyTokenAuth(registry, npmrc, context, pkgRoot) { + const { + env: { DEFAULT_NPM_REGISTRY = OFFICIAL_REGISTRY }, + } = context; + + if (registryIsDefault(registry, DEFAULT_NPM_REGISTRY)) { + await verifyAuthContextAgainstRegistry(npmrc, registry, context); + } else { + await attemptPublishDryRun(npmrc, registry, context, pkgRoot); } } + +export default async function (npmrc, pkg, { pkgRoot }, context) { + const registry = getRegistry(pkg, context); + + if (await oidcContextEstablished(registry, pkg, context)) { + return; + } + + await setNpmrcAuth(npmrc, registry, context); + + await verifyTokenAuth(registry, npmrc, context, pkgRoot); +} diff --git a/package-lock.json b/package-lock.json index 3ef8017f..b4587535 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0-development", "license": "MIT", "dependencies": { + "@actions/core": "^1.11.1", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", + "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", @@ -47,6 +49,41 @@ "semantic-release": ">=20.1.0" } }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -98,6 +135,15 @@ "node": ">=0.1.90" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -3555,7 +3601,6 @@ "version": "11.2.0", "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", - "dev": true, "license": "MIT", "dependencies": { "execa": "^8.0.0", @@ -3569,7 +3614,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", @@ -3593,7 +3637,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -3606,7 +3649,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=16.17.0" @@ -3616,7 +3658,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -3629,7 +3670,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^4.0.0" @@ -3645,7 +3685,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3658,7 +3697,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5835,7 +5873,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -6628,7 +6665,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, "license": "MIT" }, "node_modules/merge2": { @@ -6688,7 +6724,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10292,7 +10327,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^4.0.0" @@ -13536,6 +13570,15 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -13676,6 +13719,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", diff --git a/package.json b/package.json index a1af3947..64994f88 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "Gregor Martynus (https://twitter.com/gr2m)" ], "dependencies": { + "@actions/core": "^1.11.1", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", + "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", diff --git a/test/get-registry.test.js b/test/get-registry.test.js index 2cad03f7..e6d444e2 100644 --- a/test/get-registry.test.js +++ b/test/get-registry.test.js @@ -3,11 +3,12 @@ import test from "ava"; import fs from "fs-extra"; import { temporaryDirectory } from "tempy"; import getRegistry from "../lib/get-registry.js"; +import { OFFICIAL_REGISTRY } from "../lib/definitions/constants.js"; test("Get default registry", (t) => { const cwd = temporaryDirectory(); - t.is(getRegistry({ name: "package-name" }, { cwd, env: {} }), "https://registry.npmjs.org/"); - t.is(getRegistry({ name: "package-name", publishConfig: {} }, { cwd, env: {} }), "https://registry.npmjs.org/"); + t.is(getRegistry({ name: "package-name" }, { cwd, env: {} }), OFFICIAL_REGISTRY); + t.is(getRegistry({ name: "package-name", publishConfig: {} }, { cwd, env: {} }), OFFICIAL_REGISTRY); }); test('Get the registry configured in ".npmrc" and normalize trailing slash', async (t) => { diff --git a/test/get-release-info.test.js b/test/get-release-info.test.js index 951e2d24..9a7c5dc4 100644 --- a/test/get-release-info.test.js +++ b/test/get-release-info.test.js @@ -1,5 +1,6 @@ import test from "ava"; import getReleaseInfo from "../lib/get-release-info.js"; +import { OFFICIAL_REGISTRY } from "../lib/definitions/constants.js"; test("Default registry and scoped module", async (t) => { t.deepEqual( @@ -7,7 +8,7 @@ test("Default registry and scoped module", async (t) => { { name: "@scope/module" }, { env: {}, nextRelease: { version: "1.0.0" } }, "latest", - "https://registry.npmjs.org/" + OFFICIAL_REGISTRY ), { name: "npm package (@latest dist-tag)", diff --git a/test/integration.test.js b/test/integration.test.js index 613db916..6a912755 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -91,7 +91,7 @@ test('Skip npm token verification if "package.private" is true', async (t) => { ); }); -test("Throws error if NPM token is invalid", async (t) => { +test("Throws error if NPM token is invalid when targeting the default registry", async (t) => { const cwd = temporaryDirectory(); const env = { NPM_TOKEN: "wrong_token", DEFAULT_NPM_REGISTRY: npmRegistry.url }; const pkg = { name: "published", version: "1.0.0", publishConfig: { registry: npmRegistry.url } }; @@ -111,7 +111,7 @@ test("Throws error if NPM token is invalid", async (t) => { t.is(error.message, "Invalid npm token."); }); -test("Throws error if NPM token is not provided", async (t) => { +test("Throws error if NPM token is not provided when targeting the default registry", async (t) => { const cwd = temporaryDirectory(); const env = { DEFAULT_NPM_REGISTRY: npmRegistry.url }; const pkg = { name: "published", version: "1.0.0", publishConfig: { registry: npmRegistry.url } }; @@ -131,17 +131,24 @@ test("Throws error if NPM token is not provided", async (t) => { t.is(error.message, "No npm token specified."); }); -test("Skip Token validation if the registry configured is not the default one", async (t) => { +test("Verify the token with a publish dry-run if the registry configured is not the default one", async (t) => { const cwd = temporaryDirectory(); const env = { NPM_TOKEN: "wrong_token" }; const pkg = { name: "published", version: "1.0.0", publishConfig: { registry: "http://custom-registry.com/" } }; await fs.outputJson(path.resolve(cwd, "package.json"), pkg); - await t.notThrowsAsync( + + const { + errors: [error], + } = await t.throwsAsync( t.context.m.verifyConditions( {}, { cwd, env, options: {}, stdout: t.context.stdout, stderr: t.context.stderr, logger: t.context.logger } ) ); + + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDNPMAUTH"); + t.is(error.message, "Invalid npm authentication."); }); test("Verify npm auth and package", async (t) => { diff --git a/test/trusted-publishing/oidc-context.test.js b/test/trusted-publishing/oidc-context.test.js new file mode 100644 index 00000000..97e3b1d1 --- /dev/null +++ b/test/trusted-publishing/oidc-context.test.js @@ -0,0 +1,38 @@ +import test from "ava"; +import * as td from "testdouble"; + +import { OFFICIAL_REGISTRY } from "../../lib/definitions/constants.js"; + +let oidcContextEstablished, exchangeToken; +const pkg = {}; +const context = {}; + +test.beforeEach(async (t) => { + ({ default: exchangeToken } = await td.replaceEsm("../../lib/trusted-publishing/token-exchange.js")); + td.when(exchangeToken(pkg, context)).thenResolve(undefined); + + ({ default: oidcContextEstablished } = await import("../../lib/trusted-publishing/oidc-context.js")); +}); + +test.afterEach.always((t) => { + td.reset(); +}); + +test.serial( + "that `true` is returned when a trusted-publishing context has been established with the official registry", + async (t) => { + td.when(exchangeToken(pkg, context)).thenResolve("token-value"); + + t.true(await oidcContextEstablished(OFFICIAL_REGISTRY, pkg, context)); + } +); + +test.serial("that `false` is returned when OIDC token exchange fails in a supported CI provider", async (t) => { + td.when(exchangeToken(pkg, context)).thenResolve(undefined); + + t.false(await oidcContextEstablished(OFFICIAL_REGISTRY, pkg, context)); +}); + +test.serial("that `false` is returned when a custom registry is targeted", async (t) => { + t.false(await oidcContextEstablished("https://custom.registry.org/", pkg, context)); +}); diff --git a/test/trusted-publishing/token-exchange.test.js b/test/trusted-publishing/token-exchange.test.js new file mode 100644 index 00000000..f9ea19bd --- /dev/null +++ b/test/trusted-publishing/token-exchange.test.js @@ -0,0 +1,112 @@ +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"; + +// https://api-docs.npmjs.com/#tag/registry.npmjs.org/operation/exchangeOidcToken + +let exchangeToken, getIDToken, envCi; +const packageName = "@scope/some-package"; +const pkg = { name: packageName }; +const idToken = "id-token-value"; +const token = "token-value"; +const logger = { log: () => undefined }; + +test.beforeEach(async (t) => { + await td.replace(globalThis, "fetch"); + ({ getIDToken } = await td.replaceEsm("@actions/core")); + ({ default: envCi } = await td.replaceEsm("env-ci")); + + ({ default: exchangeToken } = await import("../../lib/trusted-publishing/token-exchange.js")); +}); + +test.afterEach.always((t) => { + td.reset(); + + delete process.env.NPM_ID_TOKEN; +}); + +test.serial("that an access token is returned when token exchange succeeds on GitHub Actions", async (t) => { + td.when(envCi()).thenReturn({ name: GITHUB_ACTIONS_PROVIDER_NAME }); + td.when(getIDToken("npm:registry.npmjs.org")).thenResolve(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({ token }), { status: 201, headers: { "Content-Type": "application/json" } }) + ); + + t.is(await exchangeToken(pkg, { logger }), token); +}); + +test.serial("that `undefined` is returned when ID token retrieval fails on GitHub Actions", async (t) => { + td.when(envCi()).thenReturn({ name: GITHUB_ACTIONS_PROVIDER_NAME }); + td.when(getIDToken("npm:registry.npmjs.org")).thenThrow( + new Error("Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable") + ); + + t.is(await exchangeToken(pkg, { logger }), undefined); +}); + +test.serial("that `undefined` is returned when token exchange fails on GitHub Actions", async (t) => { + td.when(envCi()).thenReturn({ name: GITHUB_ACTIONS_PROVIDER_NAME }); + td.when(getIDToken("npm:registry.npmjs.org")).thenResolve(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 an access token is returned when token exchange succeeds on GitLab Pipelines", 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", + 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 ID token is not available on GitLab Pipelines", async (t) => { + td.when(envCi()).thenReturn({ name: GITLAB_PIPELINES_PROVIDER_NAME }); + + t.is(await exchangeToken(pkg, { logger }), undefined); +}); + +test.serial("that `undefined` is returned when token exchange fails on GitLab Pipelines", 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", + 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 no supported CI provider is detected", async (t) => { + td.when(envCi()).thenReturn({ name: "Other Service" }); + + t.is(await exchangeToken(pkg, { logger }), undefined); +}); diff --git a/test/verify-auth.test.js b/test/verify-auth.test.js index 9ec940d1..3ca0fdbc 100644 --- a/test/verify-auth.test.js +++ b/test/verify-auth.test.js @@ -1,8 +1,10 @@ import test from "ava"; import * as td from "testdouble"; +import AggregateError from "aggregate-error"; +import { OFFICIAL_REGISTRY } from "../lib/definitions/constants.js"; -let execa, verifyAuth, getRegistry, setNpmrcAuth; -const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/"; +let execa, verifyAuth, getRegistry, setNpmrcAuth, oidcContextEstablished; +const DEFAULT_NPM_REGISTRY = OFFICIAL_REGISTRY; const npmrc = "npmrc contents"; const pkg = {}; const otherEnvVars = { foo: "bar" }; @@ -14,6 +16,8 @@ test.beforeEach(async (t) => { ({ execa } = await td.replaceEsm("execa")); ({ default: getRegistry } = await td.replaceEsm("../lib/get-registry.js")); ({ default: setNpmrcAuth } = await td.replaceEsm("../lib/set-npmrc-auth.js")); + ({ default: oidcContextEstablished } = await td.replaceEsm("../lib/trusted-publishing/oidc-context.js")); + td.when(oidcContextEstablished()).thenReturn(false); ({ default: verifyAuth } = await import("../lib/verify-auth.js")); }); @@ -22,10 +26,21 @@ test.afterEach.always((t) => { td.reset(); }); +test.serial( + "that the auth context for the official registry is considered valid when trusted publishing is established", + async (t) => { + td.when(getRegistry(pkg, context)).thenReturn(DEFAULT_NPM_REGISTRY); + td.when(oidcContextEstablished(DEFAULT_NPM_REGISTRY, pkg, context)).thenResolve(true); + + await t.notThrowsAsync(verifyAuth(npmrc, pkg, {}, context)); + } +); + test.serial( "that the provided token is verified with `npm whoami` when trusted publishing is not established for the official registry", async (t) => { td.when(getRegistry(pkg, context)).thenReturn(DEFAULT_NPM_REGISTRY); + td.when(oidcContextEstablished(DEFAULT_NPM_REGISTRY, pkg, context)).thenResolve(false); td.when( execa("npm", ["whoami", "--userconfig", npmrc, "--registry", DEFAULT_NPM_REGISTRY], { cwd, @@ -37,7 +52,7 @@ test.serial( stderr: { pipe: () => undefined }, }); - await t.notThrowsAsync(verifyAuth(npmrc, pkg, context)); + await t.notThrowsAsync(verifyAuth(npmrc, pkg, {}, context)); } ); @@ -45,6 +60,7 @@ test.serial( "that the auth context for the official registry is considered invalid when no token is provided and trusted publishing is not established", async (t) => { td.when(getRegistry(pkg, context)).thenReturn(DEFAULT_NPM_REGISTRY); + td.when(oidcContextEstablished(DEFAULT_NPM_REGISTRY, pkg, context)).thenResolve(false); td.when( execa("npm", ["whoami", "--userconfig", npmrc, "--registry", DEFAULT_NPM_REGISTRY], { cwd, @@ -55,7 +71,7 @@ test.serial( const { errors: [error], - } = await t.throwsAsync(verifyAuth(npmrc, pkg, context)); + } = await t.throwsAsync(verifyAuth(npmrc, pkg, {}, context)); t.is(error.name, "SemanticReleaseError"); t.is(error.code, "EINVALIDNPMTOKEN"); @@ -63,10 +79,136 @@ test.serial( } ); +test.serial( + "that a publish dry run is performed to validate token presence when publishing to a custom registry", + async (t) => { + const otherRegistry = "https://other.registry.org"; + const execaResult = Promise.resolve({ + stderr: ["foo", "bar", "baz"], + }); + execaResult.stderr = { pipe: () => undefined }; + execaResult.stdout = { pipe: () => undefined }; + td.when(getRegistry(pkg, context)).thenReturn(otherRegistry); + td.when(oidcContextEstablished(DEFAULT_NPM_REGISTRY, pkg, context)).thenResolve(false); + td.when( + execa( + "npm", + [ + "publish", + cwd, + "--dry-run", + "--tag=semantic-release-auth-check", + "--userconfig", + npmrc, + "--registry", + otherRegistry, + ], + { + cwd, + env: otherEnvVars, + preferLocal: true, + lines: true, + } + ) + ).thenReturn(execaResult); + + await t.notThrowsAsync(verifyAuth(npmrc, pkg, {}, context)); + } +); + +test.serial( + "that a publish dry run is performed to validate token presence when publishing to a custom registry from a sub-directory", + async (t) => { + const otherRegistry = "https://other.registry.org"; + const pkgRoot = "/dist"; + const execaResult = Promise.resolve({ + stderr: ["foo", "bar", "baz"], + }); + execaResult.stderr = { pipe: () => undefined }; + execaResult.stdout = { pipe: () => undefined }; + td.when(getRegistry(pkg, context)).thenReturn(otherRegistry); + td.when(oidcContextEstablished(DEFAULT_NPM_REGISTRY, pkg, context)).thenResolve(false); + td.when( + execa( + "npm", + [ + "publish", + pkgRoot, + "--dry-run", + "--tag=semantic-release-auth-check", + "--userconfig", + npmrc, + "--registry", + otherRegistry, + ], + { + cwd, + env: otherEnvVars, + preferLocal: true, + lines: true, + } + ) + ).thenReturn(execaResult); + + await t.notThrowsAsync(verifyAuth(npmrc, pkg, { pkgRoot }, context)); + } +); + // since alternative registries are not consistent in implementing `npm whoami`, -// we do not attempt to verify the provided token when publishing to them -test.serial("that `npm whoami` is not invoked when publishing to a custom registry", async (t) => { - td.when(getRegistry(pkg, context)).thenReturn("https://other.registry.org"); +// the best we can attempt to verify is to do a dry run publish and check for auth warnings +test.serial( + "that the token is considered invalid when the publish dry run fails when publishing to a custom registry", + async (t) => { + const otherRegistry = "https://other.registry.org"; + const execaResult = Promise.resolve({ + stderr: ["foo", "bar", "baz", `This command requires you to be logged in to ${otherRegistry}`, "qux"], + }); + execaResult.stderr = { pipe: () => undefined }; + execaResult.stdout = { pipe: () => undefined }; + td.when(getRegistry(pkg, context)).thenReturn(otherRegistry); + td.when(oidcContextEstablished(otherRegistry, pkg, context)).thenResolve(false); + td.when( + execa( + "npm", + [ + "publish", + cwd, + "--dry-run", + "--tag=semantic-release-auth-check", + "--userconfig", + npmrc, + "--registry", + otherRegistry, + ], + { + cwd, + env: otherEnvVars, + preferLocal: true, + lines: true, + } + ) + ).thenReturn(execaResult); + + const { + errors: [error], + } = await t.throwsAsync(verifyAuth(npmrc, pkg, {}, context)); + + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EINVALIDNPMAUTH"); + t.is(error.message, "Invalid npm authentication."); + } +); + +test.serial("that errors from setting up auth bubble through this function", async (t) => { + const registry = DEFAULT_NPM_REGISTRY; + const thrownError = new Error(); + td.when(getRegistry(pkg, context)).thenReturn(registry); + td.when(oidcContextEstablished(registry, pkg, context)).thenResolve(false); + td.when(setNpmrcAuth(npmrc, registry, context)).thenThrow(new AggregateError([thrownError])); + + const { + errors: [error], + } = await t.throwsAsync(verifyAuth(npmrc, pkg, {}, context)); - await t.notThrowsAsync(verifyAuth(npmrc, pkg, context)); + t.is(error, thrownError); });