npm now requires 2FA (two-factor authentication). Publishing from the repo root is manageable—visit the URL shown during npm publish and complete 2FA.
When you develop Angular libraries, however, you publish from generated dist/ directories after build. With multiple libraries, you must 2FA for each one. Manual release is not realistic.
I solved this with trusted publish (provenance) automation.
The Problem
Angular Library Build and Publish Flow
When you develop Angular libraries, ng build produces a dist/library-name directory. You cd there and run npm publish.
One library means one 2FA in dist/library-name. In a monorepo with several Angular libraries, each dist/library-name needs its own publish—and 2FA each time—making releases very tedious.
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 where packages came from.
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 package.json
Configure repository like this:
{
"repository": {
"type": "git",
"url": "git@github.com:rdlabo-dev/ionic-angular-library.git"
}
}
On npm, open the package settings, go to "Trusted publishers", and register:
- Repository owner (e.g.
rdlabo-dev) - Repository name (e.g.
ionic-angular-library) - Workflow filename (including
.yml, e.g.release.yml)
The workflow file must live under .github/workflows/; filenames are case-sensitive. See official documentation.
Implementation
Tag-Based Release Trigger
I trigger releases with Git tags. Pushing a v* tag runs the release workflow automatically.
I use np to create tags. I added this script to package.json:
{
"scripts": {
"release": "np --no-tests --no-publish"
}
}
--no-tests and --no-publish skip tests and publish—only tag, release notes, and package.json version bump. GitHub Actions performs the actual publish.
GitHub Actions Workflow Structure
The Angular library release workflow:
- Extract version from the tag
- List library projects from
angular.json(projectType: "library") - Bump each library's package.json version
ng buildall libraries (outputsdist/library-name)- Commit version updates
- Publish from each
dist/library-name
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, so I install latest npm:
- 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
Extract Angular Library Projects
Parse angular.json for projects with projectType: "library":
- name: Extract library projects
id: libraries
run: |
LIBRARIES=$(node -e "const fs=require('fs'); const angular=JSON.parse(fs.readFileSync('angular.json','utf8')); const libs=Object.keys(angular.projects).filter(p=>angular.projects[p].projectType==='library'); console.log(libs.join(' '));")
echo "list=$LIBRARIES" >> $GITHUB_OUTPUT
Adding a library to angular.json with ng generate library includes it automatically—no workflow edits. Dynamic listing improves monorepo scalability.
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 projects/library-name/package.json for each library and commit. CI needs git identity:
- name: Update project package.json versions
run: |
VERSION="${{ steps.tag_version.outputs.version }}"
for project in ${{ steps.libraries.outputs.list }}; do
cd "projects/$project"
npm version "$VERSION" --no-git-tag-version
cd ../..
done
- name: Commit changes
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add projects/*/package.json
git commit -m "chore: update project versions to ${{ steps.tag_version.outputs.version }}" || exit 0
git push origin HEAD
npm version --no-git-tag-version updates package.json only—tags already exist from release. || exit 0 on commit allows empty commits when versions were already updated (e.g. workflow re-run) so build and publish continue.
Build Angular Libraries
Run ng build for all libraries. Artifacts land in dist/library-name:
- name: Build all projects
run: npm run prebuild
Here I run npm run prebuild; define the actual build (usually ng build) in that script.
Publish Packages
Publish from each dist/library-name. Stable semver (1.2.3) publishes normally; 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 project in ${{ steps.libraries.outputs.list }}; do
echo "Publishing $project..."
cd "dist/$project"
if [ "$IS_STABLE" = "true" ]; then
npm publish --access public
else
npm publish --access public --tag next
fi
cd ../..
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—avoid accidental production pre-releases.
Publishing from dist/library-name ships ng build output correctly. The loop publishes every library in the monorepo.
With latest npm, provenance enables from CI without --provenance—no 2FA per library.
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: Extract library projects
id: libraries
run: |
LIBRARIES=$(node -e "const fs=require('fs'); const angular=JSON.parse(fs.readFileSync('angular.json','utf8')); const libs=Object.keys(angular.projects).filter(p=>angular.projects[p].projectType==='library'); console.log(libs.join(' '));")
echo "list=$LIBRARIES" >> $GITHUB_OUTPUT
echo "Library projects: $LIBRARIES"
- 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 project package.json versions
run: |
VERSION="${{ steps.tag_version.outputs.version }}"
echo "Setting project versions to $VERSION"
for project in ${{ steps.libraries.outputs.list }}; do
echo "Updating projects/$project/package.json..."
cd "projects/$project"
npm version "$VERSION" --no-git-tag-version
cd ../..
done
- name: Build all projects
run: npm run prebuild
- name: Commit changes
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add projects/*/package.json
git commit -m "chore: update project 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 project in ${{ steps.libraries.outputs.list }}; do
echo "Publishing $project..."
cd "dist/$project"
if [ "$IS_STABLE" = "true" ]; then
npm publish --access public
else
npm publish --access public --tag next
fi
cd ../..
done
What Gets Easier
This automation simplifies Angular library releases:
- No manual 2FA: CI publishes each library—no 2FA per
dist/library-name - Batch release: one tag releases every library in the monorepo
- Unified versions: tag drives one version across all libraries
- Better security: provenance proves package origin
- Pre-releases: non-stable semver publishes to
nextautomatically - New libraries:
ng generate library+angular.jsonregistration includes them with no workflow change
Release becomes: create a tag. Run npm run release; CI builds and publishes all Angular libraries.
Summary
Mandatory npm 2FA made multi-library Angular monorepos painful—2FA for every dist/library-name. Trusted publish (provenance) lets CI publish each library without 2FA and simplifies releases greatly.
GitHub Actions plus trusted publish means one tag builds and releases multiple libraries. Dynamic listing from angular.json avoids workflow edits when you add libraries. If you manage several Angular libraries in a monorepo, consider adopting this.
See you again.