Designing a Scalable Release System for Multi-Package TypeScript Monorepos
DEV Community

Designing a Scalable Release System for Multi-Package TypeScript Monorepos

If you maintain more than one npm package in a single repository, you’ve probably felt the pain of release day. Bumping versions manually, publishing in the right order, hoping you didn’t forget a step. Multiply that by four packages and it becomes a real problem. This article is about solving that problem.

I’ll walk through a deployment pipeline I built for a multi-package TypeScript monorepo - covering version management, automated publishing, containerized verification, and documentation deployment. The patterns are generic enough to apply to any monorepo that ships multiple packages. I’ll use Nava Icon as a concrete example throughout, but the architecture works for any multi-package setup.

What “Deployment” Means for a Library

Most deployment articles talk about shipping apps to servers. For library authors, deployment means something different:

  • Version management - Bumping semver across packages correctly
  • npm publishing - Getting packages to the registry with proper access
  • Container builds - Creating reproducible verification environments
  • Documentation - Keeping docs in sync with the latest release
  • Release automation - Reducing the entire flow to a single human action

The goal: a developer makes a change, and the system handles everything else.

The Architecture

Here’s what a production-ready deployment pipeline looks like for a multi-package monorepo:

Code Change
    │
    ▼
CI Verification (lint, typecheck, build)
    │
    ▼
Docker Validation (reproducible build check)
    │
    ▼
Version Bump (automated via Changesets)
    │
    ▼
npm Publish (all changed packages)
    │
    ▼
Container Image (GHCR)
    │
    ▼
Documentation (GitHub Pages)

Every step is automated. The only human action is approving a production deployment.

1. Version Management

Versioning is the hardest part of multi-package deployment. If Package A depends on Package B, and you bump Package B, Package A needs to update its dependency. Do this wrong and you get version mismatches, broken installs, or packages that reference unpublished versions.

The Tool: Changesets

I use Changesets for version management. Here’s why it fits multi-package monorepos:

How it works:

# Developer runs this once
pnpm changeset

# Selects: which packages, bump type, description
# Creates: .changeset/lazy-load-icons.md

# The file looks like:
---
"@whydrf/nava-icon-react": minor
"@whydrf/nava-icon-vue": minor
---

Lazy-load dynamic Icon component via per-icon import () chunks

The changeset file is committed with the code. It tells the system what to bump and why.

What Changesets does on CI:

State Action
Changeset files exist Creates/updates a version PR with bumped package.json versions and changelogs
No changeset files (version PR was merged) Publishes all bumped packages to npm

Why not semantic-release? semantic-release is fully automated but all-or-nothing - it decides everything from commit messages. For multi-package monorepos where you sometimes want to bump only specific packages, Changesets gives you that control.

Configuration:

{
  "commit": false,
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": [
    "@nava-icon/docs",
    "@whydrf/nava-icon-core"
  ]
}

The ignore list excludes packages that shouldn’t be published. The updateInternalDependencies: "patch" ensures that when an internal dependency bumps, its consumers automatically get a patch bump too.

2. CI Verification Pipeline

Before anything gets published, every change goes through verification. The pipeline lives in .github/workflows/ci.yml:

Trigger Strategy

on:
  pull_request:
    branches: [ develop, stage, main ]
    paths:
      - "assets/**"
      - "packages/**"
      - "scripts/**"
      - ".github/workflows/ci.yml"
  push:
    branches: [ main ]
    paths:
      - "assets/**"
      - "packages/**"
      - "scripts/**"

Path filtering is a deployment optimization. If someone updates a README, you don’t need to rebuild 950 icon components. Only changes to source code, packages, or the CI config itself trigger the pipeline.

Concurrency Control

concurrency:
  group: ci-${{ github.event.pull_request.base.ref || github.ref }}
  cancel-in-progress: true

If a developer pushes twice in quick succession, the first CI run is cancelled. This saves compute and gives faster feedback.

The Verify Job

verify:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: 24
        cache: pnpm
    - run: pnpm install --frozen-lockfile
    - run: pnpm generate
    - run: pnpm lint
    - run: pnpm typecheck
    - run: pnpm build

Every PR gets: dependency install → code generation → lint → typecheck → build. If any step fails, the PR can’t be merged.

Environment Gating

The pipeline uses GitHub Actions environments to control what runs where:

Environment Branch Purpose
DEV develop Development validation
STAGING stage Staging validation
PRODUCTION main Release to npm

The PRODUCTION environment can require manual approval - a human checkpoint before anything gets published.

3. Docker Verification

Docker isn’t used for deployment here. It’s used for verification - ensuring the project builds correctly in a clean, reproducible environment.

Why Docker for Verification? Local development environments differ. Different Node versions, different OS behaviors, cached dependencies. Docker eliminates this variable:

FROM node:24-alpine AS base
RUN corepack enable

FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
RUN pnpm install --frozen-lockfile

FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm generate
RUN pnpm build

FROM base AS verifier
COPY --from=builder /app .
RUN pnpm lint

Layer Caching Strategy The multi-stage build is designed for Docker layer caching:

  • deps layer - Only copies manifest files, runs pnpm install. Changes rarely, so this layer is cached.
  • builder layer - Copies dependencies from deps, runs generation and build. Rebuilds on code changes.
  • verifier layer - Copies everything from builder, runs lint. Used for CI verification.

Code changes don’t invalidate the dependency cache. This makes rebuilds fast.

Local Docker Workflow

pnpm docker:build          # Build the image
pnpm docker:verify         # Run lint in container
pnpm docker:shell          # Interactive shell for debugging
pnpm docker:compose:verify # Full verification via docker-compose

Developers can verify the Docker build locally before pushing. This catches Dockerfile issues early.

4. Automated npm Publishing

This is where the deployment pipeline gets interesting. The flow is:

Release PR Flow

Developer commits changeset file
         │
         ▼
Push to main
         │
         ▼
changesets/action creates version PR (bumps package.json versions, updates CHANGELOGs)
         │
         ▼
auto-merge workflow enables squash merge
         │
         ▼
CI runs on version PR
         │
         ▼
PR merged automatically
         │
         ▼
Push to main (from merge)
         │
         ▼
changesets/action detects no pending changesets → runs `changeset publish`
         │
         ▼
Packages published to npm
GitHub Releases created

The Auto-Merge Step

# .github/workflows/auto-merge-changeset.yml
auto-merge:
  if: github.head_ref == 'changeset-release/main'
  steps:
    - run: gh pr merge ${{ github.event.pull_request.number }} --auto --squash

When the Changesets action creates the version PR, this workflow enables auto-merge. Once CI passes, GitHub merges it without human intervention. The next CI run then publishes.

Publishing Configuration

publish:
  if: github.event_name == 'push' && github.ref_name == 'main'
  environment: PRODUCTION
  permissions:
    contents: write
    pull-requests: write
  steps:
    - uses: changesets/action@v1
      with:
        publish: pnpm publish:ci
        createGithubReleases: true
      env:
        NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

The publish:ci script runs changeset publish, which:

  • Reads the version bumps from the merged changeset PR
  • Publishes each changed package to npm
  • Creates GitHub Releases for each package

5. Container Image Publishing

After npm packages are published, a Docker image is built and pushed to GitHub Container Registry:

docker-publish:
  needs: publish
  steps:
    - uses: docker/login-action@v3
      with:
        registry: ghcr.io
        password: ${{ secrets.GITHUB_TOKEN }}
    - uses: docker/metadata-action@v5
      with:
        images: ghcr.io/${{ github.repository_owner }}/nava-icons
        tags: |
          type=raw,value=latest
          type=sha
    - uses: docker/build-push-action@v6
      with:
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

Every release gets two tags: latest and a SHA-based tag. The SHA tag lets you pin to a specific release if needed.

Why Push Docker Images for a Library? Three reasons:

  1. Reproducible verification - Anyone can pull the image and verify the build
  2. CI cache - Subsequent builds can use the cached image
  3. Future deployment - If you add a playground or demo app, the image is ready

6. Documentation Deployment

The docs site is a Next.js app with static export. It deploys to GitHub Pages:

# .github/workflows/deploy-doc.yml
on:
  push:
    branches: [ main ]
    paths: [ "docs/**" ]

The pipeline builds the docs, uploads the artifact, and deploys to GitHub Pages. Only docs/ changes trigger this - icon or package changes don’t redeploy docs unless the docs themselves change. This separation matters for deployment efficiency. Documentation deployment is independent of package deployment.

7. Deployment Timeline

Here’s what a typical release looks like end-to-end:

Time Event
T+0:00 Developer commits changeset, pushes to main
T+2:00 Version PR created by changesets/action
T+2:00 Auto-merge enabled
T+5:00 CI passes, version PR merged
T+5:00 Push to main triggers publish job
T+10:00 Packages published to npm
T+10:00 GitHub Releases created
T+12:00 Docker image pushed to GHCR
T+12:00 Documentation redeployed (if docs changed)

Total time from push to published: ~10 minutes. Human involvement: zero (after the initial push).

8. Security in Deployment

A deployment pipeline that publishes to npm needs security controls:

Control Implementation
Token management NPM_TOKEN stored as a GitHub secret, never logged or exposed
Minimal permissions Each job declares only the permissions it needs
Frozen lockfile pnpm install --frozen-lockfile prevents dependency tampering
Environment protection PRODUCTION environment can require manual approval
Path-based triggers CI only runs on relevant file changes
Permission scoping contents: read by default, write only where needed

What I’d Add for Higher Security

  • npm provenance - Sign packages with GitHub OIDC to prove they came from your CI
  • SLSA build provenance - Generate attestations for build artifacts
  • Dependency scanning - Automated vulnerability checks on dependencies
  • Branch protection - Require reviews for changes to CI configuration

9. Deployment Patterns Worth Stealing

These patterns work for any multi-package monorepo, not just icon libraries:

Pattern 1: Path-Based Triggers

paths:
  - "packages/**"
  - ".github/workflows/ci.yml"

Don’t rebuild everything when documentation changes. Trigger CI only on relevant paths.

Pattern 2: Concurrency Groups

concurrency:
  group: ci-${{ github.event.pull_request.base.ref || github.ref }}
  cancel-in-progress: true

Cancel in-progress runs when new commits push. Save compute, get faster feedback.

Pattern 3: Environment Gating
Use GitHub Actions environments to separate staging from production. Add manual approval gates for production releases.

Pattern 4: Automated Version PRs
Let a tool (Changesets, Lerna, etc.) create the version PR. Human reviews it, CI validates it, auto-merge handles the rest.

Pattern 5: Docker for Verification, Not Deployment
Use Docker to ensure builds work in a clean environment. Don’t confuse verification (does it build?) with deployment (does it run?).

Pattern 6: Separate Documentation Deployment
Docs deployment should be independent of package deployment. Different triggers, different pipelines, different failure modes.

10. What I’d Improve

The pipeline works, but there’s always room for improvement:

Improvement Why It Matters
npm provenance Proves packages came from your CI, prevents supply chain attacks
Bundle size tracking Catch regressions before they ship to users
Integration tests Verify published packages install and work correctly
Automated dependency updates Keep dependencies current without manual effort
Release notifications Slack/Discord alerts when packages are published
Canary releases Test changes before a full release

Conclusion

The important part of building a deployment pipeline isn’t choosing between pnpm, Turborepo, or Changesets. It’s designing a system where every artifact has a predictable path from source to distribution. For multi-package monorepos, that path looks like:

  • Code changes → CI verification (lint, typecheck, build)
  • Version changes → Automated version PR
  • Merged PR → npm publish + container build
  • Published → Documentation update

The key principles:

  • Automate everything that can be automated. Manual steps are where errors happen.
  • Use path-based triggers. Don’t rebuild what hasn’t changed.
  • Gate production releases. A human should approve before anything goes live.
  • Use Docker for verification. Reproducible builds matter.
  • Separate concerns. Docs deployment ≠ package deployment ≠ container builds.

The goal is a system where a developer can push a change and trust that the right packages get published to the right places in the right order - every time.

Deployment pipeline built with GitHub Actions, Changesets, Docker, and Turborepo. Source code: github.com/nava-icon/nava-icon

Comments

No comments yet. Start the discussion.