🚀 Executive Summary
TL;DR: This guide addresses the common problem of manual, error-prone semantic versioning by introducing an automated system. It leverages merge request labels (e.g., ‘breaking-change’, ‘feature’, ‘bug’) to automatically determine and apply the correct semantic version bump (major, minor, or patch) using a Python script, saving engineering time and eliminating versioning mistakes.
🎯 Key Takeaways
- Implement a consistent labeling strategy (e.g., ‘breaking-change’ for MAJOR, ‘feature’ for MINOR, ‘bug’/’fix’ for PATCH) to drive semantic versioning.
- Utilize a Python script with `requests` and `python-dotenv` to fetch the latest tag, analyze merged merge requests based on their labels, calculate the next version, and create a new Git tag.
- Ensure your Personal Access Token (PAT) has the necessary `api` and `write_repository` scopes for your Git provider (GitLab/GitHub) to read MRs and create tags.
- Automate the script execution using CI/CD pipelines (recommended) or cron jobs for scheduled version tagging.
- The script gracefully handles repositories with no existing tags by initializing with ‘v0.1.0’ and prioritizes the ‘highest’ version bump label found across all merged MRs since the last tag.
Automate Semantic Versioning Tagging based on Merge Labels
Hey team, Darian here. Let’s talk about a workflow that used to drive me crazy: manual versioning. Every week, I’d find myself scrolling through merge logs, trying to remember if that one PR was a bug fix (patch), a new feature (minor), or a breaking change (major). It was tedious, error-prone, and honestly, a terrible use of engineering time. After one too many mistaken patch releases, I decided to automate it. This system I’m about to show you saves my team hours every month and completely eliminates “whoops, wrong version” conversations.
The core idea is simple: we’ll use merge request labels to tell our script what kind of version bump to make. Let’s dive in.
Prerequisites
- You have Python 3 installed on the machine or runner that will execute the script.
- A GitLab or GitHub repository where you have Maintainer/Admin privileges.
- A Personal Access Token (PAT) for your Git provider with `api` and `write_repository` scopes. We need this to read merge requests and create new tags.
- Basic familiarity with managing environment variables.
The Guide: Step-by-Step
Step 1: Define Your Labeling Strategy
First, we need a consistent labeling system. In my projects, we stick to a simple convention that maps directly to Semantic Versioning (SemVer):
breaking-change: Triggers a MAJOR version bump (e.g., 1.5.2 -> 2.0.0)feature: Triggers a MINOR version bump (e.g., 1.5.2 -> 1.6.0)bugorfix: Triggers a PATCH version bump (e.g., 1.5.2 -> 1.5.3)
The script will look at all merge requests since the last tag and pick the “highest” label to determine the version bump. So, if you have one `feature` and three `bug` fixes, it will correctly create a minor release.
Step 2: Setting up Your Python Environment
I’ll skip the standard virtual environment setup since you likely have your own workflow for that. Let’s jump straight to the dependencies. You’ll need to install two main packages. You can do this with pip: one is `requests` to handle our API calls, and the other is `python-dotenv` which is great for managing secrets like our API token.
Next, create two files in your project directory: `version_bumper.py` for our script and a `config.env` file to store our secrets. Never commit `config.env` to your repository!
Your `config.env` file should look something like this:
GIT_PROVIDER_URL="https://gitlab.com"
PROJECT_ID="YOUR_PROJECT_ID"
PRIVATE_TOKEN="YOUR_PERSONAL_ACCESS_TOKEN"
Pro Tip: For GitLab, your `PROJECT_ID` is the number you see in your project’s settings page, not the project name. For GitHub, you’d adjust this to use the `owner/repo` format and modify the API endpoints accordingly.
Step 3: The Automation Script
Now for the fun part. Open up `version_bumper.py` and let’s build the logic. We’ll fetch the latest tag, find all merge requests merged since that tag was created, check their labels, and then create and push a new tag.
Here’s the complete script. I’ve added comments to explain what each part does.
import os
import requests
from datetime import datetime
from dotenv import load_dotenv
# --- Configuration ---
load_dotenv('config.env')
GIT_PROVIDER_URL = os.getenv("GIT_PROVIDER_URL")
PROJECT_ID = os.getenv("PROJECT_ID")
PRIVATE_TOKEN = os.getenv("PRIVATE_TOKEN")
API_URL = f"{GIT_PROVIDER_URL}/api/v4/projects/{PROJECT_ID}"
HEADERS = {"PRIVATE-TOKEN": PRIVATE_TOKEN}
# --- Helper Functions ---
def get_latest_tag():
"""Fetches the most recent tag from the repository."""
try:
response = requests.get(f"{API_URL}/repository/tags", headers=HEADERS)
response.raise_for_status()
tags = response.json()
if not tags:
print("No tags found. Starting with v0.1.0.")
return None, None
latest_tag = tags[0] # API returns them sorted by date
return latest_tag['name'], latest_tag['commit']['created_at']
except requests.exceptions.RequestException as e:
print(f"Error fetching tags: {e}")
return None, None
def get_merged_mrs_since(timestamp):
"""Fetches all merge requests merged since a given timestamp."""
if not timestamp: # Handle case of no previous tags
url = f"{API_URL}/merge_requests?state=merged&scope=all"
else:
url = f"{API_URL}/merge_requests?state=merged&scope=all&updated_after={timestamp}"
try:
response = requests.get(url, headers=HEADERS)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
print(f"Error fetching merge requests: {e}")
return []
def calculate_next_version(current_version, bump_type):
"""Calculates the next version number based on the bump type."""
if current_version is None:
return "0.1.0"
# Strip 'v' prefix if it exists
if current_version.startswith('v'):
current_version = current_version[1:]
major, minor, patch = map(int, current_version.split('.'))
if bump_type == "major":
major += 1
minor = 0
patch = 0
elif bump_type == "minor":
minor += 1
patch = 0
elif bump_type == "patch":
patch += 1
return f"{major}.{minor}.{patch}"
def create_new_tag(tag_name, ref):
"""Creates and pushes a new tag to the repository."""
try:
payload = {
"tag_name": tag_name,
"ref": ref
}
response = requests.post(f"{API_URL}/repository/tags", headers=HEADERS, json=payload)
response.raise_for_status()
print(f"Successfully created tag: {tag_name}")
except requests.exceptions.RequestException as e:
print(f"Error creating tag: {e}")
print(f"Response body: {e.response.text}")
# --- Main Logic ---
def main():
print("Starting versioning process...")
latest_tag_name, last_tag_date = get_latest_tag()
print(f"Latest tag found: {latest_tag_name or 'None'}")
merged_mrs = get_merged_mrs_since(last_tag_date)
if not merged_mrs:
print("No new merge requests found since the last tag. Nothing to do.")
return
print(f"Found {len(merged_mrs)} merged MRs to process.")
bump_level = 0 # 0: none, 1: patch, 2: minor, 3: major
for mr in merged_mrs:
labels = mr.get('labels', [])
if "breaking-change" in labels:
bump_level = max(bump_level, 3)
elif "feature" in labels:
bump_level = max(bump_level, 2)
elif "bug" in labels or "fix" in labels:
bump_level = max(bump_level, 1)
if bump_level == 0:
print("No version-related labels found on new MRs. Exiting.")
return
bump_map = {1: "patch", 2: "minor", 3: "major"}
bump_type = bump_map[bump_level]
print(f"Determined version bump type: {bump_type}")
next_version = calculate_next_version(latest_tag_name, bump_type)
new_tag_name = f"v{next_version}"
# Typically, we tag the main branch head.
main_branch_ref = "main" # Or 'master', depending on your repo
print(f"Preparing to create new tag '{new_tag_name}' on ref '{main_branch_ref}'.")
create_new_tag(new_tag_name, main_branch_ref)
if __name__ == "__main__":
main()
Step 4: Schedule the Automation
A script isn’t automated until something is running it for you. You have two excellent options:
- CI/CD Pipeline (Recommended): This is my preferred method. You can add a job to your `.gitlab-ci.yml` or GitHub Actions workflow that runs on a schedule (e.g., every Monday morning). This keeps the logic tied to your repository.
- Cron Job: A classic for a reason. If you have a dedicated build server, you can set up a simple cron job. Be mindful of the environment and paths.
A scheduled cron job would look like this (runs every Monday at 2 AM):
0 2 * * 1 python3 version_bumper.py
Pro Tip: When running from a cron job, make sure to use absolute paths to your script and `config.env` file or `cd` into the correct directory as part of the command, as cron’s default working directory can be unpredictable.
Common Pitfalls
Here’s where I’ve stumbled in the past, so you don’t have to:
- Incorrect PAT Scopes: I once spent an hour debugging a “401 Unauthorized” error. The problem? My Personal Access Token didn’t have the `write_repository` scope, so it could read tags but couldn’t create them. Double-check your token permissions!
- Forgetting the First Tag: The script needs to gracefully handle a repository with no tags yet. My script includes a check for this and defaults to `v0.1.0`, but it’s an easy state to forget to test.
- Rate Limiting: On very active repositories with hundreds of merge requests, you might hit the API rate limit. The script should be resilient, but for massive projects, consider adding logic to handle pagination for the API calls.
Conclusion
And that’s it. You’ve now offloaded the mental work of versioning to a simple, reliable script. By enforcing a label-based workflow, you create a clear, auditable history of why each version was created. This not only saves time but also brings a level of consistency and professionalism to your release process. It’s a small change that has a huge impact on team velocity and sanity.
Happy automating!
– Darian Vance
🤖 Frequently Asked Questions
âť“ How does this automation determine the version bump type?
The automation determines the version bump type by examining labels on all merge requests merged since the last tag. It prioritizes ‘breaking-change’ for a major bump, ‘feature’ for a minor bump, and ‘bug’ or ‘fix’ for a patch bump, selecting the highest applicable bump level.
âť“ How does this compare to alternatives?
Compared to manual versioning, this system eliminates human error and tedious work, providing consistency and an auditable history. While other CI/CD plugins exist, this custom Python script offers greater flexibility and control over the specific labeling strategy and integration with your existing workflow, without relying on third-party actions.
âť“ What is a common implementation pitfall?
A common pitfall is using a Personal Access Token (PAT) with insufficient permissions. The PAT must have `api` and `write_repository` scopes to successfully fetch merge requests and create new tags on the repository.
Leave a Reply