Automate Semantic Versioning in Your CLI Tool
Version management is the unsexy part of maintaining a CLI tool. Most developers either forget to bump versions, use inconsistent commit messages, or manually edit package.json before every release. This leads to confusing changelogs, missing releases, and users running outdated versions without knowing it.
Let's automate this. We'll build a version management system that analyzes git commits, determines the correct version bump, generates changelogs, and publishes — all in one command.
Conventional Commits → Automatic Version Bumps
The Conventional Commits specification gives structure to commit messages:
feat: add --json output flag → minor bump (1.0.0 → 1.1.0)
fix: handle empty input gracefully → patch bump (1.1.0 → 1.1.1)
feat!: redesign config file format → major bump (1.1.1 → 2.0.0)
docs: update README examples → no bump
chore: update dependencies → no bump
Here's how to parse them:
// lib/versioning.ts
interface Commit {
hash: string;
type: string;
scope?: string;
breaking: boolean;
description: string;
}
export function parseCommit(message: string): Commit | null {
const match = message.match(
/^(\w+)(?:\(([^)]+)\))?(!)?:\s+(.+)/
);
if (!match) return null;
return {
hash: '',
type: match[1],
scope: match[2],
breaking: match[3] === '!' || message.includes('BREAKING CHANGE:'),
description: match[4],
};
}
export function determineVersionBump(commits: Commit[]): 'major' | 'minor' | 'patch' | null {
let hasBreaking = false;
let hasFeature = false;
let hasFix = false;
for (const commit of commits) {
if (commit.breaking) hasBreaking = true;
if (commit.type === 'feat') hasFeature = true;
if (commit.type === 'fix' || commit.type === 'perf') hasFix = true;
}
if (hasBreaking) return 'major';
if (hasFeature) return 'minor';
if (hasFix) return 'patch';
return null;
}
Get Commits Since Last Release
import { execSync } from 'node:child_process';
export function getCommitsSinceTag(tag?: string): Commit[] {
const range = tag ? `${tag}..HEAD` : 'HEAD';
const log = execSync(
`git log ${range} --pretty=format:"%H %s"`,
{ encoding: 'utf-8' }
).trim();
if (!log) return [];
return log.split('\n')
.map(line => {
const [hash, ...messageParts] = line.split(' ');
const message = messageParts.join(' ');
const commit = parseCommit(message);
if (commit) commit.hash = hash;
return commit;
})
.filter((c): c is Commit => c !== null);
}
export function getLatestTag(): string | null {
try {
return execSync('git describe --tags --abbrev=0', {
encoding: 'utf-8',
}).trim();
} catch {
return null;
}
}
Generate Changelog
export function generateChangelog(
commits: Commit[],
version: string,
): string {
const sections: Record<string, string[]> = {
'Breaking Changes': [],
'Features': [],
'Bug Fixes': [],
'Performance': [],
'Other': [],
};
for (const commit of commits) {
const scope = commit.scope ? `**${commit.scope}:** ` : '';
const entry = `- ${scope}${commit.description} (${commit.hash.slice(0, 7)})`;
if (commit.breaking) {
sections['Breaking Changes'].push(entry);
} else if (commit.type === 'feat') {
sections['Features'].push(entry);
} else if (commit.type === 'fix') {
sections['Bug Fixes'].push(entry);
} else if (commit.type === 'perf') {
sections['Performance'].push(entry);
} else {
sections['Other'].push(entry);
}
}
const date = new Date().toISOString().split('T')[0];
let changelog = `## ${version} (${date})\n\n`;
for (const [title, entries] of Object.entries(sections)) {
if (entries.length === 0) continue;
changelog += `### ${title}\n\n`;
changelog += entries.join('\n') + '\n\n';
}
return changelog;
}
The Release Command
#!/usr/bin/env node
import { program } from 'commander';
import { readFile, writeFile } from 'node:fs/promises';
import { execSync } from 'node:child_process';
import chalk from 'chalk';
program
.command('release')
.description('Create a new release based on commits')
.option('--dry-run', 'Show what would happen without doing it')
.option('--force <type>', 'Force a specific bump: major, minor, patch')
.action(async (options) => {
// 1. Get commits since last release
const lastTag = getLatestTag();
const commits = getCommitsSinceTag(lastTag);
if (commits.length === 0) {
console.log(chalk.yellow(' No commits since last release'));
return;
}
// 2. Determine version bump
const bumpType = options.force || determineVersionBump(commits);
if (!bumpType) {
console.log(chalk.yellow(' No version-worthy changes found'));
console.log(chalk.gray(' Use conventional commits: feat:, fix:, or BREAKING CHANGE:'));
return;
}
// 3. Calculate new version
const pkg = JSON.parse(await readFile('package.json', 'utf-8'));
const [major, minor, patch] = pkg.version.split('.').map(Number);
const newVersion = bumpType === 'major'
? `${major + 1}.0.0`
: bumpType === 'minor'
? `${major}.${minor + 1}.0`
: `${major}.${minor}.${patch + 1}`;
// 4. Generate changelog
const changelog = generateChangelog(commits, newVersion);
console.log(chalk.bold(`\n Release: ${pkg.version} → ${chalk.green(newVersion)} (${bumpType})`));
console.log(chalk.gray(` ${commits.length} commits\n`));
console.log(changelog);
if (options.dryRun) {
console.log(chalk.yellow(' Dry run — no changes made'));
return;
}
// 5. Update package.json
pkg.version = newVersion;
await writeFile('package.json', JSON.stringify(pkg, null, 2) + '\n');
// 6. Update CHANGELOG.md
try {
const existing = await readFile('CHANGELOG.md', 'utf-8');
await writeFile('CHANGELOG.md', changelog + existing);
} catch {
await writeFile('CHANGELOG.md', `# Changelog\n\n${changelog}`);
}
// 7. Commit, tag, push
execSync('git add package.json CHANGELOG.md');
execSync(`git commit -m "chore: release v${newVersion}"`);
execSync(`git tag v${newVersion}`);
console.log(chalk.green(` ✓ Tagged v${newVersion}`));
console.log(chalk.gray(` Run: git push && git push --tags && npm publish`));
});
program.parse();
Usage
# See what the next release would look like
mytool release --dry-run
# Create a release (auto-detects bump type from commits)
mytool release
# Force a major release
mytool release --force major
# Full release pipeline
mytool release && git push && git push --tags && npm publish
Integrating with CI
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for commit analysis
- uses: actions/setup-node@v4
- run: npm ci
- run: npx mytool release
- run: git push && git push --tags
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Commit Message Enforcement
Add a git hook to validate commit messages:
# .husky/commit-msg
#!/bin/sh
PATTERN="^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?(!)?: .{1,72}$"
if ! grep -qE "$PATTERN" "$1"; then
echo "Invalid commit message format."
echo "Expected: type(scope): description"
echo "Examples: feat: add --json flag"
echo " fix(parser): handle empty input"
exit 1
fi
Conclusion
Automated versioning removes human error from releases. Conventional commits tell machines what changed. Semantic versioning tells users what to expect. The release script ties them together. Stop manually editing version numbers — let your commit history drive your releases.
Wilson Xu maintains 12+ npm CLI tools with automated releases. Follow at dev.to/chengyixu.
Top comments (0)