DEV Community

Wilson Xu
Wilson Xu

Posted on

Automate Semantic Versioning in Your CLI Tool

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)