Skip to main content

Command Palette

Search for a command to run...

Automate Semantic Versioning in Your CLI Tool

Published
•5 min read•View as Markdown

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.

More from this blog

W

Wilson Xu

108 posts