← All articles

Automated Releases with Trusted Publish in npm Workspace Monorepos

Automate npm workspace monorepo releases with trusted publishing—GitHub Actions, tag triggers, dynamic workspace lists, and provenance without per-package 2FA.

Published
Automated Releases with Trusted Publish in npm Workspace Monorepos cover image

npm now requires 2FA (two-factor authentication). Publishing a single package is fine—visit the URL shown during npm publish and complete 2FA.

With an npm workspace monorepo managing multiple packages, each package needs 2FA. Manual handling is not realistic.

I solved this with trusted publish (provenance) automation.

The Problem

Publishing Multiple Packages in npm Workspaces

npm workspace monorepos manage multiple packages. For example:

my-monorepo/
├── package.json
├── packages/
│   ├── identity/
│   │   └── package.json
│   ├── payment/
│   │   └── package.json
│   └── terminal/
│       └── package.json

Publishing each package looks like:

  1. Run npm publish in packages/package-a
  2. Complete 2FA at the URL shown
  3. Run npm publish in packages/package-b
  4. Complete 2FA again

Repeat for every package—error-prone and slow. Releasing all packages at the same version makes manual work especially painful.

What Is Trusted Publish (Provenance)?

Trusted publish lets CI environments like GitHub Actions publish to npm without 2FA. With latest npm, provenance enables automatically from CI and proves package origin.

Provenance shows which repository, commit, and workflow published a package. Security improves and CI can publish automatically.

Automatic provenance generation requires all of:

  • Publish using trusted publishing (OIDC)
  • Publish from a public repository
  • Publish a public package

Provenance is not generated from private repos even for public packages (official documentation).

Required Setup

To use trusted publish:

  1. Add id-token: write to GitHub Actions permissions
  2. Use latest npm (CI automatically enables provenance on current npm)
  3. Register the GitHub repository as a trusted publisher on npm
  4. Set a repository field in each package's package.json

Configure repository like this:

{
  "repository": {
    "type": "git",
    "url": "git@github.com:your-org/your-monorepo.git"
  }
}

On npm, open each package's settings, go to "Trusted publishers", and register:

  • Repository owner
  • Repository name
  • Workflow filename (including .yml, e.g. release.yml)

The workflow file must live under .github/workflows/; filenames are case-sensitive. See official documentation.

Implementation

npm Workspace Configuration

Configure workspaces in the root package.json. The workflow reads workspace paths dynamically, so list each package path explicitly instead of glob patterns:

{
  "name": "my-monorepo",
  "workspaces": [
    "packages/package-a",
    "packages/package-b"
  ]
}

Add new package paths to this array when you add packages.

Tag-Based Release Trigger

Releases trigger on Git tags. Pushing a v* tag runs the release workflow.

You can use np for tags or create them manually—the important part is that pushing a tag runs the workflow.

GitHub Actions Workflow Structure

The npm workspace monorepo release workflow:

  1. Extract version from the tag
  2. Read package list from workspaces in package.json
  3. Bump each workspace package.json version
  4. Build all workspaces
  5. Commit version updates
  6. Publish each workspace

Details for each step follow.

Required Permissions

permissions:
  id-token: write
  contents: write

id-token: write is required for OIDC with trusted publish. contents: write commits version bumps.

Update npm

npm from actions/setup-node@v4 can be old. Latest npm enables provenance from CI automatically:

- name: Update npm
  run: npm install -g npm@latest

Extract Version

Extract version from the tag. From v1.2.3, get 1.2.3:

- name: Extract version from tag
  id: tag_version
  run: |
    TAG_NAME=${GITHUB_REF#refs/tags/}
    VERSION=${TAG_NAME#v}
    echo "version=$VERSION" >> $GITHUB_OUTPUT
    echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT

Read Workspace List

Read workspaces from package.json:

- name: Read workspaces
  id: workspaces
  run: |
    WORKSPACES=$(node -e "const fs=require('fs'); const ws=JSON.parse(fs.readFileSync('package.json','utf8')).workspaces||[]; console.log(ws.join(' '));")
    echo "list=$WORKSPACES" >> $GITHUB_OUTPUT

Adding a workspace to package.json includes it automatically—no workflow edits. Dynamic listing improves monorepo scalability.

The workspaces field may be an array or string; adjust if needed, though the snippet above works in most cases.

Switch to Default Branch

The workflow assumes the tagged commit is on the default branch, but tags can land on other branches. Switch to default and merge the tag commit:

- name: Switch to default branch and align with tag commit
  run: |
    DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
    git fetch origin "$DEFAULT_BRANCH"
    git checkout "$DEFAULT_BRANCH"
    git merge --ff-only "$GITHUB_SHA"

--ff-only merges only when fast-forward is possible. If the tag commit is not on default branch history, the workflow fails and blocks invalid releases.

fetch-depth: 0 fetches full history instead of shallow clone so branch switch and merge work.

Version Bump and Commit

Update each workspace package.json and commit. Use npm workspace --workspace to bump individually:

- name: Update workspace package.json versions
  run: |
    VERSION="${{ steps.tag_version.outputs.version }}"
    for workspace in ${{ steps.workspaces.outputs.list }}; do
      npm version "$VERSION" --no-git-tag-version --workspace "$workspace"
    done

- name: Commit changes
  run: |
    git config --local user.email "action@github.com"
    git config --local user.name "GitHub Action"
    git add packages/*/package.json
    git commit -m "chore: update workspace versions to ${{ steps.tag_version.outputs.version }}" || exit 0
    git push origin HEAD

npm version --no-git-tag-version updates package.json only. --workspace targets one workspace.

|| exit 0 on commit allows empty commits when versions were already updated so build and publish continue.

Build All Workspaces

Build every workspace:

- name: Build all workspaces
  run: npm run build -ws --if-present

-ws (--workspaces) runs the command in all workspaces. --if-present skips workspaces without a build script.

Publish Packages

Publish each workspace. Stable semver (1.2.3) publishes with provenance; pre-releases use the next tag:

- name: Publish packages
  run: |
    VERSION="${{ steps.tag_version.outputs.version }}"
    IS_STABLE=$(echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' && echo true || echo false)
    
    for workspace in ${{ steps.workspaces.outputs.list }}; do
      if [ "$IS_STABLE" = "true" ]; then
        npm publish --provenance --access public --workspace "$workspace"
      else
        npm publish --provenance --access public --tag next --workspace "$workspace"
      fi
    done

IS_STABLE checks three numeric segments. Non-stable versions (e.g. 1.2.3-beta.1) use --tag next so npm install does not pick them by default.

--workspace publishes each workspace as its own package.

With latest npm, provenance may enable without --provenance; specifying it explicitly is fine too.

Complete Workflow File

Save as .github/workflows/release.yml. For npm trusted publisher setup, set Workflow filename to release.yml (include .yml).

name: Release

on:
  push:
    tags:
      - 'v*'

permissions:
  id-token: write
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: 'https://registry.npmjs.org'

      - name: Update npm
        run: npm install -g npm@latest

      - name: Extract version from tag
        id: tag_version
        run: |
          TAG_NAME=${GITHUB_REF#refs/tags/}
          VERSION=${TAG_NAME#v}
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
          echo "Extracted version: $VERSION from tag: $TAG_NAME"

      - name: Install dependencies
        run: npm install

      - name: Read workspaces
        id: workspaces
        run: |
          WORKSPACES=$(node -e "const fs=require('fs'); const ws=JSON.parse(fs.readFileSync('package.json','utf8')).workspaces||[]; console.log(ws.join(' '));")
          echo "list=$WORKSPACES" >> $GITHUB_OUTPUT
          echo "Workspaces: $WORKSPACES"

      - name: Switch to default branch and align with tag commit
        run: |
          DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
          git fetch origin "$DEFAULT_BRANCH"
          git checkout "$DEFAULT_BRANCH"
          git merge --ff-only "$GITHUB_SHA"

      - name: Update workspace package.json versions
        run: |
          VERSION="${{ steps.tag_version.outputs.version }}"
          echo "Setting workspace versions to $VERSION"
          for workspace in ${{ steps.workspaces.outputs.list }}; do
            echo "Updating $workspace/package.json..."
            npm version "$VERSION" --no-git-tag-version --workspace "$workspace"
          done

      - name: Build all workspaces
        run: npm run build -ws --if-present

      - name: Commit changes
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git add packages/*/package.json
          git commit -m "chore: update workspace versions to ${{ steps.tag_version.outputs.version }}" || exit 0
          git push origin HEAD

      - name: Publish packages
        run: |
          VERSION="${{ steps.tag_version.outputs.version }}"
          IS_STABLE=$(echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' && echo true || echo false)
          
          for workspace in ${{ steps.workspaces.outputs.list }}; do
            echo "Publishing $workspace..."
            if [ "$IS_STABLE" = "true" ]; then
              npm publish --provenance --access public --workspace "$workspace"
            else
              npm publish --provenance --access public --tag next --workspace "$workspace"
            fi
          done

What Gets Easier

This automation simplifies npm workspace monorepo releases:

  1. No manual 2FA: CI publishes each workspace—no 2FA per package
  2. Batch release: one tag releases every package
  3. Unified versions: tag drives one version across workspaces
  4. Better security: provenance proves package origin
  5. Pre-releases: non-stable semver publishes to next automatically
  6. New packages: add paths to workspaces and they are included automatically

Release becomes: create a tag. CI builds and publishes all workspaces.

Summary

Mandatory npm 2FA made workspace monorepos painful—2FA for every package. Trusted publish (provenance) lets CI publish each workspace without 2FA and simplifies releases greatly.

GitHub Actions plus trusted publish means one tag builds and releases multiple packages. Dynamic listing from workspaces avoids workflow edits when you add packages. If you manage multiple packages in an npm workspace monorepo, consider adopting this.

See you again.