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:
- Run
npm publishinpackages/package-a - Complete 2FA at the URL shown
- Run
npm publishinpackages/package-b - 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:
- Add
id-token: writeto GitHub Actions permissions - Use latest npm (CI automatically enables provenance on current npm)
- Register the GitHub repository as a trusted publisher on npm
- Set a
repositoryfield 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:
- Extract version from the tag
- Read package list from
workspacesin package.json - Bump each workspace package.json version
- Build all workspaces
- Commit version updates
- 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:
- No manual 2FA: CI publishes each workspace—no 2FA per package
- Batch release: one tag releases every package
- Unified versions: tag drives one version across workspaces
- Better security: provenance proves package origin
- Pre-releases: non-stable semver publishes to
nextautomatically - New packages: add paths to
workspacesand 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.