How to Use Semantic Versioning in CI Pipelines With Examples
Apply semantic versioning in CI: pick patch, minor or major from conventional commits, compute the next version in shell, tag safely and tag images.

A version number is a promise to the people who depend on your software. Semantic versioning gives it a precise meaning, and a pipeline can keep the promise by deriving the number from what changed instead of from a person's guess. This post shows the rules and a short shell implementation, which is what tools like semantic-release do with more features. It leads into release automation and uses git tags. Examples use Bash 5.2 and Git 2.47.
The rules
A version is MAJOR.MINOR.PATCH. Increase MAJOR for an incompatible change, MINOR for a backward-compatible feature and PATCH for a backward-compatible fix. A pre-release is marked after a hyphen (1.5.0-rc.1) and sorts before the release; build metadata after a plus sign (1.5.0+build.7) is ignored in precedence. Versions before 1.0.0 may change anything. The specification is at semver.org, and the pattern to validate a version with the official regular expression is long; the simple form ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ covers real use.
Commit messages that decide the bump
fix: handle empty config file -> patch
feat: add --dry-run flag -> minor
feat!: drop support for Bash 3 -> major
refactor: split parser module -> no release
docs: update install steps -> no release
feat: new output format
BREAKING CHANGE: the default format is now JSON. -> major
The Conventional Commits convention supplies the vocabulary. A ! after the type or a BREAKING CHANGE: footer signals a major bump. Enforce the format with a commit linter, or squash-merge pull requests with a validated title, so the history is reliable enough to compute from.
Compute the next version
#!/usr/bin/env bash
set -euo pipefail
last=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo v0.0.0)
range="$last..HEAD"
[ "$last" = v0.0.0 ] && range=HEAD
log=$(git log --format='%s%n%b' "$range")
[ -n "$log" ] || { echo "no commits since $last" >&2; exit 3; }
IFS=. read -r major minor patch <<< "${last#v}"
if grep -qE '^[a-z]+(\(.+\))?!:|^BREAKING CHANGE:' <<< "$log"; then
major=$((major + 1)); minor=0; patch=0
elif grep -qE '^feat(\(.+\))?:' <<< "$log"; then
minor=$((minor + 1)); patch=0
elif grep -qE '^fix(\(.+\))?:' <<< "$log"; then
patch=$((patch + 1))
else
echo "no releasable commits" >&2; exit 3
fi
echo "v$major.$minor.$patch"next=$(./scripts/next-version.sh) && echo "$next"v1.5.0
The script reads the subject and body lines since the last tag, tests for the strongest signal first and exits with status 3 when there is nothing to release, so a pipeline can stop cleanly. The pre-1.0 edge case, where a breaking change does not have to bump major, is not handled. The pre-release suffix of the last tag is not handled either; extend it if you publish release candidates.
Tag once, use everywhere
git tag -a "$next" -m "Release $next"
git push origin "$next"Create the tag in one job, protected so that two pipelines cannot tag the same commit twice; the push of a tag that already exists fails, which is the safe outcome. The tag then triggers the release workflow, and the image build uses the same value, with docker/metadata-action deriving 1.5.0 and 1.5 from v1.5.0; see building and pushing images.
Where this does not apply
SemVer assumes a public API to break. Applications and services without consumers often use calendar versions (2026.11.3) or build numbers instead, and that is fine. A bump computed from messages is only as good as the messages: a mislabeled commit publishes the wrong promise. Libraries should review the computed bump before publishing. Tools like semantic-release and release-please handle monorepos, pre-releases and changelogs, and are worth adopting before a long script grows.
What each part of the version promises
Semantic versioning gives meaning to three numbers. The patch number increases for backward-compatible bug fixes, the minor number for backward-compatible new functionality, and the major number for changes that break compatibility. The promise is made to the users of your public interface, so the first job is to say what that interface is: for a library, its exported functions, for a command-line tool, its flags and output formats, for a service, its API. A change is breaking if it can make a previously working user fail, which includes removing an option, changing a default and altering an output format that people parse. Versions below 1.0.0 are for initial development, where anything may change, and many projects stay there too long, since a project with real users has already made promises. Pre-release versions such as 1.4.0-rc.1 sort before the release and signal that the interface may still move. The build metadata after a plus sign is ignored for precedence.
Versioning is a communication tool, not a measure of effort. A one-line change that removes a flag is a major release, and a month of internal refactoring may be a patch.
Turning commit messages into a bump
The Conventional Commits format makes the bump computable. A message starting with fix: means a patch, feat: means a minor release, and a message with an exclamation mark after the type, or a footer beginning BREAKING CHANGE:, means a major release. Other types such as docs:, chore: and refactor: do not release by themselves. The pipeline reads the commits since the last tag, takes the highest level among them and applies it. The weakness is discipline: one commit with the wrong prefix gives the wrong version, so enforce the format with a commit message check in CI or, for squash merges, a check on the pull request title. Tools exist for the whole job, such as semantic-release and release-please, and a small script is enough for a project with simple needs. Whatever computes the version, compute it once, in one job, and pass the result to every later step as an output, so the tag, the artifacts and the container image all carry the same number.
Tags, immutability and edge cases
A tag is the permanent record of a release, and it should point at one commit forever. Create it from the pipeline after the checks pass, push it once, and never move it. A pipeline that reruns on the same commit must notice that the tag exists and stop, or it will fail when it tries to create it again and leave a half-published release. Decide how to handle the first release, when no tag exists, by starting from a base such as 0.0.0 or 1.0.0, and how to handle several branches, such as maintenance lines for older majors, where the version must be computed from the right branch's history. Release candidates and hotfixes need a rule too: a hotfix on an old release bumps the patch of that line, not the latest. Write these rules down next to the pipeline, because the person debugging a wrong version at the end of a long day will need them, and keep the computation covered by a few tests with sample histories.
Testing the version logic and living with mistakes
The code that computes the next version is easy to break and expensive to get wrong, since a published version cannot be taken back. Test it against sample histories: a repository with only fixes, one with a feature, one with a breaking change, one with no releasable commits, one with a pre-release tag and one with no tags at all. A script that creates a throwaway repository, makes commits and checks the computed version is a few dozen lines and pays for itself the first time someone changes the rules. When a wrong version ships anyway, do not rewrite history. Publish the correct next version and note the mistake in the changelog. Deprecate the bad release in the package registry if it supports that, so tooling steers users away, and fix the cause in the pipeline, which is nearly always a commit message that did not follow the format, or a check that did not catch it.
See the Semantic Versioning specification and Conventional Commits 1.0.0.


