🚀 Executive Summary

TL;DR: Manual version bumping is slow, subjective, and error-prone, wasting engineering time. The solution involves enforcing Conventional Commits to automatically determine Semantic Version (SemVer) bumps via a Python script integrated into a CI/CD pipeline, ensuring consistent and efficient releases.

🎯 Key Takeaways

  • Semantic Versioning (SemVer) bumps are automated by mapping Conventional Commit types: `fix:` for PATCH, `feat:` for MINOR, and `BREAKING CHANGE:` for MAJOR.
  • A Python script utilizing the `GitPython` library is central to the solution, enabling interaction with Git repositories to read commit messages and identify the latest version tag.
  • The script determines the highest required bump level by analyzing commit messages since the last tag, then calculates and suggests the next version number.
  • Integration into CI/CD pipelines (e.g., GitHub Actions, GitLab CI) is crucial for automation, where the script runs after tests to create and push Git tags, and publish artifacts with the new version.
  • The system can be extended to automatically generate release notes by extracting subject lines from `feat` and `fix` commits, further streamlining the release process.

Automate Version Bumping (SemVer) based on Commit Conventional Commits

Automate Version Bumping (SemVer) based on Commit Conventional Commits

Hey there, Darian here. Let’s talk about a process that used to be a real thorn in my side: manual version bumping. Before we automated this at TechResolve, our pre-release checklist involved one of us scanning the Git log since the last production tag. We’d debate whether a set of changes constituted a minor or a patch release. It was slow, subjective, and honestly, a waste of valuable engineering time. I estimate I was losing at least two hours a week to this churn.

The solution was to make our commits do the work for us. By enforcing a simple commit message standard, we can write a script that determines the next version number automatically. It’s consistent, it’s fast, and it removes human error from a critical part of the release cycle. Let me show you how I set it up.

Prerequisites

Before we dive in, I’m assuming you have a few things ready:

  • A project managed with Git.
  • Python 3 installed on your machine and in your CI/CD environment.
  • A basic understanding of Semantic Versioning (SemVer: MAJOR.MINOR.PATCH).
  • Your team has agreed to use the Conventional Commits specification.

The Step-by-Step Guide

Step 1: Understanding the Logic

The whole system hinges on the Conventional Commits standard. It’s a simple rule for your commit messages. The key prefixes we care about for versioning are:

  • fix: A commit that patches a bug. This corresponds to a PATCH version bump (e.g., v1.2.0 → v1.2.1).
  • feat: A commit that introduces a new feature. This corresponds to a MINOR version bump (e.g., v1.2.1 → v1.3.0).
  • BREAKING CHANGE: A commit that has a footer starting with this text (or a ! after the type/scope) introduces a breaking API change. This corresponds to a MAJOR version bump (e.g., v1.3.0 → v2.0.0).

Our script will read all commit messages since the last version tag, find the “highest” type of change, and calculate the next version number accordingly.

Step 2: Setting Up the Python Environment

To interact with our Git repository, we’ll need a library. My go-to is `GitPython`. I’ll skip the standard virtual environment setup since you likely have your own workflow for that. Let’s jump straight to the logic.

You’ll need to install `GitPython` in your project’s environment. The standard package installer for Python will handle this for you (e.g., `pip install GitPython`).

Step 3: The Versioning Script

Here’s the Python script that does all the heavy lifting. I’ve added comments to explain each part. Save this as something like `version_bumper.py` in your project’s root directory.


import re
import git

def get_latest_tag(repo):
    """Find the latest SemVer tag in the repository."""
    try:
        # This sorts tags based on the commit date they point to
        latest_tag = sorted(repo.tags, key=lambda t: t.commit.committed_datetime)[-1]
        return latest_tag
    except IndexError:
        # Handle case with no tags
        return None

def determine_bump_level(commits):
    """Parse commit messages to determine the version bump level."""
    bump_level = None
    levels = {'MAJOR': 3, 'MINOR': 2, 'PATCH': 1}
    current_level = 0

    for commit in commits:
        message = commit.message.lower()
        if 'breaking change' in message or 'break:' in message:
            if levels['MAJOR'] > current_level:
                bump_level = 'MAJOR'
                current_level = levels['MAJOR']
        elif message.startswith('feat'):
            if levels['MINOR'] > current_level:
                bump_level = 'MINOR'
                current_level = levels['MINOR']
        elif message.startswith('fix'):
            if levels['PATCH'] > current_level:
                bump_level = 'PATCH'
                current_level = levels['PATCH']

    return bump_level

def calculate_next_version(current_version, level):
    """Calculate the next version based on the current version and bump level."""
    if not current_version:
        return "v0.1.0" # Default initial version

    v_prefix = current_version.startswith('v')
    version_str = current_version[1:] if v_prefix else current_version
    
    try:
        major, minor, patch = map(int, version_str.split('.'))
    except ValueError:
        print(f"Error: Could not parse version '{current_version}'.")
        return None # Using return instead of sys.exit

    if level == 'MAJOR':
        major += 1
        minor = 0
        patch = 0
    elif level == 'MINOR':
        minor += 1
        patch = 0
    elif level == 'PATCH':
        patch += 1
    
    next_version = f"{major}.{minor}.{patch}"
    return f"v{next_version}" if v_prefix else next_version

def main():
    """Main function to run the version bumping logic."""
    try:
        repo = git.Repo('.')
    except git.InvalidGitRepositoryError:
        print("Error: This script must be run from within a Git repository.")
        return

    latest_tag = get_latest_tag(repo)
    
    if latest_tag:
        print(f"Latest tag found: {latest_tag.name}")
        commits_since_tag = list(repo.iter_commits(f'{latest_tag.name}..HEAD'))
    else:
        print("No tags found, analyzing all commits.")
        commits_since_tag = list(repo.iter_commits())

    if not commits_since_tag:
        print("No new commits since the last tag. No version bump needed.")
        return

    bump_level = determine_bump_level(commits_since_tag)

    if bump_level:
        current_version_str = latest_tag.name if latest_tag else "v0.0.0"
        next_version = calculate_next_version(current_version_str, bump_level)
        if next_version:
            print(f"Change detected: {bump_level}")
            print(f"Next version should be: {next_version}")
    else:
        print("No 'feat', 'fix', or 'BREAKING CHANGE' commits found. No version bump needed.")

if __name__ == "__main__":
    main()

Pro Tip: In my production setups, I extend this script to also generate release notes. You can iterate through the same commit messages and pull out the subject lines for `feat` and `fix` commits to build a clean changelog automatically. This saves even more time.

Step 4: Integrating with Your CI/CD Pipeline

This script becomes truly powerful when you automate it. You don’t want to run this manually. Instead, add a step to your CI/CD pipeline (GitHub Actions, GitLab CI, Jenkins, etc.) that runs after your tests pass.

A typical workflow looks like this:

  1. Code Merged: A pull request is merged into your `main` or `develop` branch.
  2. CI Pipeline Triggered: Your CI server picks up the change.
  3. Test & Lint: All automated tests and code quality checks run.
  4. Determine Version: If tests pass, the pipeline runs our `version_bumper.py` script. It captures the output (the new version number).
  5. Tag & Release: The pipeline then uses this new version number to:
    • Create a new Git tag (e.g., `git tag v1.4.0`).
    • Push the tag back to the remote (`git push –tags`).
    • Build and publish your artifact (e.g., a Docker image `my-app:1.4.0`).

Common Pitfalls (Where I Usually Mess Up)

  • Forgetting to Push Tags: Early on, I had my CI creating tags that only existed on the build runner. The next build would fail because it couldn’t find the “latest tag” on the remote. Always make sure your CI pipeline pushes the newly created tag back to your origin.
  • Inconsistent Commit Messages: This system is only as good as your team’s discipline. If developers forget to use the conventional commit prefixes, the automation will miss changes, and you’ll have to manually intervene. A good PR template and code review process can help enforce this.
  • The First Tag: The script handles the “no tags found” scenario by defaulting to `v0.1.0`. Make sure this initial version makes sense for your project. You might want to create an initial `v0.0.0` or `v1.0.0` tag manually to bootstrap the process.

Conclusion

And there you have it. By establishing a simple contract with your commit messages, you can offload the entire versioning process to a reliable, automated script. This frees up your team to focus on building features, not arguing about version numbers. It brings consistency to your releases and creates a clear, auditable history of changes right in your Git log.

Give it a try. It’s a small investment that pays huge dividends in process efficiency.

– Darian Vance

Darian Vance - Lead Cloud Architect

Darian Vance

Lead Cloud Architect & DevOps Strategist

With over 12 years in system architecture and automation, Darian specializes in simplifying complex cloud infrastructures. An advocate for open-source solutions, he founded TechResolve to provide engineers with actionable, battle-tested troubleshooting guides and robust software alternatives.


🤖 Frequently Asked Questions

âť“ How does automated version bumping using Conventional Commits work?

The system parses commit messages adhering to the Conventional Commits specification (e.g., `fix:`, `feat:`, `BREAKING CHANGE:`). A Python script then determines the highest required Semantic Version bump (PATCH, MINOR, or MAJOR) based on these commit types since the last tag, and calculates the next version number.

âť“ How does this automated approach compare to manual version bumping?

Automated version bumping eliminates the subjectivity, human error, and significant time consumption associated with manual Git log scanning and team debates over version numbers. It provides consistent, fast, and auditable release cycles, freeing engineers to focus on development.

âť“ What are common pitfalls when implementing automated version bumping and how can they be avoided?

Common pitfalls include forgetting to push newly created Git tags to the remote (solved by configuring CI/CD to `git push –tags`), inconsistent Conventional Commit message usage by the team (mitigated by PR templates and code reviews), and handling the initial project tag (can be bootstrapped with a manual `v0.0.0` or `v1.0.0` tag).

Leave a Reply

Discover more from TechResolve - SaaS Troubleshooting & Software Alternatives

Subscribe now to keep reading and get access to the full archive.

Continue reading