From 0cb21e1a246b1214aaafc11d033930ecb859224f Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Mon, 22 Sep 2025 22:38:27 -0400 Subject: [PATCH] feat: allow token to be optional for OIDC-based publish Closes #242 --- .github/workflows/ci-cd.yaml | 3 +- README.md | 39 +++++++++++++++++-- action.yaml | 2 +- dist/main.js | 8 ++-- src/__tests__/normalize-options.test.ts | 17 +++++--- src/cli/index.ts | 4 +- src/normalize-options.ts | 12 +++--- src/npm/__tests__/use-npm-environment.test.ts | 20 ++++++++++ src/npm/call-npm-cli.ts | 1 - src/npm/use-npm-environment.ts | 5 ++- src/options.ts | 9 ++++- 11 files changed, 93 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci-cd.yaml b/.github/workflows/ci-cd.yaml index c0e3e48..8648611 100644 --- a/.github/workflows/ci-cd.yaml +++ b/.github/workflows/ci-cd.yaml @@ -230,6 +230,7 @@ jobs: if: ${{ github.ref == 'refs/heads/main' }} name: Publish runs-on: ubuntu-latest + environment: npm timeout-minutes: 10 permissions: @@ -259,5 +260,3 @@ jobs: - name: Publish to NPM uses: ./ - with: - token: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 8cd997b..feeb544 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,10 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` -See [GitHub's Node.js publishing guide](https://docs.github.com/en/actions/tutorials/publish-packages/publish-nodejs-packages) for more details and examples. +See GitHub's [Node.js publishing][] guide and npm's [trusted publishing][] docs for more details and examples. + +[Node.js publishing]: https://docs.github.com/en/actions/tutorials/publish-packages/publish-nodejs-packages +[trusted publishing]: https://docs.npmjs.com/trusted-publishers#supported-cicd-providers ## Features @@ -97,6 +100,30 @@ jobs: token: ${{ secrets.NPM_TOKEN }} ``` +If you have [trusted publishing][] configured for your package and use `npm@>=11.5.1`, you can omit the `token` input and use OIDC instead. + +> [!IMPORTANT] +> If you're publishing a private package, you will still need to provide a read-only `token` so the action can read existing versions from the registry before publish. + +```diff + jobs: + publish: + runs-on: ubuntu-latest ++ permissions: ++ contents: read ++ id-token: write # required to use OIDC + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "24" # includes npm@11.6.0 + - run: npm ci + - run: npm test + - uses: JS-DevTools/npm-publish@v4 +- with: +- token: ${{ secrets.NPM_TOKEN }} +``` + You can also publish to third-party registries. For example, to publish to the [GitHub Package Registry][], set `token` to `secrets.GITHUB_TOKEN` and `registry` to `https://npm.pkg.github.com`: ```yaml @@ -134,7 +161,7 @@ You can set any or all of the following input parameters using `with`: | Name | Type | Default | Description | | ---------------- | ---------------------- | ----------------------------- | -------------------------------------------------------------------------------- | -| `token` | string | **required** | Authentication token to use with the configured registry. | +| `token` | string | unspecified | Registry authentication token, not required if using [trusted publishing][]³ | | `registry`¹ | string | `https://registry.npmjs.org/` | Registry URL to use. | | `package` | string | Current working directory | Path to a package directory, a `package.json`, or a packed `.tgz` to publish. | | `tag`¹ | string | `latest` | [Distribution tag][npm-tag] to publish to. | @@ -146,6 +173,7 @@ You can set any or all of the following input parameters using `with`: 1. May be specified using `publishConfig` in `package.json`. 2. Provenance requires npm `>=9.5.0`. +3. Trusted publishing npm `>=11.5.1` and must be run from a supported cloud provider. [npm-tag]: https://docs.npmjs.com/cli/v9/commands/npm-publish#tag [npm-access]: https://docs.npmjs.com/cli/v9/commands/npm-publish#access @@ -209,7 +237,7 @@ import type { Options } from "@jsdevtools/npm-publish"; | Name | Type | Default | Description | | -------------------- | ---------------------- | ----------------------------- | -------------------------------------------------------------------------------- | -| `token` | string | **required** | Authentication token to use with the configured registry. | +| `token` | string | **required** | Registry authentication token, not required if using [trusted publishing][]³ | | `registry`¹ | string, `URL` | `https://registry.npmjs.org/` | Registry URL to use. | | `package` | string | Current working directory | Path to a package directory, a `package.json`, or a packed `.tgz` to publish. | | `tag`¹ | string | `latest` | [Distribution tag][npm-tag] to publish to. | @@ -223,6 +251,7 @@ import type { Options } from "@jsdevtools/npm-publish"; 1. May be specified using `publishConfig` in `package.json`. 2. Provenance requires npm `>=9.5.0`. +3. Trusted publishing npm `>=11.5.1` and must be run from a supported cloud provider. ### API output @@ -281,7 +310,9 @@ Arguments: Options: - --token (Required) npm authentication token. + --token npm authentication token. + Not required if using trusted publishing. + See npm documentation for details. --registry Registry to read from and write to. Defaults to "https://registry.npmjs.org/". diff --git a/action.yaml b/action.yaml index 5d13fc1..ea42188 100644 --- a/action.yaml +++ b/action.yaml @@ -9,7 +9,7 @@ branding: inputs: token: description: The NPM access token to use when publishing - required: true + required: false registry: description: The NPM registry URL to use diff --git a/dist/main.js b/dist/main.js index 2caae27..10b5468 100644 --- a/dist/main.js +++ b/dist/main.js @@ -254,7 +254,7 @@ async function useNpmEnvironment(manifest, options, task) { const npmrcDirectory = await fs.mkdtemp(path.join(temporaryDirectory, "npm-publish-")); const npmrc = path.join(npmrcDirectory, ".npmrc"); const environment = { - NODE_AUTH_TOKEN: token, + NODE_AUTH_TOKEN: token ?? "", npm_config_userconfig: npmrc }; await fs.writeFile(npmrc, config, "utf8"); @@ -822,7 +822,7 @@ function normalizeOptions(manifest, options) { const defaultAccess = manifest.publishConfig?.access ?? (manifest.scope === void 0 ? ACCESS_PUBLIC : void 0); const defaultProvenance = manifest.publishConfig?.provenance ?? false; return { - token: validateToken(options.token), + token: validateToken(options.token ?? void 0), registry: validateRegistry(options.registry ?? defaultRegistry), tag: setValue(options.tag, defaultTag, validateTag), access: setValue(options.access, defaultAccess, validateAccess), @@ -839,8 +839,8 @@ const setValue = (value, defaultValue, validate$1) => ({ isDefault: value === void 0 }); const validateToken = (value) => { - if (typeof value === "string" && value.length > 0) return value; - throw new InvalidTokenError(); + if (typeof value !== "string" && value !== void 0 && value !== null) throw new InvalidTokenError(); + return typeof value === "string" && value.length > 0 ? value : void 0; }; const validateRegistry = (value) => { try { diff --git a/src/__tests__/normalize-options.test.ts b/src/__tests__/normalize-options.test.ts index 58ddc2d..0491542 100644 --- a/src/__tests__/normalize-options.test.ts +++ b/src/__tests__/normalize-options.test.ts @@ -57,15 +57,22 @@ describe("normalizeOptions", () => { }); it("should throw if token invalid", () => { - expect(() => { - subject.normalizeOptions(manifest, { token: "" }); - }).toThrow(errors.InvalidTokenError); - expect(() => { // @ts-expect-error: intentionally mistyped for validation testing - subject.normalizeOptions({ token: 42 }, manifest); + subject.normalizeOptions(manifest, { token: 0 }); }).toThrow(errors.InvalidTokenError); }); + + // eslint-disable-next-line unicorn/no-null + it.each([undefined, null, ""])( + "should set unspecified token %j to undefined", + (token) => { + // @ts-expect-error: intentionally mistyped for validation testing + const result = subject.normalizeOptions(manifest, { token }); + + expect(result).toMatchObject({ token: undefined }); + } + ); }); describe("publishConfig", () => { diff --git a/src/cli/index.ts b/src/cli/index.ts index cfc2ebb..047f7f0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,7 +14,9 @@ Arguments: Options: - --token (Required) npm authentication token. + --token npm authentication token. + Not required if using trusted publishing. + See npm documentation for details. --registry Registry to read from and write to. Defaults to "https://registry.npmjs.org/". diff --git a/src/normalize-options.ts b/src/normalize-options.ts index e08ddc0..b2f8670 100644 --- a/src/normalize-options.ts +++ b/src/normalize-options.ts @@ -19,7 +19,7 @@ export const TAG_LATEST = "latest"; /** Normalized and sanitized auth, publish, and runtime configurations. */ export interface NormalizedOptions { registry: URL; - token: string; + token: string | undefined; tag: ConfigValue; access: ConfigValue; provenance: ConfigValue; @@ -58,7 +58,7 @@ export function normalizeOptions( const defaultProvenance = manifest.publishConfig?.provenance ?? false; return { - token: validateToken(options.token), + token: validateToken(options.token ?? undefined), registry: validateRegistry(options.registry ?? defaultRegistry), tag: setValue(options.tag, defaultTag, validateTag), access: setValue(options.access, defaultAccess, validateAccess), @@ -80,12 +80,12 @@ const setValue = ( isDefault: value === undefined, }); -const validateToken = (value: unknown): string => { - if (typeof value === "string" && value.length > 0) { - return value; +const validateToken = (value: unknown): string | undefined => { + if (typeof value !== "string" && value !== undefined && value !== null) { + throw new errors.InvalidTokenError(); } - throw new errors.InvalidTokenError(); + return typeof value === "string" && value.length > 0 ? value : undefined; }; const validateRegistry = (value: unknown): URL => { diff --git a/src/npm/__tests__/use-npm-environment.test.ts b/src/npm/__tests__/use-npm-environment.test.ts index e246cfd..afeb3e3 100644 --- a/src/npm/__tests__/use-npm-environment.test.ts +++ b/src/npm/__tests__/use-npm-environment.test.ts @@ -77,4 +77,24 @@ describe("useNpmEnvironment", () => { await expect(fs.access(npmrcPath!)).rejects.toThrow(/ENOENT/); } ); + + it("allows unspecified token", async () => { + const inputManifest = { name: "fizzbuzz" } as PackageManifest; + const inputOptions = { + token: undefined, + registry: new URL("http://example.com/"), + temporaryDirectory: directory, + } as NormalizedOptions; + + const result = await subject.useNpmEnvironment( + inputManifest, + inputOptions, + async (_manifest, _options, environment) => { + await Promise.resolve(); + return environment; + } + ); + + expect(result).toMatchObject({ NODE_AUTH_TOKEN: "" }); + }); }); diff --git a/src/npm/call-npm-cli.ts b/src/npm/call-npm-cli.ts index fcda95d..e8db240 100644 --- a/src/npm/call-npm-cli.ts +++ b/src/npm/call-npm-cli.ts @@ -36,7 +36,6 @@ export const VIEW = "view"; export const PUBLISH = "publish"; export const E404 = "E404"; -export const E409 = "E409"; export const EPUBLISHCONFLICT = "EPUBLISHCONFLICT"; const IS_WINDOWS = os.platform() === "win32"; diff --git a/src/npm/use-npm-environment.ts b/src/npm/use-npm-environment.ts index d984629..517ac3f 100644 --- a/src/npm/use-npm-environment.ts +++ b/src/npm/use-npm-environment.ts @@ -42,7 +42,10 @@ export async function useNpmEnvironment( path.join(temporaryDirectory, "npm-publish-") ); const npmrc = path.join(npmrcDirectory, ".npmrc"); - const environment = { NODE_AUTH_TOKEN: token, npm_config_userconfig: npmrc }; + const environment = { + NODE_AUTH_TOKEN: token ?? "", + npm_config_userconfig: npmrc, + }; await fs.writeFile(npmrc, config, "utf8"); diff --git a/src/options.ts b/src/options.ts index f456ec5..e4d7bd3 100644 --- a/src/options.ts +++ b/src/options.ts @@ -24,8 +24,13 @@ export interface Logger { /** Options that determine how/whether the package is published. */ export interface Options { - /** The NPM access token to use when publishing. */ - token: string; + /** + * The NPM access token to use when publishing. + * + * May be left unspecified if `npm` is running in an environment with [trusted + * publishing](https://docs.npmjs.com/trusted-publishers#supported-cicd-providers) + */ + token?: string | undefined; /** * The absolute or relative path of your package.