7 min read

Type‑Safe Semantic Versioning & Automated Releases in a TypeScript Monorepo (Changesets + GitHub Actions)

Learn how to enforce type‑safe version bumps and ship packages automatically in a TS monorepo using Changesets and GitHub Actions.
Type‑Safe Semantic Versioning & Automated Releases in a TypeScript Monorepo (Changesets + GitHub Actions)

Introduction

Maintaining a TypeScript monorepo that contains many inter‑dependent libraries can feel like juggling knives. One slip—an incorrect version bump, a missed changelog entry, or a broken publish step—can break downstream projects and waste hours of debugging.

In this article we’ll build a type‑safe release workflow that solves three common pain points:

  1. Semantic versioning that respects TypeScript’s type contracts – the published package.json version is the single source of truth for both runtime and compile‑time checks.
  2. Automated changelog generation that never drifts from the actual changes.
  3. Zero‑touch CI/CD that validates, builds, and publishes every package that truly changed.

We’ll use the Changesets library for version orchestration and GitHub Actions for the CI pipeline. The final setup works on any monorepo managed by pnpm or yarn workspaces, but the concepts are transferable to npm workspaces as well.

TL;DR – After reading this you’ll be able to push a feature branch, open a PR, and let the pipeline publish a new, type‑checked version of only the packages that need it.

1. Why “type‑safe” semantic versioning matters

Semantic Versioning (MAJOR.MINOR.PATCH) is a contract:

Increment Meaning TypeScript impact
MAJOR Breaking API change Consumers must update type imports
MINOR Additive, backwards‑compatible feature New types are added, old ones stay
PATCH Bug‑fix, no API change No type impact

If a library bumps its version without a corresponding type change (or vice‑versa), downstream packages can compile successfully while still pulling an incompatible binary at runtime. The solution is to tie the version bump to a type‑level assertion that the change is indeed breaking, additive, or neutral.

Changesets enforces this by requiring the author to declare the change type (major, minor, patch) when they create a changeset file. We’ll augment that step with a custom TypeScript lint rule that validates the declared bump against the actual diff of exported types.


2. Setting up the monorepo

# Create a fresh repo
mkdir ts-mono && cd ts-mono
git init

# Initialise a pnpm workspace
pnpm init -y
cat > pnpm-workspace.yaml <<'EOF'
packages:
  - "packages/*"
EOF

Create two simple libraries:

mkdir -p packages/core packages/ui
# core package
cat > packages/core/package.json <<'EOF'
{
  "name": "@myorg/core",
  "version": "0.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc -p ."
  }
}
EOF

# ui package that depends on core
cat > packages/ui/package.json <<'EOF'
{
  "name": "@myorg/ui",
  "version": "0.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "dependencies": {
    "@myorg/core": "workspace:*"
  },
  "scripts": {
    "build": "tsc -p ."
  }
}
EOF

Add a shared tsconfig.base.json at the repo root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "exclude": ["node_modules", "dist"]
}

Each package adds a tsconfig.json that extends the base:

{
  "extends": "../../tsconfig.base.json",
  "include": ["src"]
}

Now we have a minimal monorepo ready for the release tooling.


3. Installing Changesets

pnpm add -D @changesets/cli
pnpm changeset init

The init command creates:

  • .changeset/config.json – tells Changesets where packages live and how to generate changelogs.
  • .changeset folder where individual changeset files will be stored.

3.1 Enforcing type‑safety in changeset files

Create a custom script scripts/validate-changeset.ts:

#!/usr/bin/env ts-node

import * as fs from 'fs';
import * as path from 'path';
import { Project } from 'ts-morph';
import glob from 'fast-glob';

function getExportedTypes(pkgPath: string): Set<string> {
  const project = new Project({ tsConfigFilePath: `${pkgPath}/tsconfig.json` });
  const sourceFiles = project.getSourceFiles('src/**/*.ts');
  const types = new Set<string>();

  sourceFiles.forEach(sf => {
    sf.getExportSymbols().forEach(sym => {
      const decl = sym.getDeclarations()[0];
      if (decl && (decl.getKindName().includes('Interface') || decl.getKindName().includes('TypeAlias') || decl.getKindName().includes('Class'))) {
        types.add(sym.getName());
      }
    });
  });

  return types;
}

// Compare exported type sets between two commits
function diffExports(oldPkg: string, newPkg: string): { added: Set<string>; removed: Set<string> } {
  const oldTypes = getExportedTypes(oldPkg);
  const newTypes = getExportedTypes(newPkg);
  const added = new Set([...newTypes].filter(x => !oldTypes.has(x)));
  const removed = new Set([...oldTypes].filter(x => !newTypes.has(x)));
  return { added, removed };
}

// Main
(async () => {
  const [pkgName, baseRef = 'HEAD~1'] = process.argv.slice(2);
  if (!pkgName) {
    console.error('Usage: validate-changeset <package-name> [base-ref]');
    process.exit(1);
  }

  const pkgPath = path.resolve('packages', pkgName);
  const tmpDir = path.resolve('.tmp', `${pkgName}-${Date.now()}`);

  // Checkout old version into a temp folder
  await exec(`git checkout ${baseRef} -- ${pkgPath}`);
  const oldExports = getExportedTypes(pkgPath);
  // Restore current version
  await exec(`git checkout -`);
  const newExports = getExportedTypes(pkgPath);

  const { added, removed } = diffExports(oldExports, newExports);
  const breaking = removed.size > 0;
  const additive = added.size > 0 && !breaking;

  const changesetFile = glob.sync(`.changeset/*.md`).find(f => f.includes(pkgName));
  if (!changesetFile) {
    console.log('No changeset for this package – nothing to validate.');
    return;
  }
  const contents = fs.readFileSync(changesetFile, 'utf-8');
  const declared = /---\n(major|minor|patch)\n---/.exec(contents)?.[1];

  if (breaking && declared !== 'major') {
    console.error(`❌ Breaking change detected in ${pkgName} but changeset declares "${declared}". Use "major".`);
    process.exit(1);
  }
  if (additive && declared !== 'minor') {
    console.error(`❌ Additive change detected in ${pkgName} but changeset declares "${declared}". Use "minor".`);
    process.exit(1);
  }
  console.log('✅ Changeset type matches exported type diff.');
})();

Add it to package.json scripts:

"validate:changeset": "ts-node scripts/validate-changeset.ts"

The script is run in CI (see the workflow later). It guarantees that a major bump is only allowed when exported types are removed or altered incompatibly, and a minor bump when new types appear.


4. GitHub Actions pipeline

Create .github/workflows/release.yml:

name: Release

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  # 1️⃣ Lint, type‑check and unit test everything
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - name: Install deps
        run: pnpm install --frozen-lockfile
      - name: Build & type‑check
        run: pnpm -r run build
      - name: Run tests
        run: pnpm -r test

  # 2️⃣ Validate changesets against type diffs
  validate-changeset:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # needed for git diff
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - run: pnpm install --frozen-lockfile
      - name: Install ts-morph & fast-glob
        run: pnpm add -D ts-morph fast-glob
      - name: Run validator
        run: pnpm validate:changeset

  # 3️⃣ If there are pending changesets, version & publish
  version-and-publish:
    needs: validate-changeset
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - name: Install deps
        run: pnpm install --frozen-lockfile
      - name: Create release PR (if needed)
        id: changeset
        run: |
          pnpm changeset version
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add .
          git commit -m "chore: version packages [skip ci]" || echo "No changes to commit"
          git push origin HEAD
      - name: Publish to npm
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          pnpm changeset publish --no-git-tag

What the workflow does

Job Purpose
test Guarantees the monorepo builds and passes unit tests before any version bump.
validate‑changeset Executes the custom validator to enforce type‑safe bump declarations.
version‑and‑publish Runs changeset version (which updates package.json files, creates a changelog entry, and writes a git tag), commits the version bump, and finally publishes only the packages that have changed.

The if: github.ref == 'refs/heads/main' guard ensures that versioning only happens on the protected main branch. Feature branches can still add changeset files; they will be merged and the pipeline will take care of the release.


5. Real‑world example: Adding a new exported type

  1. Create a feature branch
git checkout -b feat/add-user-type
  1. Add a new type to core
// packages/core/src/user.ts
export interface User {
  id: string;
  name: string;
}
  1. Export it from the public entry point
// packages/core/src/index.ts
export * from './user';
  1. Create a changeset
pnpm changeset

Select @myorg/core, choose minor, and write a short description. This creates .changeset/xxxx-minor.md with:

---
"@myorg/core": minor
---

Add User interface for authentication layer.
  1. Open a PR – the CI runs the three jobs. The validator sees that a new exported type was added, confirms the minor bump, and the pipeline eventually publishes @myorg/core@1.1.0. Downstream @myorg/ui (which depends on core) automatically receives the updated version when it’s built in the next CI run.

6. Handling breaking changes

Suppose we rename a property in an exported interface, which is a breaking change.

// packages/core/src/user.ts
export interface User {
  // OLD: uuid: string;
  id: string; // renamed
  name: string;
}

When we run pnpm changeset, we must select major. If we mistakenly choose minor, the validator will:

  • Detect that the exported type set is unchanged, but the shape of User differs (via a simple AST diff or a runtime type‑only check).
  • Fail the CI with a clear message: “Breaking change detected in @myorg/core but changeset declares "minor". Use "major".

Thus the release cannot proceed until the author corrects the bump.


7. Publishing only changed packages

Changesets tracks which packages have been affected by a changeset, as well as dependency propagation. If ui depends on core, a version bump in core automatically bumps ui with a patch (unless ui itself has its own changeset). This ensures that:

  • Consumers of ui never get a mismatched peer dependency.
  • The published dist folder of each package contains the exact code for that version.

The pnpm changeset publish --no-git-tag command reads the generated release PR, publishes each package to the configured registry, and writes a git tag of the form @myorg/core@1.1.0. Tags are valuable for traceability and for tools like dependabot that can automatically bump downstream dependencies.


8. Optional enhancements

Enhancement Why it helps
Zod schema generation from types Guarantees runtime validation matches the TypeScript contract, useful for public APIs.
Changeset status badge Add a markdown badge to the repo README that shows pending changesets.
Pre‑publish script that runs npm pack --dry-run Prevents accidental publishing of large bundles or missing files.
Cache node_modules in GitHub Actions Speeds up the pipeline dramatically for large monorepos.
Release notes in conventional‑commit style Improves readability for downstream developers.

These are optional; the core workflow already provides a robust, type‑safe release process.


9. Recap

  • Semantic versioning + TypeScript → Treat the version number as a type contract.
  • Changesets orchestrates version bumps, changelogs, and dependency propagation.
  • Custom validator ensures the declared bump (major|minor|patch) matches the actual exported type changes.
  • GitHub Actions ties it all together: lint → test → validate → version → publish.

By committing a single changeset file and merging a PR, you get:

  • A type‑checked version bump.
  • An automatically updated CHANGELOG.md.
  • A Git tag and npm publish for every affected package.

No more “I forgot to bump the version” or “the downstream app broke after the release”. The pipeline makes the monorepo self‑healing.

Give it a try on your next project—once set up, the release process becomes invisible, and you can focus on writing code rather than managing versions.