Every production incident starts with the same question: which version is deployed? Git tags give you a permanent, human-readable anchor for every release, and Semantic Versioning gives those anchors meaning. In this lesson you will master annotated tags, semver rules, and a fully automated release pipeline that bumps versions and generates changelogs from the conventional commits you already write.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Understand what Git tags are and how they differ from branches
- Create annotated and lightweight tags and push them to remote repositories
- Apply Semantic Versioning (MAJOR.MINOR.PATCH) rules to real releases
- Map conventional commits to automatic version bumps
- Generate changelogs automatically from commit history
- Build a GitHub Actions release pipeline that tags, versions, and publishes
- Publish GitHub Releases with notes and assets
2. Why This Matters
Your team deploys automatically on every merge to main. One Friday afternoon a customer reports a regression: the checkout flow crashes. Your first question is always the same: what changed? If every deploy just ships as "latest", you have no way to answer. You cannot roll back, you cannot bisect the blame, and you cannot tell the customer which version to avoid.
Git tags fix this by giving every deploy a permanent, human-readable anchor. Combine tags with Semantic Versioning and your release history becomes a contract: v1.2.0 added a feature, v1.2.1 fixed a bug, v2.0.0 broke the API. Customers and your own team can reason about compatibility instantly. And when version bumps and changelogs are generated automatically from conventional commits, releasing stops being a Friday-afternoon ritual and becomes a routine, low-risk operation.
3. Core Concepts
3.1 What Is a Git Tag?
A tag is an immutable pointer to a specific commit. Unlike a branch, which moves forward as you commit, a tag is frozen: it always points to the exact commit you tagged. Tags are the standard way to mark release points — v1.0.0, v2.3.1 — so you can always return to the exact code that shipped.
3.2 Lightweight vs Annotated Tags
Git has two kinds of tags. A lightweight tag is just a pointer with no metadata — fine for temporary markers. An annotated tag stores the tagger name, email, date, and a message, and can be signed with git tag -s. For releases you should always use annotated tags: they record who released, when, and why.
git tag v1.0.0 # lightweight tag (no metadata)
git tag -a v1.0.0 -m "Release v1.0.0: initial stable API" # annotated tag (recommended)
3.3 Semantic Versioning (SemVer)
Semantic Versioning encodes meaning in a three-part version number: MAJOR.MINOR.PATCH. Increment MAJOR for breaking changes, MINOR for new backward-compatible features, and PATCH for backward-compatible bug fixes. Optional pre-release suffixes (1.0.0-alpha.1, 1.0.0-rc.2) signal versions that are not yet stable, and build metadata (1.0.0+build.42) is ignored for precedence.
vMAJOR.MINOR.PATCH
MAJOR -> breaking changes (e.g. v2.0.0)
MINOR -> new feature, backward compatible (e.g. v1.1.0)
PATCH -> bug fix, backward compatible (e.g. v1.0.1)
Pre-release: v1.0.0-alpha.1, v1.0.0-rc.2
3.4 Conventional Commits Drive Versioning
This is where the previous lesson pays off. If your commit-msg hook enforces Conventional Commits, the type of each commit tells you exactly how the version must bump: fix produces a PATCH, feat produces a MINOR, and a breaking change (feat! or a BREAKING CHANGE footer) produces a MAJOR. Tools such as semantic-release and github-tag-action read this history and compute the next version for you — no manual decision, no human error.
git log --oneline v1.0.0..HEAD
# 3f2a1b4 feat: add user profile endpoint
# 8c9d0e1 fix: correct timeout in retry logic
# next version: v1.1.0 (MINOR bump because of feat)
3.5 Tags vs Branches
Branches move; tags do not. A branch tracks ongoing development and is deleted after a feature merges. A tag pins a moment in history that should never move. If you need to fix a released version, you branch from the tag, fix, and tag the fix — you never retag the old release.
4. Hands-On Practice
Step 1: Create Your First Release Tag
Create an annotated tag for your current main branch. The -a flag creates an annotated tag and -m attaches the release message. Verify the tag with git show.
git tag -a v1.0.0 -m "Release v1.0.0: initial stable API"
git show v1.0.0 --stat
Step 2: Push Tags to the Remote
Tags are not included in a regular git push — you must push them explicitly. Push a single tag with git push origin <tag> or every tag with --tags.
git push origin v1.0.0
# or push every tag:
git push origin --tags
Step 3: List, Filter, and Inspect Tags
Use git tag -l with a pattern to filter, and git describe to see where HEAD sits relative to the nearest tag. The output v1.0.0-2-g3f2a1b4 means: 2 commits after v1.0.0, current commit is 3f2a1b4.
$ git tag -l "v1.*"
v1.0.0
v1.0.1
$ git describe --tags
v1.0.0-2-g3f2a1b4
Step 4: Bump the Version with Conventional Commits
Make a feature commit and a fix commit, then decide the next version using the mapping from 3.4. Practice until it is automatic: feat → MINOR, fix → PATCH, breaking change → MAJOR.
git commit -m "feat: add user profile endpoint"
git commit -m "fix: correct timeout in retry logic"
# Both commits sit on top of v1.0.0 -> next release is v1.1.0
git tag -a v1.1.0 -m "Release v1.1.0: user profiles"
Step 5: Automate Version Bumps with GitHub Actions
Now automate what you just did by hand. This workflow runs on every push to main, reads your conventional commits, computes the next semantic version, creates the tag, and opens a GitHub Release with auto-generated notes. The github-tag-action step does the semver calculation; action-gh-release publishes the release.
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Bump version and push tag
id: tag
uses: mathieudutour/github-tag-action@v6
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
default_bump: patch
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.new_tag }}
generate_release_notes: true
Step 6: Publish a Release with Notes
If you prefer to publish releases manually, the GitHub CLI does it in one command. Pass a title and notes, or point the workflow at a changelog file your pipeline generated.
gh release create v1.1.0 \
--title "v1.1.0" \
--notes "See the changelog below for details"
Step 7: Zero-Touch Releasing with semantic-release
For teams that want fully hands-off releases, semantic-release analyzes commits, computes the next version, writes the changelog, creates the tag, and publishes — all in CI. Run the dry-run locally to preview exactly what it would release.
npx semantic-release --dry-run
5. Common Errors & Solutions
Error: "tag 'v1.0.0' already exists"
You tried to create a tag that already exists, or you want to move a tag to a different commit. Git refuses by default because retagging silently rewrites release history. If you truly must move a tag (before anyone has fetched it), delete it, recreate it, and force-push.
git tag -d v1.0.0
git tag -a v1.0.0 -m "Release v1.0.0 (recreated)"
git push origin :v1.0.0
git push origin v1.0.0 --force
Error: "failed to push some refs" when pushing a tag
You tagged a local-only commit and tried to push it. If the tag points to a commit the remote does not have, Git rejects the push. Push the branch first, or create the tag on a commit that is already on the remote.
git push origin main
git push origin v1.0.0
Error: "You are not currently on a branch" after checking out a tag
Checking out a tag puts you in detached HEAD state: any commit you make is not attached to a branch and can be garbage-collected. If you need to patch a release, create a branch from the tag instead.
git checkout v1.0.0 # detached HEAD - read only!
git switch -c hotfix/v1.0.1 # create a branch from the tag
Error: Version numbers drift between tags and release names
Some tools use v1.0.0, others 1.0.0. Mixing conventions breaks automation that parses versions. Pick one convention (the v prefix is the GitHub default) and let a single tool own versioning instead of bumping tags by hand in three different places.
Error: "No names found, cannot describe anything" from git describe
git describe needs at least one reachable tag. Fresh repositories and shallow clones (fetch-depth: 1) have no tags, so describe fails. Fetch the full history and tags first — which is exactly why the release workflow above sets fetch-depth: 0.
git fetch --tags --unshallow
git describe --tags
6. Summary Checklist
- Created an annotated tag with a release message
- Pushed tags to the remote explicitly with
git push origin <tag> - Listed and inspected tags with
git tag -landgit show - Applied SemVer rules:
fix→ PATCH,feat→ MINOR, breaking → MAJOR - Mapped conventional commits to the next version number
- Automated version bumps with a GitHub Actions release workflow
- Published a GitHub Release with generated release notes
7. Practice Exercise
Build a fully tagged, auto-releasing repository from scratch:
- Create a new repository, add a README, and commit it with a conventional message such as
docs: add project README. - Create an annotated tag
v0.1.0and push it to GitHub. - Add a feature commit (
feat: ...) and a fix commit (fix: ...). - Decide the next version by hand using the SemVer rules, then create and push the tag.
- Add the GitHub Actions release workflow from Step 5 and push it, then merge a
featcommit. - Open the Actions tab and confirm a new tag plus a GitHub Release appeared automatically.
- Bonus: install the commit-msg hook from the previous lesson and verify that a non-conventional commit is rejected before it can confuse your versioning.
8. Next Steps
You can now pin every release and let automation choose the next version. The natural next step in this series is Open Source Contribution Workflows: forking, pull request templates, CODEOWNERS, and collaborating at scale. After that, we will look at GitHub Projects and Issues Automation, where you will wire issue tracking into the same automation you just built.
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment