diff --git a/.github/workflows/test-oidc-validation.yml b/.github/workflows/test-oidc-validation.yml new file mode 100644 index 00000000..9c0bcd15 --- /dev/null +++ b/.github/workflows/test-oidc-validation.yml @@ -0,0 +1,128 @@ +name: Test OIDC Validation + +on: + workflow_dispatch: + inputs: + test_scenario: + description: 'Test scenario' + required: true + type: choice + options: + - 'oidc-success' + - 'oidc-missing-permission' + - 'oidc-old-npm' + - 'legacy-success' + +permissions: + contents: write + pull-requests: write + # id-token: write # Intentionally commented for testing + +jobs: + test-oidc: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write # This enables OIDC + if: github.event.inputs.test_scenario == 'oidc-success' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Update npm + run: npm install -g npm@latest + + - name: Install dependencies + run: yarn install + + - name: Test OIDC validation + uses: ./ + with: + oidcAuth: true + # No publish script - will just validate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + test-missing-permission: + runs-on: ubuntu-latest + permissions: + contents: write + # No id-token permission + if: github.event.inputs.test_scenario == 'oidc-missing-permission' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Update npm + run: npm install -g npm@latest + + - name: Install dependencies + run: yarn install + + - name: Test missing permission (should fail) + uses: ./ + with: + publish: echo "test" + oidcAuth: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + test-old-npm: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + if: github.event.inputs.test_scenario == 'oidc-old-npm' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + # Don't update npm - use older version + + - name: Install dependencies + run: yarn install + + - name: Test old npm version (should fail) + uses: ./ + with: + publish: echo "test" + oidcAuth: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + test-legacy: + runs-on: ubuntu-latest + if: github.event.inputs.test_scenario == 'legacy-success' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: yarn install + + - name: Test legacy mode (no publish) + uses: ./ + with: + oidcAuth: false + # No publish script - will just validate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: dummy-token-for-testing diff --git a/README.md b/README.md index 5ea2e5e5..174de4fb 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This action for [Changesets](https://github.com/changesets/changesets) creates a - title - The pull request title. Default to `Version Packages` - setupGitUser - Sets up the git user for commits as `"github-actions[bot]"`. Default to `true` - createGithubReleases - A boolean value to indicate whether to create Github releases after `publish` or not. Default to `true` +- oidcAuth - Use npm OIDC trusted publishing instead of NPM_TOKEN. Default to `false` - commitMode - Specifies the commit mode. Use `"git-cli"` to push changes using the Git CLI, or `"github-api"` to push changes via the GitHub API. When using `"github-api"`, all commits and tags are GPG-signed and attributed to the user or app who owns the `GITHUB_TOKEN`. Default to `git-cli`. - cwd - Changes node's `process.cwd()` if the project is not located on the root. Default to `process.cwd()` @@ -123,6 +124,101 @@ For example, you can add a step before running the Changesets GitHub Action: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` +#### With OIDC Trusted Publishing + +npm supports [Trusted Publishing with OIDC](https://docs.npmjs.com/trusted-publishers), which eliminates the need for long-lived NPM tokens. This is the recommended approach for publishing to npm from GitHub Actions. + +**Prerequisites:** + +1. npm CLI version 11.5.1 or higher +2. [Configure a trusted publisher](https://docs.npmjs.com/trusted-publishers) on npmjs.com for your packages: + - Go to your organization/package settings on npmjs.com + - Add a trusted publisher with your GitHub repository details (organization, repository, workflow file name) +3. Add `id-token: write` permission to your workflow + +**Example workflow:** + +```yml +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write + pull-requests: write + id-token: write # Required for npm OIDC + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + + # Ensure npm 11.5.1+ is available + - name: Update npm + run: npm install -g npm@latest + + - name: Install Dependencies + run: yarn + + - name: Create Release Pull Request or Publish to npm + id: changesets + uses: changesets/action@v1 + with: + publish: yarn release + oidcAuth: true # Enable OIDC authentication + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # No NPM_TOKEN needed with OIDC! + + - name: Send a Slack notification if a publish happens + if: steps.changesets.outputs.published == 'true' + run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!" +``` + +**Benefits of OIDC:** + +- ✅ No long-lived tokens to manage or rotate +- ✅ Cryptographic provenance attestation automatically generated +- ✅ More secure authentication flow +- ✅ Eliminates risk of token leakage + +**Provenance Attestation:** + +When publishing with OIDC, npm automatically generates cryptographic provenance attestation. This provides verifiable proof that your package was published from the specified GitHub repository and workflow. The attestation appears on your package page on npmjs.com as a verified badge, giving users confidence in the package's origin and integrity. + +Learn more: https://docs.npmjs.com/trusted-publishers#provenance-attestation + +**Migration from NPM_TOKEN to OIDC:** + +1. Update your workflow to use npm 11.5.1+ +2. Configure trusted publisher on npmjs.com +3. Add `id-token: write` permission to your workflow +4. Set `oidcAuth: true` in the changesets action +5. Remove `NPM_TOKEN` from the workflow and GitHub secrets + +**Validation:** + +The action automatically validates: + +- npm version is 11.5.1 or higher +- `id-token: write` permission is granted +- `NPM_TOKEN` is not set (to avoid conflicting authentication) + +If validation fails, you'll receive clear error messages with instructions on how to fix the issue. + #### Custom Publishing If you want to hook into when publishing should occur but have your own publishing functionality, you can utilize the `hasChangesets` output. diff --git a/action.yml b/action.yml index 863d571d..977aaa9c 100644 --- a/action.yml +++ b/action.yml @@ -36,6 +36,10 @@ inputs: or app who owns the GITHUB_TOKEN. required: false default: "git-cli" + oidcAuth: + description: Use npm OIDC trusted publishing instead of NPM_TOKEN + required: false + default: false branch: description: Sets the branch in which the action will run. Default to `github.ref_name` if not provided required: false diff --git a/docs/testing-guide.md b/docs/testing-guide.md new file mode 100644 index 00000000..25251211 --- /dev/null +++ b/docs/testing-guide.md @@ -0,0 +1,132 @@ +# OIDC Testing Guide + +## Quick Test Using This Workflow + +1. Push the test workflow to your fork: + ```bash + git add .github/workflows/test-oidc-validation.yml + git commit -m "test: add OIDC validation test workflow" + git push origin main + ``` + +2. Run the workflow manually from GitHub Actions UI: + - Go to Actions tab + - Select "Test OIDC Validation" + - Run workflow with different scenarios + +## Full End-to-End Test (If Needed) + +### 1. Create Test Package Repository + +```bash +# Create a new test repo +mkdir changesets-oidc-test +cd changesets-oidc-test +npm init -y + +# Update package.json +cat > package.json <<'EOF' +{ + "name": "@YOUR_NPM_USERNAME/changesets-oidc-test", + "version": "0.0.1", + "description": "Test package for OIDC changesets", + "main": "index.js", + "scripts": { + "release": "changeset publish" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/YOUR_USERNAME/changesets-oidc-test.git" + } +} +EOF + +# Create minimal package +echo 'module.exports = "test";' > index.js + +# Initialize changesets +npx @changesets/cli init +``` + +### 2. Configure npm OIDC Trusted Publishing + +1. Go to npmjs.com → Your Profile → Publishing Access +2. Add a trusted publisher: + - Source: GitHub Actions + - Repository: `YOUR_USERNAME/changesets-oidc-test` + - Workflow file: `.github/workflows/release.yml` + - Environment: (leave empty or specify) + +### 3. Create Workflow in Test Repo + +```yaml +# .github/workflows/release.yml +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: npm install -g npm@latest + - run: yarn install + + - uses: GarthDB/changesets-action@v1.6.9 + with: + publish: yarn release + oidcAuth: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +### 4. Create a Test Changeset + +```bash +npx changeset add +# Select patch +# Describe changes +# Commit and push +``` + +### 5. Monitor the Workflow + +Watch the GitHub Actions run to verify: +- ✅ OIDC validation passes +- ✅ Version PR is created +- ✅ After merging, package publishes successfully +- ✅ Provenance attestation is generated + +## Recommended Approach + +Given that: +1. Your code is already tested in production (Adobe spectrum-design-data) +2. We only simplified redundant code without changing functionality +3. All 30 tests pass locally + +**Recommendation: Use Option 1 or 2 first** + +- Push test workflow to your fork +- Run validation tests +- If those pass, you're ready for PR + +**Only create a full test package if:** +- Maintainers request it +- You want extra confidence +- You need to debug an issue diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 00000000..e91160c7 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as core from "@actions/core"; +import fs from "node:fs/promises"; +import { getExecOutput } from "@actions/exec"; + +// Mock external dependencies +vi.mock("@actions/core"); +vi.mock("@actions/exec"); +vi.mock("node:fs/promises"); + +// Import actual implementations after mocks are set up +import { setupNpmAuth, createNpmrcFile, validateOidcEnvironment, fileExists } from "./utils.ts"; + +describe("npm authentication setup", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + process.env.GITHUB_TOKEN = "test-token"; + process.env.HOME = "/home/test"; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("setupNpmAuth", () => { + it("validates OIDC environment when oidcAuth is true", async () => { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "11.6.2", + stderr: "", + exitCode: 0, + }); + + await setupNpmAuth(true); + + // validateOidcEnvironment should have been called internally + expect(getExecOutput).toHaveBeenCalledWith("npm", ["--version"]); + }); + + it("throws error when NPM_TOKEN is missing in legacy mode", async () => { + delete process.env.NPM_TOKEN; + + await expect(setupNpmAuth(false)).rejects.toThrow( + "NPM_TOKEN environment variable is required" + ); + expect(getExecOutput).not.toHaveBeenCalled(); + }); + + it("succeeds when NPM_TOKEN is present in legacy mode", async () => { + process.env.NPM_TOKEN = "test-token"; + + await expect(setupNpmAuth(false)).resolves.toBeUndefined(); + expect(getExecOutput).not.toHaveBeenCalled(); + }); + + it("propagates OIDC validation errors", async () => { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "10.0.0", + stderr: "", + exitCode: 0, + }); + + await expect(setupNpmAuth(true)).rejects.toThrow("npm version 10.0.0 detected"); + }); + }); + + describe("createNpmrcFile", () => { + it("creates .npmrc file when it does not exist", async () => { + process.env.NPM_TOKEN = "test-token-123"; + vi.mocked(fs.access).mockRejectedValue(new Error("File not found")); + vi.mocked(fs.writeFile).mockResolvedValue(); + + await createNpmrcFile(); + + expect(fs.writeFile).toHaveBeenCalledWith( + "/home/test/.npmrc", + "//registry.npmjs.org/:_authToken=test-token-123\n" + ); + expect(fs.appendFile).not.toHaveBeenCalled(); + }); + + it("appends to existing .npmrc when auth token is missing", async () => { + process.env.NPM_TOKEN = "test-token-456"; + vi.mocked(fs.access).mockResolvedValue(undefined); // File exists + vi.mocked(fs.readFile).mockResolvedValue("some-other-config=value\n"); + vi.mocked(fs.appendFile).mockResolvedValue(); + + await createNpmrcFile(); + + expect(fs.readFile).toHaveBeenCalledWith("/home/test/.npmrc", "utf8"); + expect(fs.appendFile).toHaveBeenCalledWith( + "/home/test/.npmrc", + "\n//registry.npmjs.org/:_authToken=test-token-456\n" + ); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it("does not modify .npmrc when auth token already exists", async () => { + process.env.NPM_TOKEN = "test-token-789"; + vi.mocked(fs.access).mockResolvedValue(undefined); // File exists + vi.mocked(fs.readFile).mockResolvedValue( + "//registry.npmjs.org/:_authToken=existing-token\n" + ); + + await createNpmrcFile(); + + expect(fs.readFile).toHaveBeenCalledWith("/home/test/.npmrc", "utf8"); + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(fs.appendFile).not.toHaveBeenCalled(); + }); + + it("throws error when NPM_TOKEN is not set", async () => { + delete process.env.NPM_TOKEN; + + await expect(createNpmrcFile()).rejects.toThrow( + "NPM_TOKEN is required to create .npmrc file" + ); + }); + }); + + describe("Integration: OIDC mode does not create .npmrc", () => { + it("validates OIDC and skips .npmrc creation", async () => { + const writeFileSpy = vi.spyOn(fs, "writeFile"); + const appendFileSpy = vi.spyOn(fs, "appendFile"); + + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "11.6.2", + stderr: "", + exitCode: 0, + }); + + // Setup OIDC auth + await setupNpmAuth(true); + + // Verify OIDC validation was called (via npm version check) + expect(getExecOutput).toHaveBeenCalledWith("npm", ["--version"]); + + // Verify no .npmrc operations occurred + const npmrcWriteCalls = writeFileSpy.mock.calls.filter((call) => + call[0].toString().includes(".npmrc") + ); + const npmrcAppendCalls = appendFileSpy.mock.calls.filter((call) => + call[0].toString().includes(".npmrc") + ); + + expect(npmrcWriteCalls).toHaveLength(0); + expect(npmrcAppendCalls).toHaveLength(0); + + writeFileSpy.mockRestore(); + appendFileSpy.mockRestore(); + }); + }); + + describe("Integration: Legacy mode creates .npmrc", () => { + it("creates .npmrc file when NPM_TOKEN is set", async () => { + const writeFileSpy = vi.spyOn(fs, "writeFile"); + + process.env.NPM_TOKEN = "legacy-token-123"; + vi.mocked(fs.access).mockRejectedValue(new Error("File not found")); + vi.mocked(fs.writeFile).mockResolvedValue(); + + // Setup legacy auth + await setupNpmAuth(false); + + // Create .npmrc file + await createNpmrcFile(); + + // Verify .npmrc was created with correct token + expect(writeFileSpy).toHaveBeenCalledWith( + "/home/test/.npmrc", + "//registry.npmjs.org/:_authToken=legacy-token-123\n" + ); + expect(getExecOutput).not.toHaveBeenCalled(); + + writeFileSpy.mockRestore(); + }); + }); + + describe("Error handling", () => { + it("handles OIDC validation failure gracefully", async () => { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "10.0.0", + stderr: "", + exitCode: 0, + }); + + await expect(setupNpmAuth(true)).rejects.toThrow( + /npm 11.5.1\+ required for OIDC/ + ); + }); + + it("provides clear error when NPM_TOKEN is missing in legacy mode", async () => { + delete process.env.NPM_TOKEN; + + await expect(setupNpmAuth(false)).rejects.toThrow( + "NPM_TOKEN environment variable is required when not using OIDC authentication" + ); + }); + }); + + describe("Backward compatibility", () => { + it("defaults to legacy mode when oidcAuth is false", async () => { + process.env.NPM_TOKEN = "test-token"; + + await expect(setupNpmAuth(false)).resolves.toBeUndefined(); + expect(getExecOutput).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 1b0f63ab..d953fc5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { Git } from "./git.ts"; import { setupOctokit } from "./octokit.ts"; import readChangesetState from "./readChangesetState.ts"; import { runPublish, runVersion } from "./run.ts"; -import { fileExists } from "./utils.ts"; +import { fileExists, setupNpmAuth, createNpmrcFile } from "./utils.ts"; const getOptionalInput = (name: string) => core.getInput(name) || undefined; @@ -43,14 +43,34 @@ const getOptionalInput = (name: string) => core.getInput(name) || undefined; `machine github.com\nlogin github-actions[bot]\npassword ${githubToken}` ); + // Validate authentication early if publish script exists + let publishScript = core.getInput("publish"); + let hasPublishScript = !!publishScript; + + if (hasPublishScript) { + const oidcAuth = core.getBooleanInput("oidcAuth"); + + try { + if (oidcAuth) { + core.info("Using npm OIDC trusted publishing"); + await setupNpmAuth(true); + core.info("OIDC environment validated successfully"); + } else { + core.info("Using legacy NPM_TOKEN authentication"); + await setupNpmAuth(false); + } + } catch (error) { + core.setFailed(error instanceof Error ? error.message : String(error)); + return; + } + } + let { changesets } = await readChangesetState(); - let publishScript = core.getInput("publish"); let hasChangesets = changesets.length !== 0; const hasNonEmptyChangesets = changesets.some( (changeset) => changeset.releases.length > 0 ); - let hasPublishScript = !!publishScript; core.setOutput("published", "false"); core.setOutput("publishedPackages", "[]"); @@ -67,33 +87,32 @@ const getOptionalInput = (name: string) => core.getInput(name) || undefined; "No changesets found. Attempting to publish any unpublished packages to npm" ); - let userNpmrcPath = `${process.env.HOME}/.npmrc`; - if (await fileExists(userNpmrcPath)) { - core.info("Found existing user .npmrc file"); - const userNpmrcContent = await fs.readFile(userNpmrcPath, "utf8"); - const authLine = userNpmrcContent.split("\n").find((line) => { - // check based on https://github.com/npm/cli/blob/8f8f71e4dd5ee66b3b17888faad5a7bf6c657eed/test/lib/adduser.js#L103-L105 - return /^\s*\/\/registry\.npmjs\.org\/:[_-]authToken=/i.test(line); - }); - if (authLine) { - core.info( - "Found existing auth token for the npm registry in the user .npmrc file" - ); + const oidcAuth = core.getBooleanInput("oidcAuth"); + + // Setup .npmrc for legacy mode (OIDC was already validated earlier) + if (!oidcAuth) { + const userNpmrcPath = `${process.env.HOME}/.npmrc`; + if (await fileExists(userNpmrcPath)) { + core.info("Found existing user .npmrc file"); + const userNpmrcContent = await fs.readFile(userNpmrcPath, "utf8"); + const authLine = userNpmrcContent.split("\n").find((line) => { + // check based on https://github.com/npm/cli/blob/8f8f71e4dd5ee66b3b17888faad5a7bf6c657eed/test/lib/adduser.js#L103-L105 + return /^\s*\/\/registry\.npmjs\.org\/:[_-]authToken=/i.test(line); + }); + if (authLine) { + core.info( + "Found existing auth token for the npm registry in the user .npmrc file" + ); + } else { + core.info( + "Didn't find existing auth token for the npm registry in the user .npmrc file, creating one" + ); + await createNpmrcFile(); + } } else { - core.info( - "Didn't find existing auth token for the npm registry in the user .npmrc file, creating one" - ); - await fs.appendFile( - userNpmrcPath, - `\n//registry.npmjs.org/:_authToken=${process.env.NPM_TOKEN}\n` - ); + core.info("No user .npmrc file found, creating one"); + await createNpmrcFile(); } - } else { - core.info("No user .npmrc file found, creating one"); - await fs.writeFile( - userNpmrcPath, - `//registry.npmjs.org/:_authToken=${process.env.NPM_TOKEN}\n` - ); } const result = await runPublish({ @@ -102,6 +121,7 @@ const getOptionalInput = (name: string) => core.getInput(name) || undefined; octokit, createGithubReleases: core.getBooleanInput("createGithubReleases"), cwd, + oidcAuth, }); if (result.published) { diff --git a/src/oidc.test.ts b/src/oidc.test.ts new file mode 100644 index 00000000..aaa9e09d --- /dev/null +++ b/src/oidc.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getExecOutput } from "@actions/exec"; +import { validateOidcEnvironment } from "./utils.ts"; + +vi.mock("@actions/exec"); + +describe("OIDC validation", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("validateOidcEnvironment", () => { + it("passes validation with correct setup", async () => { + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "11.6.2", + stderr: "", + exitCode: 0, + }); + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + + await expect(validateOidcEnvironment()).resolves.toBeUndefined(); + }); + + it("throws error for npm version < 11.5.1", async () => { + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "10.8.1", + stderr: "", + exitCode: 0, + }); + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + + await expect(validateOidcEnvironment()).rejects.toThrow( + /npm version 10.8.1 detected. npm 11.5.1\+ required for OIDC/ + ); + await expect(validateOidcEnvironment()).rejects.toThrow( + /npm install -g npm@latest/ + ); + }); + + it("throws error for npm version 11.5.0 (edge case)", async () => { + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "11.5.0", + stderr: "", + exitCode: 0, + }); + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + + await expect(validateOidcEnvironment()).rejects.toThrow( + /npm version 11.5.0 detected/ + ); + }); + + it("passes validation for npm 11.5.1 exactly", async () => { + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "11.5.1", + stderr: "", + exitCode: 0, + }); + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + + await expect(validateOidcEnvironment()).resolves.toBeUndefined(); + }); + + it("throws error for missing id-token permission", async () => { + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "11.6.2", + stderr: "", + exitCode: 0, + }); + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.NPM_TOKEN; + + await expect(validateOidcEnvironment()).rejects.toThrow( + /id-token: write permission not detected/ + ); + await expect(validateOidcEnvironment()).rejects.toThrow( + /permissions:.*id-token: write/s + ); + }); + + it("throws error when NPM_TOKEN is set", async () => { + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "11.6.2", + stderr: "", + exitCode: 0, + }); + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + process.env.NPM_TOKEN = "secret-token"; + + await expect(validateOidcEnvironment()).rejects.toThrow( + /NPM_TOKEN is set but oidcAuth: true/ + ); + await expect(validateOidcEnvironment()).rejects.toThrow( + /Remove NPM_TOKEN secret or set oidcAuth: false/ + ); + }); + + it("handles npm version with leading/trailing whitespace", async () => { + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: " 11.6.2\n", + stderr: "", + exitCode: 0, + }); + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + + await expect(validateOidcEnvironment()).resolves.toBeUndefined(); + }); + + it("throws error when npm command fails", async () => { + vi.mocked(getExecOutput).mockRejectedValue( + new Error("npm command not found") + ); + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = "https://example.com"; + delete process.env.NPM_TOKEN; + + await expect(validateOidcEnvironment()).rejects.toThrow(); + }); + + it("validates all requirements are checked in order", async () => { + // npm version is checked first + vi.mocked(getExecOutput).mockResolvedValue({ + stdout: "10.0.0", + stderr: "", + exitCode: 0, + }); + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + process.env.NPM_TOKEN = "token"; + + // Should fail on npm version, not on other checks + await expect(validateOidcEnvironment()).rejects.toThrow( + /npm version 10.0.0 detected/ + ); + }); + }); +}); diff --git a/src/run.ts b/src/run.ts index 18ec1da4..101e050d 100644 --- a/src/run.ts +++ b/src/run.ts @@ -64,6 +64,7 @@ type PublishOptions = { createGithubReleases: boolean; git: Git; cwd: string; + oidcAuth?: boolean; }; type PublishedPackage = { name: string; version: string }; @@ -83,13 +84,31 @@ export async function runPublish({ octokit, createGithubReleases, cwd, + oidcAuth = false, }: PublishOptions): Promise { let [publishCommand, ...publishArgs] = script.split(/\s+/); + // Build exec options with explicit OIDC environment variables if using OIDC + // This is necessary because some toolchains (proto shims, moon, etc.) may start + // fresh shell environments that don't inherit all environment variables. + // Explicitly passing them ensures OIDC works correctly even through these wrappers. + const execOptions: Parameters[2] = { cwd }; + + if (oidcAuth) { + core.info("Passing OIDC environment variables to publish command"); + execOptions.env = { + ...process.env, + // Explicitly pass OIDC variables - these are required for npm OIDC authentication + ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL || "", + ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN || "", + CI: process.env.CI || "true", + }; + } + let changesetPublishOutput = await getExecOutput( publishCommand, publishArgs, - { cwd } + execOptions ); let { packages, tool } = await getPackages(cwd); diff --git a/src/utils.ts b/src/utils.ts index 8ae53cff..727cae8b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,6 +6,8 @@ import type { Root } from "mdast"; // @ts-ignore import mdastToString from "mdast-util-to-string"; import { getPackages, type Package } from "@manypkg/get-packages"; +import { getExecOutput } from "@actions/exec"; +import semverGte from "semver/functions/gte.js"; export const BumpLevels = { dep: 0, @@ -113,3 +115,85 @@ export function fileExists(filePath: string) { () => false ); } + +export async function validateOidcEnvironment(): Promise { + // Check npm version + const { stdout } = await getExecOutput("npm", ["--version"]); + const npmVersion = stdout.trim(); + + if (!semverGte(npmVersion, "11.5.1")) { + throw new Error( + `npm version ${npmVersion} detected. npm 11.5.1+ required for OIDC.\n` + + `Add step to your workflow:\n` + + ` - run: npm install -g npm@latest` + ); + } + + // Check for id-token permission + if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { + throw new Error( + `id-token: write permission not detected.\n` + + `Add to your workflow:\n` + + `permissions:\n` + + ` contents: write\n` + + ` id-token: write` + ); + } + + // Check that NPM_TOKEN is not set (conflicting auth methods) + if (process.env.NPM_TOKEN) { + throw new Error( + `NPM_TOKEN is set but oidcAuth: true.\n` + + `Remove NPM_TOKEN secret or set oidcAuth: false` + ); + } +} + +/** + * Sets up npm authentication by either validating OIDC environment or validating NPM_TOKEN. + * This function should be called early in the workflow, before reading changesets. + */ +export async function setupNpmAuth(oidcAuth: boolean): Promise { + if (oidcAuth) { + await validateOidcEnvironment(); + } else { + // Legacy NPM_TOKEN authentication + if (!process.env.NPM_TOKEN) { + throw new Error( + "NPM_TOKEN environment variable is required when not using OIDC authentication. " + + "Either set the NPM_TOKEN secret or enable OIDC by setting oidcAuth: true" + ); + } + } +} + +/** + * Creates or updates .npmrc file with NPM_TOKEN authentication. + * This should only be called in legacy mode (when oidcAuth is false). + */ +export async function createNpmrcFile(): Promise { + if (!process.env.NPM_TOKEN) { + throw new Error("NPM_TOKEN is required to create .npmrc file"); + } + + const userNpmrcPath = `${process.env.HOME}/.npmrc`; + + if (await fileExists(userNpmrcPath)) { + const userNpmrcContent = await fs.readFile(userNpmrcPath, "utf8"); + const authLine = userNpmrcContent.split("\n").find((line) => { + // check based on https://github.com/npm/cli/blob/8f8f71e4dd5ee66b3b17888faad5a7bf6c657eed/test/lib/adduser.js#L103-L105 + return /^\s*\/\/registry\.npmjs\.org\/:[_-]authToken=/i.test(line); + }); + if (!authLine) { + await fs.appendFile( + userNpmrcPath, + `\n//registry.npmjs.org/:_authToken=${process.env.NPM_TOKEN}\n` + ); + } + } else { + await fs.writeFile( + userNpmrcPath, + `//registry.npmjs.org/:_authToken=${process.env.NPM_TOKEN}\n` + ); + } +}