Topic · Branching & Integration

Tags & Releases

A tag is a name that pins a commit in place. Branches move; tags do not. Once you say "v1.0.0 is this commit," that name should mean the same thing six months later, on every clone, forever. That stability is what turns a commit into a release.

What a tag is

A tag is a named pointer to a commit. Mechanically, it looks a lot like a branch: a small file under .git/refs/tags/ that contains a commit SHA. The difference is one of intent enforced by convention and tooling. Branches advance as you commit; a tag is meant to stay put. Once v1.0.0 points at commit 9fceb02, that's the deal forever, on your clone and on everyone else's.

That immutability is the entire reason tags exist. When someone files a bug "in v1.2.3," you need to be able to check out exactly what that meant. A tag is the contract that lets you do so. It's also why release infrastructure (CI builds, package registries, security advisories) keys off tags rather than branch tips.

Tag

A reference that names a specific commit and is not expected to move. Git supports two kinds: lightweight tags, which are bare references to a commit, and annotated tags, which are full Git objects with their own SHA, tagger metadata, and message. Annotated tags can additionally be cryptographically signed.

Lightweight, annotated, signed

There are three flavors of tag, and the distinction matters more than it looks at first.

Lightweight tags

The simplest form: a file under refs/tags/ with a commit SHA in it. No metadata, no author, no date, no message. Cheap and fast. Pro Git describes lightweight tags as "very much like a branch that doesn't change — it's just a pointer to a specific commit."

lightweight tagshell
git tag v1.4-lw

Useful for personal bookmarks ("this is where I left off Tuesday"), throwaway markers in scripts, and one-off comparisons. Not appropriate for releases you share with anyone: there's no author or date recorded, and you can't sign them.

Annotated tags

The full version. An annotated tag is its own object in the Git database, with its own SHA, and it stores the tagger name, email, date, and a message. The tag object then points at the commit. This is the form Pro Git and Git's own documentation recommend for anything shared.

annotated tagshell
git tag -a v1.4 -m "Release 1.4 - faster startup, new --json output"

Because annotated tags are real objects, git show v1.4 shows the tagger and message before showing the commit. They also fingerprint cleanly across forks, which matters for downstream consumers verifying authenticity.

Signed tags

An annotated tag with a cryptographic signature attached. With your signing key configured, git tag -s signs the tag object using GPG (or SSH, in modern Git); anyone with your public key can verify it with git tag -v.

signed tagshell
git tag -s v1.4 -m "Release 1.4"
git tag -v v1.4

For security-sensitive releases — anything users will execute on their machines, anything that flows into a supply chain — this is the bar. See Signing, Trust, and Provenance for how the verification side actually works.

Kind What it stores Has its own SHA? When to use
Lightweight Just a commit pointer. No — refers directly to the commit SHA. Personal markers, scripts, scratch bookmarks. Never for shared releases.
Annotated Tagger, date, message; points at a commit. Yes — the tag is itself an object in the object database. The default for any tag you push or share. All public release tags.
Signed Everything in an annotated tag, plus a cryptographic signature. Yes. Security-sensitive releases, package publishing, anything where downstream consumers need to verify the author.
When to sign

If your tag triggers a build that ends up on someone else's machine — a published package, a container image, a shipped binary — sign it. The verification cost is near zero; the cost of a forged release tag is enormous. CI pipelines can be configured to refuse unsigned tags, which gives you a hard gate against this whole class of mistake.

Listing and inspecting tags

Three commands cover almost everything you need day to day.

list and inspectshell
# list all tags, sorted alphabetically
git tag

# list tags matching a glob (requires -l when you pass a pattern)
git tag -l "v1.*"

# show what a tag points at, plus tag message for annotated tags
git show v1.4

# show the commit a tag resolves to, no message
git rev-parse v1.4

By default git tag sorts alphabetically, which orders v1.10.0 before v1.2.0. For a sane sort by version number, pass --sort=v:refname. For the most recently created tag, git describe --tags --abbrev=0 is the usual trick.

Pushing and deleting tags

The single most surprising thing about tags: git push does not push them. A plain push moves branch refs only. If you create a tag and push without naming it, the tag stays on your machine and nowhere else.

publishing tagsshell
# push one specific tag
git push origin v1.4

# push every local tag that isn't on the remote yet
git push origin --tags

# push only annotated tags reachable from pushed commits
git push origin --follow-tags

--follow-tags is the most disciplined option: it pushes annotated tags only, and only the ones that live on commits you're already pushing. Many teams set push.followTags = true in their global config so tagging-and-pushing becomes a single habit.

Deletion is symmetric: you must delete locally and on the remote, in two commands. The local one alone leaves the tag alive on the server, which leads to the classic "I deleted it, why is it back?" moment after the next fetch.

deleting a tagshell
# delete the tag locally
git tag -d v1.4

# delete it on the remote
git push origin --delete v1.4

# older equivalent syntax for the remote delete
git push origin :refs/tags/v1.4

Tag immutability

Tags are immutable by convention, not by Git's enforcement. git tag -f will happily overwrite a local tag, and git push -f --tags will push the change. But the moment a tag has left your machine, moving it becomes a public problem.

Don't move published tags

Once a tag is on a shared remote, treat it as frozen. Anyone who fetched the old v1.4 still has the old commit cached locally, while new clones get the new one. Two people now mean two different things when they say "v1.4," which defeats the entire point of tagging. The Git documentation describes silent re-tagging as "the insane thing": it asks users to trust that names mean what they used to mean, and re-tagging quietly breaks that trust. If a release was wrong, ship v1.4.1 instead.

The exception is when nobody has pulled the tag yet — if you tagged five seconds ago and noticed an off-by-one in the message, git tag -f before pushing is fine. After publishing, the rule is: cut a new version.

main branch with two release tags pinning historical commits a1b2 3f4d 8c2e d7a1 9fce b02 v1.0.0 v1.1.0 main / HEAD Flags pin commits. main keeps advancing rightward; v1.0.0 and v1.1.0 never move.

Semantic versioning

Tag names are free-form — Git doesn't enforce a format — but a dominant convention has emerged. Semantic Versioning (SemVer 2.0) gives tags a structured meaning so consumers can reason about compatibility without reading the changelog.

SemVer 2.0

Versions are written as MAJOR.MINOR.PATCH. Increment MAJOR for incompatible API changes, MINOR for backward-compatible additions, PATCH for backward-compatible bug fixes. Optional pre-release identifiers follow a hyphen (-alpha, -rc.1); optional build metadata follows a plus sign (+sha.abc123).

SemVer examplestext
v1.0.0              first stable release
v1.1.0              added a feature, didn't break anything
v1.1.1              fixed a bug, no API change
v2.0.0              breaking change: removed deprecated endpoint
v2.0.0-rc.1         release candidate; precedes v2.0.0
v2.0.0-alpha.2+sha.7c4e8a   pre-release with build metadata

The v prefix is conventional but not required by SemVer itself; some ecosystems (Go modules, for example) require it, others don't care. Pick one style and stick with it across the project. Pre-release tags sort before the corresponding stable version, which is exactly what you want for "release candidate ships first, then real release."

Releases as a forge concept

Git itself has tags. It does not have "releases." Release is something forges (GitHub, GitLab, Gitea, Forgejo) layer on top.

A GitHub Release, in the platform's own words, is a "deployable software iteration you can package and make available for a wider audience." Under the hood it's still a tag — you can't have a release without one — but the forge attaches:

  • Release notes — human-readable summary, often auto-generated from PR titles.
  • Downloadable artifacts — binaries, installers, source archives. The forge auto-generates source ZIP/tarball from the tagged commit.
  • Changelog hooks — comparison links to the previous release, contributor lists.
  • CI triggers — pipelines watching for release events or pushed v* tags to build and publish artifacts.
  • Status flags — "pre-release," "latest," "draft."
Tag vs release

The tag is the Git object. The release is the forge's UI surface around it. If you delete the tag, the release breaks. If you delete the release on the forge, the tag usually survives. Keep that asymmetry in mind when undoing things.

Release-creation workflows

Three patterns cover almost every shipping project. Pick the lightest one that fits.

Tag-from-main

For small projects or fast-moving libraries where main is always shippable: finish the work, run the tests, tag the latest commit, push. That's the release.

tag from mainshell
git checkout main
git pull
git tag -a v1.4.0 -m "Release 1.4.0"
git push origin v1.4.0

Release branch

When stabilization needs time (you're shipping a binary, you have integration tests that run for an hour, you have customers running older majors): cut a release/v2.0 branch from main. Bug fixes go to main first, then get cherry-picked to the release branch. When stable, tag on the release branch and ship.

release branchshell
git switch -c release/v2.0 main
# ... stabilization commits and cherry-picked fixes ...
git tag -a v2.0.0 -m "Release 2.0.0"
git push origin release/v2.0 v2.0.0

This pattern keeps main open for risky new work while the release calms down. See Cherry-pick & patch flow for the mechanics of porting fixes across branches.

Tag-and-CI

The modern default. Pushing a tag matching v* triggers a CI workflow that builds, tests, signs, and uploads artifacts to the release. You never touch a "publish" button — the tag is the publish button. Most release-automation tooling (GoReleaser, semantic-release, Release Please) is built around this model.

Reading the past from a tag

The reason you tagged in the first place: someone reports a bug in v1.0.0, and you need to see exactly that code.

visit an old releaseshell
# detached HEAD on the tagged commit - inspect, build, run
git checkout v1.0.0

# if you need to ship a fix on that release, branch from the tag
git switch -c hotfix/v1.0.1 v1.0.0
# ... commit the fix ...
git tag -a v1.0.1 -m "Hotfix 1.0.1"
git push origin hotfix/v1.0.1 v1.0.1

git checkout v1.0.0 drops you into "detached HEAD" state — useful for inspection, but new commits made there are easy to lose because no branch points at them. If you intend to commit, create a branch first with git switch -c.

git describe and changelogs

git describe generates a human-readable name for a commit, relative to the nearest reachable tag. The output looks like:

git describe outputtext
v1.2.3-7-gabc1234

Read left to right: nearest tag is v1.2.3, the current commit is 7 commits after it, and the current SHA starts with abc1234. The literal g means "this is Git." If the current commit is a tagged commit, you just get the tag name with no suffix.

using git describeshell
# default - prefers annotated tags
git describe

# include lightweight tags too
git describe --tags

# just the nearest tag, no commits-since suffix
git describe --abbrev=0

This is gold for CI: bake the output into your binary as the version string and every build is traceable to a Git commit, even between releases. myapp --version prints v1.2.3-7-gabc1234 and you know in seconds which commit shipped.

Generating changelogs

The simplest changelog is the commit log between two tags:

changelog between releasesshell
# every commit between v1.0.0 and v1.1.0
git log v1.0.0..v1.1.0 --oneline

# group by author
git shortlog v1.0.0..v1.1.0

# just the merge commits (often = the PRs)
git log v1.0.0..v1.1.0 --merges --oneline

For structured changelogs, tools like git-cliff, Conventional Commits + semantic-release, and Google's Release Please consume commit messages following a format (feat:, fix:, BREAKING CHANGE:) and produce categorized release notes automatically. The discipline pays off on the third release, not the first.

Common pitfalls

Pitfall 1

Forgetting that git push doesn't push tags. You tag v1.4, push, declare victory — and the tag never made it to the remote. Run git push origin v1.4 explicitly, or configure push.followTags = true globally so annotated tags ride along with commits automatically.

Pitfall 2

Using a lightweight tag for a release. Lightweight tags carry no tagger, no date, no message. Six months later git show v1.4 reveals nothing about who tagged it or why, and you can't sign them. Always use -a (or -s) for anything shared.

Pitfall 3

Moving a published tag. Force-pushing a tag change rewrites what v1.4 means, but only on the remote. Anyone who fetched the old tag still sees the old commit; new clones see the new one. Two truths, same name. Ship a new version instead.

Pitfall 4

Committing on a detached HEAD after git checkout v1.0.0. Those commits aren't on any branch. When you switch away, they're only reachable through the reflog and the garbage collector eventually eats them. If you intend to commit on top of a tag, git switch -c hotfix/... v1.0.0 first.

Worked examples

Example 1: Cut and publish v1.4.0 from main

You finished the work on main, CI is green, and you want to ship.

full release flowshell
git checkout main
git pull --ff-only
git tag -a v1.4.0 -m "Release 1.4.0 - new --json output, faster startup"
git push origin v1.4.0

If your forge has a Release UI, it now sees the tag and offers to create a Release from it. If CI is configured to react to v* tags, the build pipeline starts automatically.

Example 2: A hotfix on an older release

Production runs v1.0.0. A critical bug needs fixing without dragging in everything that's landed on main since.

hotfix branch from a tagshell
git switch -c hotfix/v1.0.1 v1.0.0
# edit, commit the fix
git commit -am "Fix off-by-one in retry loop"
git tag -a v1.0.1 -m "Hotfix 1.0.1"
git push origin hotfix/v1.0.1 v1.0.1

# port the fix forward to main
git switch main
git cherry-pick hotfix/v1.0.1

The hotfix branch lets you ship v1.0.1 without exposing your customers to anything else. The cherry-pick at the end keeps main in sync.

Example 3: Sign a release tag and verify it

For releases that get distributed as binaries, signed tags let downstream consumers confirm the tag was created by you, not by someone who took over your account.

sign and verifyshell
# one-time: configure your signing key
git config --global user.signingkey ABCDEF0123456789

# sign the tag
git tag -s v2.0.0 -m "Release 2.0.0"

# verify before pushing
git tag -v v2.0.0
# > gpg: Good signature from "Your Name <you@example.com>"

git push origin v2.0.0

The verify step runs automatically on the forge side too, which is how you get the "Verified" badge on a GitHub release.

Example 4: Use git describe to version a CI build

Every CI build produces a binary. You want each binary to know which commit built it, even when the commit isn't tagged.

build scriptshell
VERSION=$(git describe --tags --always --dirty)
echo "Building $VERSION"
go build -ldflags "-X main.version=$VERSION" ./cmd/myapp

On a clean tagged commit you get v1.4.0. Seven commits later you get v1.4.0-7-gabc1234. If the working tree has uncommitted edits, --dirty adds -dirty to the suffix — an invaluable signal that the binary in your hand didn't come from any committed state.

Sources & further reading

  • Pro Git, 2.6: Tagging Textbook Scott Chacon and Ben Straub

    The primary reference for tag mechanics: lightweight vs annotated tags, signing, listing patterns, pushing, deleting, and tagging old commits after the fact.

  • git-tag manual page Reference Git project

    Authoritative command reference. Includes the well-known "On Re-tagging" section that argues against silently moving published tags and explains the security reasoning.

  • git-describe manual page Reference Git project

    Exact specification of the tag-N-gSHA output format, the --dirty flag, abbreviation length rules, and behavior when no tag is reachable.

  • Semantic Versioning 2.0.0 Reference Tom Preston-Werner et al.

    The full SemVer specification: MAJOR/MINOR/PATCH rules, pre-release identifiers, build metadata, and precedence rules for comparing versions.

  • About releases Tutorial GitHub Docs

    How GitHub Releases sit on top of tags: release notes, attached binaries, auto-generated source archives, and the tag-vs-release distinction.

  • Git tag Tutorial Atlassian

    A practical walkthrough of the common tag operations with extra emphasis on team workflows and the --tags push flag.