Semantic Versioning with Tags

A Git tag is a permanent, named pointer to a single commit — most often the exact commit that represents a released version of your software. Unlike a branch, a tag never moves once you create it, which makes it the natural way to say "this is precisely what we shipped as v1.2.0." Semantic Versioning (SemVer) is a widely used convention for the version numbers themselves — MAJOR.MINOR.PATCH — that tells anyone depending on your code, at a glance, whether upgrading is safe. Put the two together — SemVer-formatted tags on the exact commits you release — and you get a readable, trustworthy release history that both humans and automation (like GitHub Actions) can rely on.

Overview: How Tags and Semantic Versioning Work

Git already has one kind of pointer you know well: a branch. A branch like main is a reference that automatically moves forward to the newest commit every time you commit. A tag is also a reference, but it is not supposed to move. Once v1.0.0 points at a commit, it should point at that commit forever. That immutability is the whole point — a tag is a promise that a specific snapshot of the repository corresponds to a specific, communicable version number.

Lightweight vs. annotated tags

Git actually supports two different kinds of tags, and the distinction matters:

  • Lightweight tags (git tag v1.0.0) are nothing more than a file under .git/refs/tags/ containing a commit SHA. There is no message, no author, no date — it behaves like a branch that never moves.
  • Annotated tags (git tag -a v1.0.0 -m "...") create a brand-new object in Git’s object database — a fourth object type alongside blobs, trees, and commits. This tag object stores the commit it points to, the tagger’s name and email, the date, a message, and optionally a GPG signature. Git computes a SHA-1 hash over that content, just like it does for commits, and the ref in refs/tags/ points at this tag object, which in turn points at the commit.

Recall Git’s object model: a commit object points to a tree (a snapshot of the directory structure), and a tree points to blobs (file contents) and other trees. An annotated tag simply adds one more layer on top: tag → commit → tree → blobs. Because an annotated tag is a real object with metadata and an optional signature, it is the right choice for anything you intend to call a "release." Lightweight tags are better reserved for private, throwaway bookmarks (marking a commit you want to find again later).

Semantic Versioning format

SemVer defines a version number as MAJOR.MINOR.PATCH, for example 2.4.1:

  • MAJOR increments when you make an incompatible, breaking change to your public API or behavior.
  • MINOR increments when you add functionality in a backward-compatible way.
  • PATCH increments when you make a backward-compatible bug fix.

SemVer also defines optional suffixes: a pre-release identifier such as 1.0.0-alpha.1 or 2.0.0-rc.2 marks a version as unstable and, by SemVer’s precedence rules, sorts before its associated release (1.0.0-alpha.1 comes before 1.0.0). Build metadata such as 1.0.0+build.42 can be appended for informational purposes (like a CI build number) and is ignored when comparing precedence. Git projects almost universally prefix the tag itself with a lowercase vv1.0.0 rather than bare 1.0.0 — purely as convention; SemVer itself doesn’t require the v, but nearly every tool and workflow (including GitHub Releases) expects it.

Syntax

git tag v1.4.2
git tag -a v1.4.2 -m "Release version 1.4.2"
git tag -a v1.4.2 a1b2c3d -m "Release version 1.4.2"
git push origin v1.4.2
git push origin --tags
git tag -d v1.4.2
git push origin --delete v1.4.2
git checkout v1.4.2
Flag Meaning
-a Create an annotated tag — a full Git object with tagger, date, and message.
-m "message" Attach a message to an annotated tag, same idea as -m for commits.
-s Create a signed annotated tag using your configured GPG key.
-d Delete a tag from your local repository (does not touch the remote).
-l "pattern" List tags matching a glob, e.g. git tag -l "v1.*".
-n When listing, show each annotated tag’s message alongside its name.
-f Force-move an existing tag to point at a new commit — dangerous once published.
--tags Used with git push or git fetch to include all tags, not just commits reachable from a branch.

Examples

Example 1: Tagging your first release

git log --oneline -1
git tag -a v1.0.0 -m "Release v1.0.0: initial public release"
git tag

Output:

a1b2c3d (HEAD -> main) feat: add CSV export button
v1.0.0

git log confirms which commit HEAD is sitting on. git tag -a then creates a new annotated tag object with the message "Release v1.0.0: initial public release" and points it at that commit. Running git tag with no arguments lists every tag in the repository, and v1.0.0 now shows up.

Example 2: Following SemVer as the project evolves

git commit -m "fix: correct off-by-one error in pagination"
git tag -a v1.0.1 -m "Release v1.0.1: pagination bug fix"

git commit -m "feat: add dark mode toggle"
git tag -a v1.1.0 -m "Release v1.1.0: dark mode support"

git commit -m "feat!: redesign public API for report generation"
git tag -a v2.0.0 -m "Release v2.0.0: breaking change to report API"

git log --oneline --decorate

Output:

e5f6a7b (HEAD -> main, tag: v2.0.0) feat!: redesign public API for report generation
c3d4e5f (tag: v1.1.0) feat: add dark mode toggle
b2c3d4e (tag: v1.0.1) fix: correct off-by-one error in pagination
a1b2c3d (tag: v1.0.0) feat: add CSV export button

Each release bumps a different part of the version number based on the change it contains. The pagination fix is backward-compatible, so only PATCH increases (v1.0.0 → v1.0.1). The dark mode toggle adds functionality without breaking anything, so MINOR increases and PATCH resets to zero (v1.1.0). The API redesign breaks existing callers — signaled here with the Conventional Commits ! marker — so MAJOR increases and both MINOR and PATCH reset (v2.0.0). git log --oneline --decorate shows tags next to the commits they point to, which is a quick way to see your release history at a glance.

Example 3: Pushing tags and publishing a GitHub Release

git push origin v2.0.0
git push origin --tags
gh release create v2.0.0 --title "v2.0.0" --notes "Breaking change: report generation API redesigned. See CHANGELOG.md for migration steps."

Output:

To github.com:octocat/reportly.git
 * [new tag]         v2.0.0 -> v2.0.0
Everything up-to-date
https://github.com/octocat/reportly/releases/tag/v2.0.0

This is a detail that surprises many beginners: git push does not send tags by default, because a tag isn’t attached to any branch’s upstream. You must push it explicitly by name, or use --tags to push every local tag that isn’t already on the remote (note the second command reports "Everything up-to-date" because the first already pushed v2.0.0). Once the tag exists on GitHub, gh release create (or the "Draft a new release" button in the GitHub web UI) turns it into a full Release with formatted notes, downloadable source archives, and optional binary attachments.

How Git Tags Work Step by Step

When you run git tag -a v1.0.0 -m "Release v1.0.0", Git does the following:

  1. It reads the current commit that HEAD resolves to (or the commit you named explicitly).
  2. It builds a new object of type tag containing: the target commit’s SHA, the type (commit), the tag name, your name/email/timestamp as the tagger, and your message.
  3. It hashes that content with SHA-1 (or SHA-256, on repositories configured for it) to get the tag object’s own unique ID, and writes the object into .git/objects/.
  4. It writes a ref file at .git/refs/tags/v1.0.0 containing the tag object’s SHA — not the commit’s SHA directly.

You can inspect this yourself with the plumbing command git cat-file:

git cat-file -p v1.0.0

Output:

object a1b2c3d4e5f678901234567890abcdef12345678
type commit
tag v1.0.0
tagger Jane Doe  1770000000 -0500

Release v1.0.0: initial public release

Compare that to a lightweight tag: running the same command against one just prints the commit itself, because the ref points directly at the commit object — there is no intermediate tag object at all. This is also why checking out a tag (git checkout v1.0.0 or git switch --detach v1.0.0) puts you in detached HEAD state: HEAD now points directly at a commit instead of at a branch, so any new commits you make there aren’t reachable from any branch and can be lost once you switch away unless you create a branch to hold them.

Automating Releases with GitHub Actions

Because a pushed tag is a real, observable event, you can trigger a workflow whenever one matching your release pattern arrives:

name: Release
on:
  push:
    tags:
      - 'v*.*.*'
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          generate_release_notes: true

This workflow only fires on tags shaped like v1.2.3, checks out the tagged commit, and creates a GitHub Release with auto-generated notes — no manual gh release create step required.

Common Mistakes

Using a lightweight tag for a real release. Running plain git tag v1.0.0 works, but you lose the tagger, date, message, and the ability to sign it. Six months later, git show v1.0.0 tells you nothing about why that version exists. Fix: always use -a (and -m) for anything you’d call a release.

Forgetting that tags don’t push automatically. A developer creates v3.1.0, everything looks fine locally, but a plain git push never sends it — the tag never reaches GitHub, so no Release gets created and no CI workflow fires. Fix: push the tag explicitly (git push origin v3.1.0) or push all outstanding tags with git push origin --tags.

Force-moving a tag that’s already been shared. Someone notices v1.5.0 was cut one commit too early and "fixes" it like this:

git tag -f v1.5.0 HEAD
git push origin v1.5.0 --force

If any teammate, CI runner, or downstream consumer already fetched the old v1.5.0, they now have a tag pointing at a completely different commit than everyone else, with no warning. Tags are supposed to be immutable identifiers — moving one silently breaks that guarantee. The safer fix is almost always to cut a new version instead:

git push origin --delete v1.5.0
git tag -d v1.5.0
git tag -a v1.5.0 -m "Release v1.5.0 (corrected)"
git push origin v1.5.0

Only do this if you’re certain no one has already built against the old tag, and communicate the change to your team — deleting and recreating a public tag is still a form of history rewriting.

Breaking SemVer’s contract. Shipping a breaking API change but only bumping PATCH (e.g. v1.2.3 → v1.2.4) misleads every consumer who trusts SemVer to mean "patch releases are always safe to take." Fix: let the size of the change dictate the version bump, not how it feels to write.

Best Practices

  • Always use annotated tags (-a) for anything you consider a release; reserve lightweight tags for personal, throwaway bookmarks.
  • Follow MAJOR.MINOR.PATCH strictly, and use the v prefix (v1.4.2) to match the convention GitHub, npm, and most tooling expect.
  • Write a real message on every release tag — it doubles as a miniature changelog entry when someone runs git show on it.
  • Sign release tags with -s when authenticity matters (open-source distributions, security-sensitive releases).
  • Use pre-release identifiers (v2.0.0-rc.1) for release candidates so testers can install them without them being mistaken for the final release.
  • Push tags deliberately and promptly — don’t let a tagged release sit unpushed while CI and teammates remain unaware it exists.
  • Turn tags into GitHub Releases with real release notes rather than leaving consumers to read raw tag names.
  • Treat a published tag as immutable; if you got it wrong, release the next patch version rather than force-moving it.

Practice Exercises

1. You just merged a pull request that fixes a null-pointer crash, and no new features have shipped since v2.3.0. Create the correctly versioned annotated tag for this release and push it to origin. (Hint: which part of MAJOR.MINOR.PATCH should change for a pure bug fix?)

2. Your team wants a small group of users to try an upcoming v3.0.0 before the real release goes out. Tag the current commit using SemVer’s pre-release syntax, and be able to explain why that tag sorts before v3.0.0 under SemVer’s precedence rules.

3. You tagged the wrong commit as v1.5.0 and already pushed it, and two teammates have already fetched it. Write out the safest sequence of commands to correct this without silently breaking your teammates’ existing clones.

Summary

  • A tag is a permanent pointer to one commit; unlike a branch, it is not meant to move.
  • Lightweight tags are just a ref; annotated tags (-a) create a real Git object with a tagger, date, message, and optional signature — prefer annotated tags for releases.
  • Semantic Versioning uses MAJOR.MINOR.PATCH: MAJOR for breaking changes, MINOR for backward-compatible features, PATCH for bug fixes.
  • Pre-release identifiers like -rc.1 and build metadata like +build.5 extend SemVer for release candidates and CI builds.
  • git push does not send tags by default — use git push origin <tag> or git push origin --tags.
  • Never force-move (-f) a tag that’s already been pushed and fetched by others; cut a new version instead.
  • GitHub Releases are built on top of tags and can attach release notes, source archives, and trigger CI/CD through GitHub Actions.