Mert Özen Further With Every Line
DevOps Git & GitHub — #23

Git Tags and Releases: Marking Versions, git describe and GitHub Releases

Mert Özen Nov 6, 2026 8 min 2 views
Git Tags and Releases: Marking Versions, git describe and GitHub Releases

We mark releases with git tag: lightweight versus annotated tags, the tag object, semver, why push doesn't send tags, --tags versus --follow-tags, reading git describe output, GitHub Releases, deleting tags and why moving a published tag is dangerous.

Version note: The commands in this post were tested with Git 2.54.

Guess First

bookstore is ready for its first release. Ada marks the last commit and pushes. Once again we use a local bare repo (../bookstore.git) as the remote; it stands in for the repo on GitHub.

$ git tag v1.0.0
$ git push
Everything up-to-date

What does Everything up-to-date mean? Is the tag visible on GitHub now? Let's list the tags on the remote to find out:

$ git ls-remote --tags origin
$ git tag -d v1.0.0
Deleted tag 'v1.0.0' (was 2262247)

The list is empty. git push sends branches, not tags. Ada deleted the tag, because in this post we will create it again the right way.

What Is a Tag: A Name That Doesn't Move

In the branch post we saw that a branch is a moving pointer to a commit; it moves forward with every commit. A tag is also a name pointing to a commit, but it stays put. Whatever commit v1.0.0 points to today, it will point to five years from now.

A book analogy: a branch is the bookmark between the pages, moving forward as you read. A tag is the edition line on the copyright page; "second edition" always refers to the same text.

That is why tags are used for releases. When a customer says "there is a bug in 1.0.0", you need to know exactly which code was released. On the CI/CD side, a tag is often the event that triggers a release pipeline; we will build that in the GitHub Actions series.

Lightweight and Annotated Tags

Git has two kinds of tags:

$ git tag v1.0.0-rc.1
$ git tag -a v1.0.0 -m "Release 1.0.0"
$ git tag
v1.0.0
v1.0.0-rc.1
$ git cat-file -t v1.0.0-rc.1
commit
$ git cat-file -t v1.0.0
tag

The first one is a lightweight tag: just a name pointing to a commit, a branch that doesn't move. The second one is an annotated tag: it is created with -a and creates its own object in Git's object database. This is the fourth object type, next to the blob, tree and commit we saw in the commit post. Let's look inside:

$ git cat-file -p v1.0.0
object 226224745a84b8abf90db3158c728bb837e3734a
type commit
tag v1.0.0
tagger Ada Lovelace <ada@example.com> 1794380880 +0300

Release 1.0.0
$ git show v1.0.0 --stat
tag v1.0.0
Tagger: Ada Lovelace <ada@example.com>
Date:   Wed Nov 11 10:08:00 2026 +0300

Release 1.0.0

commit 226224745a84b8abf90db3158c728bb837e3734a (HEAD -> main, tag: v1.0.0-rc.1, tag: v1.0.0, origin/main, origin/HEAD)
Author: Ada Lovelace <ada@example.com>
Date:   Wed Nov 11 10:05:00 2026 +0300
...

An annotated tag carries who marked it, when, and with what message, and it can also be signed. The rule is simple: always use annotated tags for releases you publish. Keep lightweight tags for temporary notes to yourself, like "I'll look at this commit later". git describe and --follow-tags, which you will see shortly, also behave according to this distinction.

You can also put a tag on a commit in the past; just add the commit at the end of the command:

$ git tag -a v0.9.0 HEAD~1 -m "Beta release"
$ git tag -l "v1.*"
v1.0.0
v1.0.0-rc.1
$ git log --oneline -3
2262247 (HEAD -> main, tag: v1.0.0-rc.1, tag: v1.0.0, origin/main, origin/HEAD) Add search by title
2112fd8 (tag: v0.9.0) Add .gitignore
558fced Add cart total

-l filters with a pattern. If you don't pass -m for an annotated tag, Git opens an editor for the message.

Version Numbers with Semver

Tag names are free-form, but most teams use Semantic Versioning (semver): MAJOR.MINOR.PATCH. A backwards-incompatible change bumps MAJOR, a compatible new feature bumps MINOR, a bug fix only bumps PATCH. After 1.4.2, a bug fix gives 1.4.3, a new feature 1.5.0, a broken API 2.0.0. Pre-release versions are appended with a hyphen: 1.0.0-rc.1, 1.0.0-beta.2.

The leading v is not part of semver, but it is an established habit in the Git world: v1.0.0. Whichever you choose, be consistent within the repo. One warning: git tag sorts the list alphabetically, so v1.10.0 comes before v1.9.0. To see them in version order, use git tag --sort=-v:refname.

Pushing Tags: --tags and --follow-tags

You push a single tag by name:

$ git push origin v1.0.0
To ../bookstore.git
 * [new tag]         v1.0.0 -> v1.0.0
$ git ls-remote --tags origin
89d6b8689dce60f31d5d941cca5425730752be5e	refs/tags/v1.0.0
226224745a84b8abf90db3158c728bb837e3734a	refs/tags/v1.0.0^{}

The two lines are there because it is an annotated tag: the first is the tag object itself, the second, ending in ^{}, the commit it points to.

To send them all at once there are two options. git push --tags sends every local tag: lightweight ones, notes to yourself and half-finished experiments included. --follow-tags is more selective: it sends only the annotated tags reachable from the commits you push. Ada made one more commit and added a lightweight note to herself and an annotated tag for the new release:

$ git tag try-perf
$ git tag -a v1.1.0 -m "Release 1.1.0"
$ git push --follow-tags
To ../bookstore.git
   2262247..9d469ef  main -> main
 * [new tag]         v0.9.0 -> v0.9.0
 * [new tag]         v1.1.0 -> v1.1.0

Two annotated tags went along with main: the new v1.1.0, and v0.9.0 that we put on an old commit earlier, because it is also part of the pushed history. try-perf and v1.0.0-rc.1 stayed local because they are lightweight. To make this the default, use git config set push.followTags true.

git describe: Which Release Is This Build From?

The most useful by-product of tags is git describe. Two more commits were made after the last release:

$ git describe
v1.1.0-2-g1879802
$ git describe --abbrev=0
v1.1.0
$ git describe v1.1.0
v1.1.0

Break the output into pieces: the nearest annotated tag is v1.1.0, then 2 commits after it, and with the g prefix the short hash of the current commit, 1879802. In other words, "two commits after 1.1.0, at this commit". --abbrev=0 gives only the tag; if you are exactly on the tag, the output is just the tag name.

By default describe ignores lightweight tags:

$ git tag snapshot
$ git describe
v1.1.0-2-g1879802
$ git describe --tags
snapshot
$ git describe --dirty
v1.1.0-2-g1879802-dirty

--tags takes lightweight ones into account too. --dirty appends -dirty if the working tree has uncommitted changes. Embedding this output in the application's version info is a common technique in build scripts: a v1.1.0-2-g1879802-dirty in a bug report tells you the release, the commit, and that the build wasn't clean.

GitHub Releases

On GitHub, a release is a page built on top of a tag. On the repo page you go to the Releases section and click "Draft a new release". You pick an existing tag or type a new tag name; GitHub creates it at the tip of the branch you choose. Then you write a title and a description. The "Generate release notes" button produces a list from the PRs merged between two tags. You can attach built files, mark the release as a "pre-release", and publish.

You can do the same from the command line with gh release create v1.1.0 --generate-notes. What matters is this: a release is a GitHub concept, a tag is a Git concept. The tag travels with the repo to every clone; the release page stays on GitHub. Deleting a release doesn't delete the tag; you have to delete the tag separately.

Deleting and Moving Tags

Deleting local tags is easy, and it only affects your machine:

$ git tag -d v1.0.0-rc.1 try-perf snapshot
Deleted tag 'v1.0.0-rc.1' (was 2262247)
Deleted tag 'try-perf' (was 9d469ef)
Deleted tag 'snapshot' (was 1879802)
$ git push origin --delete v0.9.0
To ../bookstore.git
 - [deleted]         v0.9.0

Deleting from the remote is also one command, but the tag keeps living on the machine of everyone who fetched it before.

The really dangerous thing is moving a published tag to another commit. Say you found a bug after publishing v1.1.0, fixed it, and want the tag on the new commit:

$ git tag -a -f v1.1.0 -m "Release 1.1.0"
Updated tag 'v1.1.0' (was aad46fe)
$ git push origin v1.1.0
To ../bookstore.git
 ! [rejected]        v1.1.0 -> v1.1.0 (already exists)
error: failed to push some refs to '../bookstore.git'
hint: Updates were rejected because the tag already exists in the remote.
$ git push --force origin v1.1.0
To ../bookstore.git
 + aad46fe...6234b58 v1.1.0 -> v1.1.0 (forced update)

Git stopped us first; we got through with --force. Now let's look at the machine of Alan, who had fetched the tag before:

$ git fetch
$ git rev-parse --short v1.1.0^{commit}
9d469ef
$ git fetch --tags
From ../bookstore
 ! [rejected] v1.1.0     -> v1.1.0  (would clobber existing tag)
$ git fetch --tags --force
From ../bookstore
 t [tag update]      v1.1.0     -> v1.1.0
$ git rev-parse --short v1.1.0^{commit}
1879802

A plain git fetch said nothing, and Alan's v1.1.0 stayed on the old commit. When he asked explicitly with --tags, Git refused to clobber the existing tag. Only --force updated it. So when you move a tag, "1.1.0" can mean two different pieces of code across the team, in CI and on every machine that pulled it, and nobody may notice. The right way is almost always a new release: v1.1.1. Only consider moving a tag when you are sure nobody has fetched it yet.

Common Pitfalls

Assuming the tag was pushed. git push doesn't send tags. Either push the tag by name or use --follow-tags.

Sending junk with --tags. Your lightweight notes go too, and cleaning them from the remote doesn't clean them from everyone's machine.

Using lightweight tags for releases. Who marked it and when gets lost, git describe doesn't see it, --follow-tags doesn't send it.

Moving a published tag. As you saw above, you silently create two different "same releases". Cut a new PATCH release.

Being surprised by detached HEAD. You can inspect a release with git switch --detach v1.0.0; since a tag is not a branch, commits you make there belong to no branch. If you are going to fix something, create a branch first with git switch -c hotfix/1.0.1 v1.0.0.

A Small Experiment

★☆☆ Put a lightweight and an annotated tag on the same commit in your bookstore repo. Compare the git cat-file -t and git show output and write the difference in one sentence.

★★☆ With a bare repo, create one lightweight and two annotated tags; put one of the annotated ones on a commit in a branch you won't push. Guess which ones will appear in git ls-remote --tags origin after git push --follow-tags, then check.

★★★ Move a tag with two clones and watch how git fetch, git fetch --tags and git fetch --tags --force behave in the other clone. Then write a small command for your own build script that writes the git describe output into a version file.

With tags you now know which commit was released. But if a bug slipped in between two releases, how do you find which of hundreds of commits broke it? In the next post we do that in a few steps with binary search using git bisect, and then use git worktree to work on two branches of the same repo at the same time.