Semantic Versioning for Builds

Use semantic versioning for builds — CI/CD foundations.

Focus: use semantic versioning for builds

Sponsored

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-0932 the 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:

  1. Git tags — every release gets a tag like v1.2.3. The pipeline reads the most recent tag and increments it.
  2. Commit messages — use conventional commit prefixes (fix:, feat:, breaking:) to determine which part to bump.
  3. A version file — e.g., VERSION in 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: or patch: → bump PATCH
  • feat: or feature: → bump MINOR
  • breaking: 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: 0 to your checkout step. Also check that your commit message starts with fix:, feat:, or breaking: exactly.
  • I get fatal: No names found from git 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-beta and 1.2.0, some tools treat beta as 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.100 and 1.2.3+build.200 are 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: 0 in GitHub Actions, causing git describe to 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 breaks git describe on the first build.
  • Relying on version ordering that includes build metadata — +build.1 and +build.2 are considered equal, which can cause incorrect release decisions.

Variations

  1. Use a tool like semantic-release or python-semantic-release to automate version bumping and changelog generation.
  2. Instead of commit prefixes, use a VERSION file that the pipeline increments manually or via a PR check.
  3. 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 and feat: 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 from breaking: 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.