Semantic Versioning for Builds
Use semantic versioning for builds — CI/CD foundations.
Focus: use semantic versioning for builds
Your CI/CD pipeline just built the same artifact three times in an hour — once for a typo fix, once for a feature branch, and once for a commit you didn't even make. Every build has a number like build-42, but you have no idea which one is stable, which one is broken, or which one should go to production. Without a semantic version tied to each build, your pipeline is just a pile of anonymous artifacts. This lesson shows you how to use semantic versioning for builds so every artifact tells a clear story: what changed, how big the change is, and whether it’s safe to deploy.
The problem this lesson solves
When every build produces a random hash or an incrementing number, your team faces a daily nightmare:
- You can't tell which build is which. Is
build-20240513-0932the one with the login fix or the one that broke the API? - You can't compare versions. "Version 1.0.3 vs 1.0.4" means something to your team, but "build-77 vs build-78" means nothing.
- Rollbacks become guesswork. If the latest deploy breaks, you need to know exactly which previous version was stable — and that requires a versioning scheme that's both human-readable and machine-parsable.
- External consumers (APIs, libraries, SDKs) can't depend on your artifacts without a stable version identifier. No version, no trust.
This isn't a cosmetic issue. A missing or ambiguous version can cause wrong deployments, broken dependencies, and failed audit trails. In continuous delivery, the version is the contract between your pipeline and the rest of the world.
Core concept / mental model
Think of a semantic version as a tiny, structured envelope for every build. It carries three numbers that tell you the story of the change:
- MAJOR — you broke compatibility (e.g., removed an API endpoint, changed the database schema)
- MINOR — you added functionality in a backward-compatible way (e.g., new feature, new optional parameter)
- PATCH — you fixed a bug or made a backward-compatible change (e.g., typo fix, performance tweak)
The format is MAJOR.MINOR.PATCH and sometimes MAJOR.MINOR.PATCH-<pre-release>+<build-metadata> — for example 2.5.0-beta.1+build.204. The pre-release label (like -alpha, -rc.1) says "this isn't stable yet," and the build metadata (like +build.204) lets you track the exact CI build without affecting version ordering.
Think of your artifact as a product in a store. The version is the label on the box. Customers (other services, your production environment) read that label to decide whether to buy (deploy) it. If the label is vague, they'll hesitate or pick the wrong one.
In CI/CD, you want to generate the semantic version automatically at build time, based on the commit history or a tag, and then embed it into the artifact — so the version travels with the build from commit to deployment.
How it works step by step
To use semantic versioning for builds, you follow a repeatable process that turns your commit history into a version string and then attaches it to every artifact.
Step 1: Decide your version source
You need a single source of truth for the version. Common options:
- Git tags — every release gets a tag like
v1.2.3. The pipeline reads the most recent tag and increments it. - Commit messages — use conventional commit prefixes (
fix:,feat:,breaking:) to determine which part to bump. - A version file — e.g.,
VERSIONin your repository, updated by the pipeline.
For most teams, Git tags + commit analysis is the most reliable because it's version control–driven and auditable.
Step 2: Compute the next version
Your pipeline checks the latest tag and the commits since that tag. Based on the message prefixes:
fix:orpatch:→ bump PATCHfeat:orfeature:→ bump MINORbreaking:or!→ bump MAJOR
If there are no commits since the last tag, reuse the same version.
Step 3: Apply the version to the build
Inject the version into the build process — as an environment variable, a build argument, or a file written during the build. For example, in a Python package you'd write it to __version__; in a Docker image you'd use it as an image tag.
Step 4: Tag and release
After a successful build, create a new Git tag with the computed version. This makes the version permanent and traceable.
Step 5: Use the version downstream
Artifacts, image tags, and deployment manifests all reference the same version, so your CD stage knows exactly what it's deploying.
Hands-on walkthrough
Let's build a minimal CI pipeline in GitHub Actions that reads commit messages and generates a semantic version. Then we'll create a simple Python artifact that uses it.
Example 1: Version computation script
Save this as .github/scripts/next_version.py:
#!/usr/bin/env python3
import re
import subprocess
import sys
def get_last_tag():
"""Return the most recent tag, defaulting to v0.0.0."""
result = subprocess.run(["git", "describe", "--tags", "--abbrev=0"],
capture_output=True, text=True)
if result.returncode != 0:
return "v0.0.0"
return result.stdout.strip()
def get_commits_since(last_tag):
"""Return commit messages since a tag."""
result = subprocess.run(["git", "log", f"{last_tag}..HEAD", "--pretty=format:%s"],
capture_output=True, text=True)
return result.stdout.splitlines()
def next_version(last_tag, commits):
"""Bump MAJOR/MINOR/PATCH based on conventional commit prefixes."""
major, minor, patch = re.match(r"v?(\d+)\.(\d+)\.(\d+)", last_tag).groups()
major, minor, patch = int(major), int(minor), int(patch)
for msg in commits:
if msg.startswith("breaking") or "!:" in msg[:10]:
major += 1
minor = 0
patch = 0
elif msg.startswith("feat"):
minor += 1
patch = 0
elif msg.startswith("fix"):
patch += 1
return f"{major}.{minor}.{patch}"
if __name__ == "__main__":
last = get_last_tag()
commits = get_commits_since(last)
version = next_version(last, commits)
print(version)
If the last tag was v1.2.0 and you committed feat: add new endpoint, the script prints 1.3.0.
Example 2: GitHub Actions workflow
Create .github/workflows/build.yml:
name: Semantic Build
on:
push:
branches: [main]
jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.next.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for git describe
- name: Compute next version
id: next
run: |
VERSION=$(python .github/scripts/next_version.py)
echo "version=$VERSION" >> $GITHUB_OUTPUT
build:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build artifact
run: |
echo "Building version ${{ needs.version.outputs.version }}"
mkdir -p dist
echo "${{ needs.version.outputs.version }}" > dist/VERSION.txt
# Simulate a build artifact
cp dist/VERSION.txt dist/app-${{ needs.version.outputs.version }}.zip
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app-${{ needs.version.outputs.version }}
path: dist/
Run this workflow after a feat: commit, and the artifact will be named app-1.3.0.zip.
Example 3: Embedding the version in a Python app
If you're building a Python service, you can inject the version during the build:
# version.py
import os
VERSION = os.getenv("APP_VERSION", "0.0.0-dev")
Then in CI you export APP_VERSION before running tests or building the Docker image:
$ export APP_VERSION=1.3.0
$ python -c "from version import VERSION; print(VERSION)"
1.3.0
This guarantees the version is part of the artifact, not just a label on the outside.
Compare options / when to choose what
| Approach | Pros | Cons | Best for |
|---|---|---|---|
| Git tags + commit analysis | Auditable, automatic, works with any language | Requires disciplined commit messages | Most teams |
| Manual version file | Simple, full control | Prone to human error, easy to forget | Small projects, manual releases |
| Build number only | Trivial to implement | No meaning about compatibility | Internal experiments, throwaway builds |
Calendar/sequential version (e.g., 2024.05.13) |
Human-friendly for release dates | Doesn't express compatibility | Products with frequent public releases |
Rule of thumb: If you share artifacts externally or need to guarantee backward compatibility, use semantic versioning. If it's purely internal and you never roll back, a build number might suffice — but you'll lose the ability to communicate change size.
Troubleshooting & edge cases
- My version isn't bumping after a commit. The most common cause is shallow clone — GitHub Actions defaults to a single commit. Add
fetch-depth: 0to your checkout step. Also check that your commit message starts withfix:,feat:, orbreaking:exactly. - I get
fatal: No names foundfromgit describe. This means there are no tags in the repo. Create an initial tag:git tag v0.0.0, or handle the error in your script (as the example does). - Pre-release versions sort incorrectly. If you use
1.2.0-betaand1.2.0, some tools treatbetaas lower, some higher. Always include a dash for pre-release and use build metadata after a plus sign — e.g.,1.2.0-rc.1+build.204. Never mix both without testing your version comparison. - Two builds race and produce the same version. If you have parallel builds on the same branch, they can both read the same last tag and compute the same next version. Use a lock/release step or use a unique build metadata suffix (
+build.${{ github.run_number }}). - Version metadata is ignored in ordering.
1.2.3+build.100and1.2.3+build.200are considered equal versions — only the+part is metadata. If you need unique ordering, bump the PATCH or use a pre-release label.
Pro tip: Always add a release tag after a successful build. Otherwise, the next build will recompute the same version and overwrite the artifact.
What you learned & what's next
You now understand use semantic versioning for builds — why it matters, how to compute it from Git history, how to inject it into your CI pipeline, and how to avoid common pitfalls. You can explain the core idea behind MAJOR.MINOR.PATCH and you've completed a practical exercise that generates a version from commits and attaches it to a build artifact.
In the next lesson, you'll learn how to promote builds across environments — using the semantic version to control which artifact goes to staging vs. production. Your version will become the key that unlocks your deployment pipeline.
Practice recap
Try this: create a fresh Git repo, set a tag v1.0.0, then make three commits — fix: typo, feat: add API, breaking: remove v1 endpoint. Run the next_version.py script and confirm it prints 2.0.0. Then wire it into a GitHub Actions workflow that uploads the artifact named app-2.0.0.zip. This exact exercise prepares you for the next lesson on environment promotion.
Common mistakes
- Forgetting to set
fetch-depth: 0in GitHub Actions, causinggit describeto fail with 'shallow clone' errors. - Using a single incrementing build number instead of semantic versioning, making it impossible to tell if a version is backward-compatible.
- Writing commit messages that don't follow conventional commit prefixes (e.g., 'feat:', 'fix:', 'breaking:'), so the version never bumps correctly.
- Not creating an initial Git tag (
v0.0.0), which breaksgit describeon the first build. - Relying on version ordering that includes build metadata —
+build.1and+build.2are considered equal, which can cause incorrect release decisions.
Variations
- Use a tool like
semantic-releaseorpython-semantic-releaseto automate version bumping and changelog generation. - Instead of commit prefixes, use a
VERSIONfile that the pipeline increments manually or via a PR check. - Adopt Calendar Versioning (
2024.05.13) for publicly released products that don't need strict compatibility semantics.
Real-world use cases
- A Python library publishes a new version to PyPI after every merge to main, with
fix:commits bumping PATCH andfeat:commits bumping MINOR. - A microservices team tags Docker images with
app-{major}.{minor}.{patch}and uses that tag in Kubernetes deployment manifests for canary rollouts. - An API gateway exposes versioned endpoints (
/v1,/v2) where the MAJOR version dictates breaking changes, and CI automatically bumps it frombreaking:commits.
Key takeaways
- Semantic versioning (
MAJOR.MINOR.PATCH) gives every build a meaningful, comparable identifier that encodes compatibility. - Derive the version automatically from Git tags and conventional commit messages to keep it consistent and auditable.
- Inject the version into the artifact (via environment variables, files, or image tags) so it travels with the build.
- Always fetch full Git history (
fetch-depth: 0) and create an initial tag to avoid common pipeline failures. - Use pre-release labels and build metadata carefully — they affect sorting and uniqueness in ways that surprise many teams.
- Tag your repository after a successful build so the next build computes a new version, not the same one.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.