← All articles

Automating Angular Library npm Publish with Trusted Publish in the 2FA Era

Use npm trusted publishing and GitHub Actions to release multiple Angular libraries from dist/ without manual 2FA—tag triggers, dynamic projects, and provenance.

Published
Automating Angular Library npm Publish with Trusted Publish in the 2FA Era cover image

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:

  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 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:

  1. Extract version from the tag
  2. List library projects from angular.json (projectType: "library")
  3. Bump each library's package.json version
  4. ng build all libraries (outputs dist/library-name)
  5. Commit version updates
  6. 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:

  1. No manual 2FA: CI publishes each library—no 2FA per dist/library-name
  2. Batch release: one tag releases every library in the monorepo
  3. Unified versions: tag drives one version across all libraries
  4. Better security: provenance proves package origin
  5. Pre-releases: non-stable semver publishes to next automatically
  6. New libraries: ng generate library + angular.json registration 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.