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:
- Semantic versioning that respects TypeScript’s type contracts – the published
package.jsonversion is the single source of truth for both runtime and compile‑time checks. - Automated changelog generation that never drifts from the actual changes.
- 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..changesetfolder 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
- Create a feature branch
git checkout -b feat/add-user-type
- Add a new type to
core
// packages/core/src/user.ts
export interface User {
id: string;
name: string;
}
- Export it from the public entry point
// packages/core/src/index.ts
export * from './user';
- 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.
- Open a PR – the CI runs the three jobs. The validator sees that a new exported type was added, confirms the
minorbump, and the pipeline eventually publishes@myorg/core@1.1.0. Downstream@myorg/ui(which depends oncore) 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
Userdiffers (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
uinever get a mismatched peer dependency. - The published
distfolder 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.
Member discussion