Implementing semantic versioning cleanly
Tags mark release points in the Git history, but only a consistent strategy built on annotated tags, a clear semantic versioning convention, and automated derivation makes them genuinely reliable for CI pipelines and deployment processes.
Table of Contents
- 1. Why inconsistent tags become a problem in the release process
- 2. Annotated tags versus lightweight tags
- 3. Mapping semantic versioning consistently onto the tag structure
- 4. Cryptographically signing release tags
- 5. Automatically deriving version numbers from the tag history
- 6. Tag based release automation in CI pipelines
- 7. Tags versus dedicated release branches
- 8. Best practices for a consistent tag strategy
- 9. Common pitfalls with tag strategies
- 10. Summary
- 11. FAQ
1. Why inconsistent tags become a problem in the release process
Without a clear convention, repositories that have grown over time quickly end up with tags in every imaginable format: v1.2.3 next to 1.2.3, release-2024-05 next to rc1, some annotated, others lightweight and stripped of any context. As soon as a CI system or a deployment script needs to automatically determine the latest version or generate a changelog, this inconsistency breaks everything, since versions can no longer be reliably compared or sorted.
A consistent tag strategy solves two problems at once: it makes releases readable and traceable for humans while providing a machine parseable foundation for automation. Semantic versioning offers an established, clearly defined structure for exactly this, one that maps directly onto Git tags.
2. Annotated tags versus lightweight tags
A lightweight tag is technically nothing more than a named pointer to a commit, comparable to a branch that just does not move automatically. An annotated tag, by contrast, is a standalone Git object with a tagger, a timestamp, a message, and an optional cryptographic signature, and it in turn points at the referenced commit.
For releases, annotated tags are practically always the right choice, because only they carry metadata such as a release message and a traceable timestamp independent of the referenced commit. Lightweight tags are at most suitable for short lived, purely local markers, for example to temporarily remember a commit, but should never be used for official releases.
# Lightweight tag: just a pointer, no metadata
git tag v1.2.3-temp
# Annotated tag: standalone object with message and timestamp
git tag -a v1.2.3 -m "Release 1.2.3: security fix for session handling"
# Inspect the difference directly
git cat-file -p v1.2.3
3. Mapping semantic versioning consistently onto the tag structure
Semantic versioning defines a version number as MAJOR.MINOR.PATCH, where MAJOR marks incompatible API changes, MINOR backward compatible new functionality, and PATCH backward compatible bug fixes. This structure can be used directly as the tag name with no modification, usually with a leading v prefix, for example v2.4.0, to clearly distinguish version tags from other tag types.
Pre release versions and build metadata have fixed syntax in semantic versioning: a hyphen suffix such as -rc.1 or -beta.2 marks a pre release, a plus suffix such as +build.42 marks pure build metadata with no effect on version comparison logic. Both suffixes map directly onto the tag name, for example v2.4.0-rc.1, as long as the tooling in use interprets this syntax correctly.
# Regular release tag following semantic versioning
git tag -a v2.4.0 -m "Release 2.4.0"
# Release candidate before the final publication
git tag -a v2.4.0-rc.1 -m "Release candidate 1 for 2.4.0"
# Patch release for a critical bug fix
git tag -a v2.4.1 -m "Release 2.4.1: hotfix for data export"
4. Cryptographically signing release tags
For publicly distributed software or security critical internal projects, a plain text message on a tag is often not enough to establish its origin beyond doubt. With git tag -s, a tag can be signed with the configured GPG key, which later allows anyone to verify whether the tag actually came from the authorized person and has not been altered since it was created.
Verification happens with git tag -v, or automatically during checkout of a signed tag, provided gpg.program is configured correctly and the signer's public key is known locally. In CI pipelines, this check can be built in as a gate before every deployment to ensure only verified releases ever reach production.
# Sign a release tag with the configured GPG key
git tag -s v2.4.0 -m "Release 2.4.0"
# Verify a tag's signature
git tag -v v2.4.0
# Show signature status in git log
git log --show-signature -1 v2.4.0
5. Automatically deriving version numbers from the tag history
Instead of maintaining version numbers manually in configuration files, the current version can be derived directly from the Git history. The command git describe --tags finds the nearest reachable tag starting from the current commit and appends the number of commits since that tag along with the abbreviated commit hash, which works excellently as the basis for automatically generated development versions.
For strict semantic versioning compliance, the output of git describe usually needs a small adjustment, since the default format with an extra hyphen and commit count does not directly match semver syntax. Many release automation tools such as semantic-release or GitVersion handle this reformatting automatically and derive the next version number from it consistently.
# Find the nearest reachable tag from the current commit
git describe --tags
# Output format: v2.4.0-14-g3a91f2c
# 14 commits since v2.4.0, current commit starts with 3a91f2c
# Only print the most recently reached tag, without extra information
git describe --tags --abbrev=0
6. Tag based release automation in CI pipelines
A common pattern is triggering a release exclusively by pushing a matching tag, instead of creating releases manually in a separate system. The CI pipeline reacts specifically to tags matching the configured pattern, builds artifacts from them, generates a changelog from the commit messages since the previous tag, and publishes the result automatically.
A clear separation matters here between tags meant to trigger a release and tags used for other purposes, such as internal markers. A consistent prefix such as v, combined with a pipeline filter targeting exactly that pattern, prevents an accidentally created tag from unintentionally triggering a production deploy.
# Example: CI pipeline only reacts to tags matching a semver pattern
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
7. Tags versus dedicated release branches
Tags mark a single, immutable point in the history and are excellent at labeling exactly that one commit as a release. Once targeted hotfixes are needed for exactly that version after release, though, without accidentally shipping already completed functionality from the main branch, a plain tag is no longer enough, since no further commits can be built on top of a tag itself.
In such cases, a dedicated release branch such as release/2.4 sensibly complements the tag strategy: the branch exists alongside main, receives only cherry picks for critical fixes, and every fix in turn gets its own patch tag such as v2.4.1. This combination of a long lived release branch and short lived, immutable tags per publication covers both traceability of individual releases and the ability to do targeted follow up work.
8. Best practices for a consistent tag strategy
Release tags should always be annotated, and additionally signed in security relevant projects, never lightweight tags. A consistent prefix, usually v, together with strict adherence to the MAJOR.MINOR.PATCH structure, is the basic prerequisite for automated tools being able to compare and sort versions reliably.
The decision on when to bump MAJOR, MINOR, or PATCH should be documented for the team and ideally supported by automated analysis of commit messages following the Conventional Commits convention, instead of being manually re debated at every single release.
9. Common pitfalls with tag strategies
A frequent mistake is moving an already published tag afterward with git tag -f, since that suddenly makes the same version name point at different commits depending on when a given user last fetched the tag. A published release tag should therefore be treated as immutable, similar to a commit already pushed to a shared branch.
Another pitfall is mixing release tags with other tag types in the same namespace, for example environment markers such as staging-2024-05 alongside version tags such as v2.4.0. That makes automated filtering considerably harder and sooner or later leads to pipeline configurations that accidentally react to the wrong tags.
| Tag type | Carries metadata | Signable | Suitable for releases |
|---|---|---|---|
| Lightweight tag | No, just a commit reference | No | Not suitable |
| Annotated tag | Yes, message and timestamp | Yes | Standard choice for releases |
| Signed annotated tag | Yes, additionally cryptographically verifiable | Yes, mandatory | Security critical releases |
| Pre release with rc suffix | Yes, if created annotated | Yes | Release candidates before publication |
Mironsoft
Git workflows, branching strategies, and CI hooks
Chaotic Git history and unclear branching rules across the team?
We set up clean Git workflows, clarify branching strategies for the team, and automate quality checks via Git hooks and CI pipelines so the history stays traceable.
Workflow Audit
Review the existing branching strategy and merge practice for weak spots.
Hook Automation
Set up pre-commit and pre-push hooks for linting, tests, and commit conventions.
Team Training
Teach rebase, cherry-pick, and conflict resolution hands-on across the team.
10. Summary
Tag Strategies
Recommended tag type
Annotated, additionally signed where needed
Format
vMAJOR.MINOR.PATCH following semantic versioning
Core command
git tag -a v2.4.0 -m "Release 2.4.0"
Automation
git describe --tags for version resolution