🚀 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
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:
- Code Merged: A pull request is merged into your `main` or `develop` branch.
- CI Pipeline Triggered: Your CI server picks up the change.
- Test & Lint: All automated tests and code quality checks run.
- Determine Version: If tests pass, the pipeline runs our `version_bumper.py` script. It captures the output (the new version number).
- 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
🤖 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