Claude Code Lerna Changelog Generation (2026)
Claude Code Lerna Changelog Generation Workflow Guide
Managing changelogs across a Lerna monorepo can quickly become a tedious task as your project grows. With multiple packages, each following different versioning schemes, manually tracking changes and generating meaningful release notes is error-prone and time-consuming. This guide shows you how to use Claude Code to automate and intelligentize your changelog generation workflow.
Understanding the Lerna Changelog Challenge
Lerna monorepos present unique challenges for changelog management. Each package in your monorepo may have its own release cycle, version numbers, and change patterns. When you run lerna publish or lerna version, you need changelogs that accurately reflect what changed in each package, not just a flat list of all commits.
The core problem is attribution: a single git repository contains commits that touch many packages simultaneously. A refactor that spans @myorg/ui, @myorg/core, and @myorg/api produces commits that belong in all three changelogs, but not necessarily with the same wording. Automated tools that operate on raw commit messages often generate noisy or redundant changelogs. Claude Code adds an intelligence layer that categorizes, deduplicates, and rewrites entries into clean, human-readable prose.
Claude Code can help by:
- Parsing commit messages to categorize changes (feat, fix, docs, etc.)
- Grouping changes by package
- Generating human-readable summaries
- Integrating with conventional commits standards
- Detecting cross-package dependency changes that warrant coordinated release notes
Why Manual Changelog Management Fails at Scale
Consider a monorepo with 12 packages releasing monthly. Each release cycle involves:
| Task | Manual Time | Automated Time |
|---|---|---|
| Identify which packages changed | 15 min | 30 sec (lerna changed) |
| Collect commits per package | 30 min | 1 min (git log + path filter) |
| Categorize by type (feat/fix/etc.) | 20 min | Automatic (conventional commits) |
| Write human-readable summaries | 45 min | 2 min (Claude Code) |
| Cross-reference related packages | 20 min | 5 min (dependency graph) |
| Format and insert into CHANGELOG.md | 15 min | 30 sec (script) |
| Total | ~2.5 hours | ~10 minutes |
At twelve packages, the manual approach consumes a full afternoon before every release. The automated approach reduces it to a brief review step.
Setting Up Your Changelog Generation Skill
First, create a dedicated Claude skill for changelog operations. This skill will encapsulate the logic for parsing, grouping, and formatting your monorepo changes.
---
name: lerna-changelog
description: Generate and manage changelogs in Lerna monorepos
---
This skill restricts tool access to only what’s necessary, reading your repository structure, executing git and Lerna commands, and writing output files.
Project Prerequisites
Before the workflow runs reliably, your repository needs a few structural requirements:
Confirm Lerna is installed and configured
npx lerna --version
Confirm your lerna.json has version config
cat lerna.json
A minimal lerna.json for changelog automation:
{
"version": "independent",
"npmClient": "npm",
"packages": ["packages/*"],
"command": {
"version": {
"conventionalCommits": true,
"changelogPreset": "conventional-changelog-angular"
}
}
}
Setting "version": "independent" is important. It means each package gets its own version number and its own changelog entries, rather than all packages bumping together.
Installing Required Dependencies
Core Lerna
npm install --save-dev lerna
Conventional commits tooling
npm install --save-dev @commitlint/config-conventional @commitlint/cli husky
Changelog preset
npm install --save-dev conventional-changelog-angular
If you want programmatic access
npm install --save-dev conventional-changelog-core
Set up commit linting so every commit in the project follows the expected format:
commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };
.husky/commit-msg
npx --no -- commitlint --edit "$1"
With this in place, commits that don’t follow the type(scope): description format are rejected at the commit stage, before they pollute your history.
Parsing Commits with Claude
The core of intelligent changelog generation is commit parsing. Claude can analyze your git history and categorize changes based on conventional commit format:
// analyze-commits.js
const { execSync } = require('child_process');
function getCommitsSince(tag) {
const range = tag ? `${tag}..HEAD` : '--all';
const output = execSync(`git log ${range} --pretty=format:"%s|%h|%an|%ae"`, {
encoding: 'utf-8'
});
return output.trim().split('\n').map(line => {
const [message, hash, author, email] = line.split('|');
const [type, scope, ...rest] = message.replace(/^(\w+)(\(.+\))?:\s*/, '$1$2|').split('|');
return {
hash,
message,
type: type || 'other',
scope: scope?.replace(/[()]/g, ''),
author,
email
};
});
}
module.exports = { getCommitsSince };
This script parses commits into structured data that Claude can then organize into meaningful changelog entries.
Attributing Commits to Packages
Raw commit parsing gives you a flat list. The next step is mapping each commit to the packages it actually touched. Git’s -- path filter is the most reliable approach:
// package-commits.js
const { execSync } = require('child_process');
const path = require('path');
function getChangedFilesForCommit(hash) {
const output = execSync(`git diff-tree --no-commit-id -r --name-only ${hash}`, {
encoding: 'utf-8'
});
return output.trim().split('\n');
}
function attributeCommitToPackages(commit, packagePaths) {
const changedFiles = getChangedFilesForCommit(commit.hash);
const touchedPackages = new Set();
for (const file of changedFiles) {
for (const [pkgName, pkgPath] of Object.entries(packagePaths)) {
if (file.startsWith(pkgPath + '/')) {
touchedPackages.add(pkgName);
}
}
}
return Array.from(touchedPackages);
}
function buildPackageCommitMap(commits, packagePaths) {
const map = {};
for (const commit of commits) {
const packages = attributeCommitToPackages(commit, packagePaths);
for (const pkg of packages) {
if (!map[pkg]) map[pkg] = [];
map[pkg].push(commit);
}
}
return map;
}
module.exports = { buildPackageCommitMap, attributeCommitToPackages };
This produces a map keyed by package name, each containing only the commits that touched files within that package’s directory. Commits that span multiple packages appear in each relevant package’s list.
Handling Scope vs. Path Attribution
Conventional commits support an optional scope field: feat(auth): add OAuth2 support. When developers use scopes that match package names, you can use scope-based attribution instead of (or alongside) path-based attribution:
function attributeByScope(commits, packageNames) {
const map = {};
for (const commit of commits) {
// Scope attribution: "feat(core): ..." goes to @myorg/core
if (commit.scope) {
const matchingPkg = packageNames.find(
name => name === commit.scope || name.endsWith(`/${commit.scope}`)
);
if (matchingPkg) {
if (!map[matchingPkg]) map[matchingPkg] = [];
map[matchingPkg].push({ ...commit, attributionMethod: 'scope' });
continue;
}
}
// Fall back to path-based attribution
// (handled by attributeCommitToPackages)
}
return map;
}
A good practice is to prefer scope attribution when the scope is present and matches a known package, and fall back to path analysis otherwise.
Integrating with Lerna’s Versioning
Claude can read Lerna’s package metadata to understand which packages changed:
Get changed packages since last release
lerna changed --since=last-release --json
Combine this with commit analysis to generate package-specific changelogs:
async function generatePackageChangelogs() {
const lernaJson = JSON.parse(await readFile('lerna.json'));
const packages = await glob('packages/*/package.json');
for (const pkg of packages) {
const pkgData = JSON.parse(await readFile(pkg));
const commits = await getCommitsForPackage(pkgData.name);
if (commits.length > 0) {
const changelog = formatChangelog(commits, pkgData.name);
await writeFile(
`packages/${pkgData.name}/CHANGELOG.md`,
changelog,
{ append: true }
);
}
}
}
This approach ensures each package maintains its own accurate changelog.
Reading the Lerna Dependency Graph
In a monorepo, packages depend on each other. When @myorg/core bumps a major version, every package that depends on it should mention the upstream change in its own changelog. Claude Code can read the dependency graph:
const { execSync } = require('child_process');
function getLernaDependencyGraph() {
const output = execSync('lerna list --graph --json', { encoding: 'utf-8' });
return JSON.parse(output);
}
function getAffectedByUpstream(pkgName, graph) {
// Find all packages that list pkgName as a dependency
const affected = [];
for (const [name, deps] of Object.entries(graph)) {
if (deps.includes(pkgName) && name !== pkgName) {
affected.push(name);
}
}
return affected;
}
With this information, Claude Code can append a note to downstream changelogs:
Dependencies
- Updated dependency on `@myorg/core` to `^3.0.0` (breaking change in upstream)
This kind of cross-package transparency is something purely mechanical tools miss entirely.
Building the Changelog Generation Workflow
Here’s how to orchestrate the full workflow with Claude:
- Detect Changes: Run
lerna changedto find packages with updates - Fetch Commits: Get commits for each changed package
- Categorize: Parse commit types using conventional commit patterns
- Format: Generate Markdown with proper headings and sections
- Update Files: Append or overwrite package changelogs
Changelog Generation Workflow
Detect Changed Packages
Execute lerna changed to identify which packages have updates since the last release.
Analyze Commit History
For each changed package:
- Fetch commits touching that package's directory
- Parse conventional commit format (type, scope, description)
- Group by type: Features, Bug Fixes, Breaking Changes, etc.
Generate Changelog Entries
Create well-formatted Markdown entries:
- Use semantic headings
- Include commit hash references
- Link to issues and PRs when available
Update Changelog Files
Automatically append new entries to each package's CHANGELOG.md
The Complete Orchestration Script
Here is a production-ready orchestration script that ties all the pieces together:
// scripts/generate-changelogs.js
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const PACKAGES_DIR = path.resolve(__dirname, '../packages');
async function main() {
// Step 1: Find changed packages
let changedPackages;
try {
const output = execSync('npx lerna changed --json', { encoding: 'utf-8' });
changedPackages = JSON.parse(output);
} catch {
console.log('No packages have changed since last release.');
return;
}
console.log(`Found ${changedPackages.length} changed packages.`);
// Step 2: For each changed package, generate changelog
for (const pkg of changedPackages) {
const pkgDir = pkg.location;
const lastTag = getLastTagForPackage(pkg.name);
const commits = getCommitsForDirectory(pkgDir, lastTag);
if (commits.length === 0) {
console.log(` ${pkg.name}: no commits found, skipping.`);
continue;
}
const categorized = categorizeCommits(commits);
const entry = formatChangelogEntry(pkg.version, categorized);
prependToChangelog(path.join(pkgDir, 'CHANGELOG.md'), entry);
console.log(` ${pkg.name}: wrote ${commits.length} entries.`);
}
}
function getLastTagForPackage(pkgName) {
try {
return execSync(`git describe --tags --abbrev=0 --match="${pkgName}@*"`, {
encoding: 'utf-8'
}).trim();
} catch {
return null; // No prior tag; include all commits
}
}
function getCommitsForDirectory(dir, since) {
const range = since ? `${since}..HEAD` : 'HEAD';
const relDir = path.relative(process.cwd(), dir);
const output = execSync(
`git log ${range} --pretty=format:"%H|%s|%an" -- ${relDir}`,
{ encoding: 'utf-8' }
);
if (!output.trim()) return [];
return output.trim().split('\n').map(line => {
const [hash, subject, author] = line.split('|');
const match = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s+(.+)/);
return {
hash: hash.slice(0, 8),
type: match ? match[1] : 'other',
scope: match ? match[2] : null,
breaking: match ? !!match[3] : false,
description: match ? match[4] : subject,
author
};
});
}
function categorizeCommits(commits) {
const categories = {
breaking: [],
feat: [],
fix: [],
perf: [],
docs: [],
refactor: [],
test: [],
chore: [],
other: []
};
for (const commit of commits) {
if (commit.breaking) {
categories.breaking.push(commit);
} else if (categories[commit.type]) {
categories[commit.type].push(commit);
} else {
categories.other.push(commit);
}
}
return categories;
}
function formatChangelogEntry(version, categorized) {
const date = new Date().toISOString().split('T')[0];
const lines = [`\n## ${version} (${date})\n`];
const sections = [
['BREAKING CHANGES', categorized.breaking],
['Features', categorized.feat],
['Bug Fixes', categorized.fix],
['Performance', categorized.perf],
['Documentation', categorized.docs],
['Refactoring', categorized.refactor],
['Tests', categorized.test],
['Maintenance', categorized.chore],
['Other', categorized.other]
];
for (const [heading, commits] of sections) {
if (commits.length === 0) continue;
lines.push(`### ${heading}\n`);
for (const c of commits) {
const scope = c.scope ? `${c.scope}: ` : '';
lines.push(`- ${scope}${c.description} ([${c.hash}])`);
}
lines.push('');
}
return lines.join('\n');
}
function prependToChangelog(filePath, content) {
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '# Changelog\n';
const header = existing.startsWith('# Changelog') ? existing : `# Changelog\n\n${existing}`;
const insertPoint = header.indexOf('\n## ');
const newContent = insertPoint === -1
? header + content
: header.slice(0, insertPoint) + content + header.slice(insertPoint);
fs.writeFileSync(filePath, newContent);
}
main().catch(console.error);
Wire this into your package.json scripts:
{
"scripts": {
"changelog": "node scripts/generate-changelogs.js",
"release": "npm run changelog && lerna version --no-changelog && git push --follow-tags"
}
}
Conventional Commits Integration
For best results, enforce conventional commits in your workflow. This standard formats commit messages as:
<type>(<scope>): <description>
[optional body]
[optional footer]
Common types include:
feat: New featuresfix: Bug fixesdocs: Documentation changesstyle: Code style changesrefactor: Code refactoringtest: Test updateschore: Maintenance tasks
When your team follows this convention, Claude can automatically generate well-organized changelogs with proper categorization.
Commit Type Decision Guide
Developers often debate which type to use. This table clarifies the intent:
| Type | When to Use | Example |
|---|---|---|
feat |
Any new capability exposed to consumers | feat(auth): add passwordless login |
fix |
Correcting a defect that caused wrong behavior | fix(cart): prevent negative item quantities |
perf |
Optimization with no behavior change | perf(search): add database index on email column |
refactor |
Code restructuring with no behavior change | refactor(payments): extract StripeClient class |
docs |
Documentation, comments, README only | docs(api): document rate limiting headers |
test |
Adding or correcting tests only | test(auth): add edge cases for token expiry |
style |
Formatting, whitespace, semicolons | style: apply prettier to all files |
chore |
Tooling, dependencies, CI config | chore(deps): upgrade eslint to v9 |
build |
Build system changes | build: switch from webpack to vite |
ci |
CI/CD pipeline changes | ci: add staging environment deploy step |
A common mistake is using chore for everything non-feature. Keeping types accurate means users reading your changelogs get honest signal about what changed.
Enforcing Conventional Commits With Commitlint
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
// Allow scopes that match your package names
'scope-enum': [
2,
'always',
['core', 'ui', 'api', 'auth', 'payments', 'notifications', 'docs']
],
// Subject must not end with a period
'subject-full-stop': [2, 'never', '.'],
// Subject must be lowercase
'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']],
// Body must be present for breaking changes
'body-max-line-length': [1, 'always', 100]
}
};
The scope-enum rule is particularly valuable in monorepos: it forces developers to declare which package their commit targets, giving you clean scope-based attribution for free.
Actionable Tips for Better Changelogs
- Automate Version Tagging
Pair changelog generation with Lerna’s version command:
lerna version --conventional-commits --changelog
This automatically updates versions and generates changelogs based on conventional commits.
- Include Context in Commits
Encourage detailed commit messages that include:
- What changed and why
- Related issue numbers
- Breaking change notices
A commit with a body gives Claude Code much more to work with:
feat(checkout): add Apple Pay support
Adds Apple Pay as a checkout option on Safari browsers.
Requires APPLE_PAY_MERCHANT_ID to be set in environment config.
The payment flow now checks for Apple Pay availability via
window.ApplePaySession before rendering the button.
Closes #412
BREAKING CHANGE: PaymentMethod enum adds 'apple_pay' value;
consumers must handle the new variant.
Versus a bare feat(checkout): add Apple Pay support. the former produces a rich changelog entry; the latter produces a one-liner.
- Review Before Publishing
Generate changelogs as part of your PR process so reviewers can verify completeness:
Preview changelog without publishing
claude --print "/lerna-changelog" --preview
- Handle Monorepo Dependencies
When packages depend on each other, reference those relationships in changelogs:
const dependencies = require('./package.json').dependencies;
function getDeprecationNotices(pkgName) {
return Object.entries(dependencies).map(([dep, version]) => {
if (isDeprecated(dep)) {
return `Note: This package uses \`${dep}\` which is deprecated.`;
}
return null;
}).filter(Boolean);
}
- Tag Releases Consistently
The entire workflow depends on git tags being present and consistently named. Adopt a naming convention and never deviate:
For independent versioning (recommended)
Tags look like: @myorg/[email protected]
lerna version --conventional-commits
For fixed versioning
Tags look like: v1.5.0
lerna version --conventional-commits
If you have an older repository with inconsistent tag formats, normalize them before setting up the automated workflow:
List all existing tags
git tag -l | sort -V
Rename a tag (requires push force on the tag ref)
git tag new-name old-name
git tag -d old-name
git push origin :refs/tags/old-name
git push origin new-name
- Add a Root-Level Summary Changelog
Individual package changelogs are ideal for package consumers, but your team often wants a single view of everything that shipped in a release. Generate a root CHANGELOG.md that aggregates entries:
async function generateRootChangelog(version, packageChangelogs) {
const date = new Date().toISOString().split('T')[0];
const lines = [`## Release ${version} (${date})\n`];
for (const [pkgName, entries] of Object.entries(packageChangelogs)) {
if (entries.length === 0) continue;
lines.push(`### ${pkgName}\n`);
for (const entry of entries) {
lines.push(`- ${entry.description}`);
}
lines.push('');
}
const existing = fs.existsSync('CHANGELOG.md')
? fs.readFileSync('CHANGELOG.md', 'utf-8')
: '# Changelog\n';
fs.writeFileSync('CHANGELOG.md', existing + '\n' + lines.join('\n'));
}
Advanced: Multi-Language Monorepo Support
If your Lerna monorepo contains packages in different languages (TypeScript, Python, Rust), adapt your commit parsing:
function detectLanguageFromPath(filePath) {
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) return 'typescript';
if (filePath.endsWith('.py')) return 'python';
if (filePath.endsWith('.rs')) return 'rust';
return 'unknown';
}
function getCommitsForPackage(pkgPath) {
// Get commits that touched files in this package
const output = execSync(
`git log --all --format="%H %s" -- ${pkgPath}`,
{ encoding: 'utf-8' }
);
return parseCommitLog(output);
}
This ensures accurate changelog generation regardless of your monorepo’s composition.
Python Packages in a Node Monorepo
Some teams keep a Python SDK alongside Node packages in the same Lerna repo. The path-based attribution handles this naturally, but you may want language-specific formatting:
function formatPythonChangelogEntry(version, commits) {
// Python projects often use a different date format and style
const date = new Date().toISOString().split('T')[0];
const lines = [`${version} (${date})\n${'='.repeat(version.length + date.length + 3)}\n`];
for (const commit of commits) {
lines.push(`* ${commit.description}`);
}
return lines.join('\n');
}
Rust Crates in a Node Monorepo
Rust crates follow semantic versioning strictly. When a Rust crate in your monorepo has a breaking change, flag it prominently:
function formatRustChangelogEntry(version, commits) {
const hasBreaking = commits.some(c => c.breaking);
const header = hasBreaking
? `## ${version} - BREAKING CHANGES\n`
: `## ${version}\n`;
const lines = [header];
// ... format entries
return lines.join('\n');
}
Integrating With CI/CD
The changelog workflow reaches its full value when automated in CI. Here is a GitHub Actions workflow that generates changelogs on every merge to main:
.github/workflows/changelog.yml
name: Generate Changelogs
on:
push:
branches: [main]
jobs:
changelog:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history required for git log
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Generate changelogs
run: npm run changelog
- name: Commit changelog updates
run: |
git add "packages/*/CHANGELOG.md" CHANGELOG.md
git diff --cached --quiet || git commit -m "chore: update changelogs [skip ci]"
git push
The fetch-depth: 0 is critical, without the full git history, git log cannot look back past the shallow clone depth and your changelogs will be incomplete.
Conclusion
Automating changelog generation in Lerna monorepos with Claude Code eliminates manual tracking and ensures consistent, comprehensive release notes. By combining conventional commits, Lerna’s package detection, and Claude’s natural language processing, you create a reproducible workflow that scales with your project.
Start by setting up the basic commit parsing, then gradually add features like dependency tracking, multi-language support, and preview workflows. Your future self, and your users, will thank you for the clear, organized changelogs.
The single biggest lever in this entire system is consistent conventional commits. Once every developer on your team writes structured commits with accurate types and scopes, the changelog pipeline becomes nearly automatic. The scripts handle the mechanics; Claude Code handles the judgment calls around categorization and human-readable summaries. Together, they reduce one of the most tedious release tasks to a ten-minute review.
Try it: Paste your error into our Error Diagnostic for an instant fix.
Related Reading
- Claude Code for PR Changelog Generation Workflow
- Claude Code Astro Static Site Generation Workflow Guide
- Claude Code Automated Alt Text Generation Workflow
- Claude Code for Lerna Monorepo Workflow
Built by theluckystrike. More at zovo.one
Find the right skill → Browse 155+ skills in our Skill Finder.