Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Git & GitHub  Module 9 – Releases, signing and security

Tags, versions and GitHub Releases

Mark releases with annotated Git tags, push tags on purpose, name any build with git describe, choose SemVer numbers and publish a GitHub Release.

  • Beginner
  • 25 minutes
  • Examples run with git 2.54.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Create lightweight and annotated tags and explain the difference
  • Send tags to a shared repository on purpose and name any build with git describe
  • Choose MAJOR, MINOR or PATCH with Semantic Versioning 2.0.0 and sort pre-releases correctly
  • Publish a GitHub Release with generated notes and attached files

Before you start

What you’ll learn · Before you start

What you will learn

  • Create lightweight and annotated tags and explain the difference
  • Send tags to a shared repository on purpose and name any build with git describe
  • Choose MAJOR, MINOR or PATCH with Semantic Versioning 2.0.0 and sort pre-releases correctly
  • Publish a GitHub Release with generated notes and attached files

Before you start

On this page

A tag is a name for one commit that, unlike a branch, stays where it is when new commits arrive, which is why projects mark their releases with tags: v1.4.0 names the exact snapshot that users of version 1.4.0 got. Git has two kinds. A lightweight tag is only that name; an annotated tag is an object of its own that records who made it, when and why, and can carry a signature. Releases get annotated tags. git push does not send tags unless you ask, and a tag that other people have fetched must never move. The number in the name usually follows Semantic Versioning, MAJOR.MINOR.PATCH, where each part says what kind of change the release holds. A GitHub Release is a page built on a tag, with notes and files to download. This lesson tags a small library, sends its tags to a shared repository, reproduces what goes wrong when a published tag moves, and names any build with git describe.

Two kinds of tag

The sessions on this page follow numfmt, a small Python library that formats numbers. Its first two versions are tagged here in the two different ways:

A lightweight tag and an annotated tag Python · tag_kinds.py
from gitsession import session

session(r"""
git init -q numfmt
cd numfmt
printf '__version__ = "0.1.0"\n' > numfmt.py
git add numfmt.py
git commit -q -m "Group digits the Indian way"
# A lightweight tag: only a name for a commit
git tag v0.1.0
printf '__version__ = "0.2.0"\n' > numfmt.py
git commit -q -am "Format amounts with two decimal places"
# An annotated tag: an object of its own, with a tagger, a time and a message
git tag -a v0.2.0 -m "numfmt 0.2.0: amounts with two decimal places"
git tag
git cat-file -t v0.1.0
git cat-file -t v0.2.0
git cat-file -p v0.2.0
git show-ref --tags --dereference
git describe
! git describe HEAD~1
git describe --tags HEAD~1
""")

Output

$ git init -q numfmt
$ cd numfmt
$ printf '__version__ = "0.1.0"\n' > numfmt.py
$ git add numfmt.py
$ git commit -q -m "Group digits the Indian way"
# A lightweight tag: only a name for a commit
$ git tag v0.1.0
$ printf '__version__ = "0.2.0"\n' > numfmt.py
$ git commit -q -am "Format amounts with two decimal places"
# An annotated tag: an object of its own, with a tagger, a time and a message
$ git tag -a v0.2.0 -m "numfmt 0.2.0: amounts with two decimal places"
$ git tag
v0.1.0
v0.2.0
$ git cat-file -t v0.1.0
commit
$ git cat-file -t v0.2.0
tag
$ git cat-file -p v0.2.0
object 1151ef0592f6c1bf1b62e62df1538a7c49fcfe1e
type commit
tag v0.2.0
tagger Asha Rao <asha@example.com> 1767240000 +0530

numfmt 0.2.0: amounts with two decimal places
$ git show-ref --tags --dereference
6929d5f3bb63534d2b5a834adf4dc0a63c507bda refs/tags/v0.1.0
8fe03c34aea1120d1161d9f1c5d557835fc431eb refs/tags/v0.2.0
1151ef0592f6c1bf1b62e62df1538a7c49fcfe1e refs/tags/v0.2.0^{}
$ git describe
v0.2.0
$ git describe HEAD~1
fatal: No annotated tags can describe '6929d5f3bb63534d2b5a834adf4dc0a63c507bda'.
However, there were unannotated tags: try --tags.
[exit status 128]
$ git describe --tags HEAD~1
v0.1.0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tag_kinds.py

git tag v0.1.0 wrote nothing but a name. git cat-file -t says that the tag is a commit, because the name in refs/tags/ points straight at the commit. git tag -a v0.2.0 -m "…" first wrote a tag object, and git cat-file -p prints it: the commit it points at, the tag’s own name, the tagger with the time, and the message. The name v0.2.0 points at that object. git show-ref --dereference shows both steps: refs/tags/v0.2.0 is the tag object 8fe03c3, and v0.2.0^{}, the tag followed through to what it points at, is the commit 1151ef0.

The lightweight tag v0.1.0 points straight at a commit; the annotated tag v0.2.0 points at a tag object, which points at its commit.refs/tags/v0.2.0annotated tagrefs/tags/v0.1.0lightweight tagTag object 8fe03c3tagger, time, message(and a signature, if signed)Commit 1151ef0Format amounts withtwo decimal placesCommit 6929d5fGroup digits theIndian wayobjectparent

A lightweight tag and an annotated tag

Text description of the diagram

The diagram shows the two tags of the tag_kinds session and what each one points to.

  1. The lightweight tag v0.1.0 is only a name in refs/tags. It points straight at commit 6929d5f, "Group digits the Indian way".
  2. The annotated tag v0.2.0 is also a name in refs/tags, but it points at a tag object, 8fe03c3, which records the tagger, the time and the message, and a signature when the tag is signed.
  3. The tag object points at commit 1151ef0, "Format amounts with two decimal places", whose parent is commit 6929d5f.

So git cat-file -t prints commit for v0.1.0 and tag for v0.2.0, and git show-ref --dereference lists v0.2.0 twice: once as the tag object and once, as v0.2.0^{}, as the commit it points at.

The difference matters beyond the bytes stored. An annotated tag says who released what, when and why, and it can be signed with git tag -s, which the next lesson covers. git describe names commits after annotated tags only, unless you add --tags, as the end of the session shows, and git push --follow-tags sends annotated tags only. The git tag manual puts it plainly: annotated tags are meant for releases, lightweight tags for private or temporary labels. Give every release an annotated tag with a message, and keep lightweight tags as your own bookmarks. A message given with -m is enough: git tag v1.0.0 -m "numfmt 1.0.0" makes an annotated tag even without -a.

India-specific

numfmt groups digits the way amounts are written in India: the last three digits, then pairs, so 1234567 is 12,34,567, which reads as 12 lakh 34 thousand 567, and 1,00,00,000 is one crore.

Listing and sorting tags

git tag lists tags in alphabetical order, which puts v1.10.0 before v1.9.0. --sort=version:refname (or v:refname) compares the numbers inside the names as numbers. Pre-releases need one more setting:

Alphabetical, version order, and pre-releases Python · version_sort.py
from gitsession import session

session(r"""
git init -q numfmt
cd numfmt
git commit -q --allow-empty -m "History of numfmt"
git tag v1.9.0
git tag v1.10.0
git tag v2.0.0-alpha
git tag v2.0.0-beta.2
git tag v2.0.0-beta.11
git tag v2.0.0-rc.1
git tag v2.0.0
# Alphabetical, the default
git tag
# As versions: 1.10 after 1.9, but every pre-release after 2.0.0
git tag --sort=version:refname
# Tell Git which suffixes mark a pre-release, in their order
git config versionsort.suffix -alpha
git config --add versionsort.suffix -beta
git config --add versionsort.suffix -rc
git tag --sort=version:refname
git tag --sort=-version:refname --list 'v2*'
""")

Output

$ git init -q numfmt
$ cd numfmt
$ git commit -q --allow-empty -m "History of numfmt"
$ git tag v1.9.0
$ git tag v1.10.0
$ git tag v2.0.0-alpha
$ git tag v2.0.0-beta.2
$ git tag v2.0.0-beta.11
$ git tag v2.0.0-rc.1
$ git tag v2.0.0
# Alphabetical, the default
$ git tag
v1.10.0
v1.9.0
v2.0.0
v2.0.0-alpha
v2.0.0-beta.11
v2.0.0-beta.2
v2.0.0-rc.1
# As versions: 1.10 after 1.9, but every pre-release after 2.0.0
$ git tag --sort=version:refname
v1.9.0
v1.10.0
v2.0.0
v2.0.0-alpha
v2.0.0-beta.2
v2.0.0-beta.11
v2.0.0-rc.1
# Tell Git which suffixes mark a pre-release, in their order
$ git config versionsort.suffix -alpha
$ git config --add versionsort.suffix -beta
$ git config --add versionsort.suffix -rc
$ git tag --sort=version:refname
v1.9.0
v1.10.0
v2.0.0-alpha
v2.0.0-beta.2
v2.0.0-beta.11
v2.0.0-rc.1
v2.0.0
$ git tag --sort=-version:refname --list 'v2*'
v2.0.0
v2.0.0-rc.1
v2.0.0-beta.11
v2.0.0-beta.2
v2.0.0-alpha

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 version_sort.py

Version order fixes v1.9.0 and v1.10.0, and beta.2 before beta.11, but it still lists every pre-release after v2.0.0, because v2.0.0 is a shorter name with the same start. In Semantic Versioning a pre-release comes before its release. The versionsort.suffix setting tells Git which suffixes mark a pre-release, in the order they should sort, and the last two listings come out in true release order. Set it once in your global configuration and git tag --sort=-version:refname lists the newest release first. A tag.sort setting makes either order the default for git tag.

To look at an old release, git switch --detach v1.2.0 puts the files of that tag in your working tree without a branch. To fix a bug in it, start a branch there: git switch -c hotfix-1.2 v1.2.0.

Tags travel only when you send them

A shared repository has its own tags, and pushing a branch does not change them:

Pushing a branch, then its tags Python · push_tags.py
from gitsession import session

session(r"""
# A shared repository (a folder here) and Asha's clone of it
git init -q --bare numfmt.git
git clone -q numfmt.git numfmt
cd numfmt
printf '__version__ = "1.0.0"\n' > numfmt.py
git add numfmt.py
git commit -q -m "Group digits the Indian way"
git tag -a v1.0.0 -m "numfmt 1.0.0"
git tag try-new-rounding
git push -q origin main
# Pushing a branch sends no tags
git ls-remote --tags origin
# --follow-tags also sends the annotated tags of the commits being pushed
git push --follow-tags origin main
git ls-remote --tags origin
# --tags would send every tag, the private lightweight one too
git push --tags --dry-run origin
git tag -d try-new-rounding
cd ..
# Ravi's new clone gets every tag on the server
git clone -q numfmt.git ravi
cd ravi
git tag
git describe
""")

Output

# A shared repository (a folder here) and Asha's clone of it
$ git init -q --bare numfmt.git
$ git clone -q numfmt.git numfmt
warning: You appear to have cloned an empty repository.
$ cd numfmt
$ printf '__version__ = "1.0.0"\n' > numfmt.py
$ git add numfmt.py
$ git commit -q -m "Group digits the Indian way"
$ git tag -a v1.0.0 -m "numfmt 1.0.0"
$ git tag try-new-rounding
$ git push -q origin main
# Pushing a branch sends no tags
$ git ls-remote --tags origin
# --follow-tags also sends the annotated tags of the commits being pushed
$ git push --follow-tags origin main
To ~/numfmt.git
 * [new tag]         v1.0.0 -> v1.0.0
$ git ls-remote --tags origin
a0f0f759679b07e6f9503f040c23059cdeb546d1	refs/tags/v1.0.0
ff78e14bd065c87b16f24ce7f179277b78722434	refs/tags/v1.0.0^{}
# --tags would send every tag, the private lightweight one too
$ git push --tags --dry-run origin
To ~/numfmt.git
 * [new tag]         try-new-rounding -> try-new-rounding
$ git tag -d try-new-rounding
Deleted tag 'try-new-rounding' (was ff78e14)
$ cd ..
# Ravi's new clone gets every tag on the server
$ git clone -q numfmt.git ravi
$ cd ravi
$ git tag
v1.0.0
$ git describe
v1.0.0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 push_tags.py

After git push origin main, git ls-remote --tags origin printed nothing: the server had the commit but no tag. git push --follow-tags sends the branch and every annotated tag that points into the history being pushed, so v1.0.0 arrived and the lightweight try-new-rounding stayed at home. ls-remote lists an annotated tag twice: the tag object, and with ^{} the commit it points at. git push --tags sends every tag in refs/tags, bookmarks included, as the dry run shows, which is why it is a poor habit. Two safer habits:

  • push the release tag by name: git push origin v1.0.0;
  • or make --follow-tags the default with git config --global push.followTags true, so that every push of a branch also sends its annotated tags.

Ravi’s new clone got v1.0.0 without asking, because git clone and git fetch bring the tags that point at commits they download. To delete a tag on the server, run git push origin --delete v1.0.0; deleting it locally with git tag -d changes nothing there.

A published tag must never move

Suppose a bug turns up just after a release, and the fix goes on top. Moving the release tag to the fix looks tidy. Here is what it does to someone who already has the tag:

Moving a tag after others fetched it Python · retag.py
from gitsession import session

session(r"""
git init -q --bare numfmt.git
git clone -q numfmt.git numfmt
cd numfmt
printf '__version__ = "1.0.0"\n' > numfmt.py
git add numfmt.py
git commit -q -m "Release 1.0.0"
git tag -a v1.0.0 -m "numfmt 1.0.0"
git push -q --follow-tags origin main
cd ..
git clone -q numfmt.git ravi
cd numfmt
# Asha finds a bug after the release and moves the tag to the fix
printf '__version__ = "1.0.0"\nFIXED = True\n' > numfmt.py
git commit -q -am "Fix rounding of negative amounts"
git tag -f -a v1.0.0 -m "numfmt 1.0.0"
! git push origin v1.0.0
git push --force origin main v1.0.0
cd ../ravi
# Ravi fetches: the new commit arrives, the moved tag does not
git fetch
! git fetch --tags
git rev-parse --short v1.0.0^{commit}
git -C ../numfmt rev-parse --short v1.0.0^{commit}
""")

Output

$ git init -q --bare numfmt.git
$ git clone -q numfmt.git numfmt
warning: You appear to have cloned an empty repository.
$ cd numfmt
$ printf '__version__ = "1.0.0"\n' > numfmt.py
$ git add numfmt.py
$ git commit -q -m "Release 1.0.0"
$ git tag -a v1.0.0 -m "numfmt 1.0.0"
$ git push -q --follow-tags origin main
$ cd ..
$ git clone -q numfmt.git ravi
$ cd numfmt
# Asha finds a bug after the release and moves the tag to the fix
$ printf '__version__ = "1.0.0"\nFIXED = True\n' > numfmt.py
$ git commit -q -am "Fix rounding of negative amounts"
$ git tag -f -a v1.0.0 -m "numfmt 1.0.0"
Updated tag 'v1.0.0' (was e87d7ac)
$ git push origin v1.0.0
To ~/numfmt.git
 ! [rejected]        v1.0.0 -> v1.0.0 (already exists)
error: failed to push some refs to '~/numfmt.git'
hint: Updates were rejected because the tag already exists in the remote.
[exit status 1]
$ git push --force origin main v1.0.0
To ~/numfmt.git
   11d69fd..63472c7  main -> main
 + e87d7ac...ebcd6ed v1.0.0 -> v1.0.0 (forced update)
$ cd ../ravi
# Ravi fetches: the new commit arrives, the moved tag does not
$ git fetch
From ~/numfmt
   11d69fd..63472c7  main       -> origin/main
$ git fetch --tags
From ~/numfmt
 ! [rejected] v1.0.0     -> v1.0.0  (would clobber existing tag)
[exit status 1]
$ git rev-parse --short v1.0.0^{commit}
11d69fd
$ git -C ../numfmt rev-parse --short v1.0.0^{commit}
63472c7

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retag.py

git tag -f moved the tag in Asha’s clone, and the server refused the plain push with (already exists): a push never replaces a tag unless forced. Asha forced it. On Ravi’s side, git fetch downloaded the new commit and said nothing about the tag, because fetch only adds tags it does not have yet. git fetch --tags refused the update, would clobber existing tag, as Git has done since version 2.20. The last two lines are the damage: for Ravi, v1.0.0 is commit 11d69fd; for Asha and the server it is 63472c7. Two people now build “version 1.0.0” from different code, and nothing warns either of them.

The git tag manual’s advice on re-tagging is blunt: once others have seen a tag, admit the mistake and use a different name, because Git will not, and should not, change a tag behind its users’ backs. So when a tag is wrong after it was pushed, leave it and release v1.0.1 with the fix. Before anyone else has fetched the tag, moving it with git tag -f is harmless. Semantic Versioning says the same about releases: the contents of a released version must not be modified, so any fix goes out as a new version.

Naming any build with git describe

Between releases, a build still needs a name that a person can read and Git can resolve. git describe makes one from the nearest annotated tag:

git describe on a tag, after it, and with changes Python · describe.py
from gitsession import session

session(r"""
git init -q numfmt
cd numfmt
printf '__version__ = "1.2.0"\n' > numfmt.py
git add numfmt.py
git commit -q -m "Release 1.2.0"
git tag -a v1.2.0 -m "numfmt 1.2.0"
git describe
# Three more commits after the release
printf 'def words(n): ...\n' > words.py
git add words.py
git commit -q -m "Start words()"
git commit -q --allow-empty -m "Handle zero in words()"
git commit -q --allow-empty -m "Handle negative numbers in words()"
git describe
git log --oneline v1.2.0..HEAD
# An uncommitted change makes the build different from every commit
printf '# work in progress\n' >> words.py
git describe --dirty
# Only the nearest tag, or the long form even on a tag
git describe --abbrev=0
git describe --long v1.2.0
# No tag yet: --always falls back to the commit ID
git init -q ../fresh
cd ../fresh
git commit -q --allow-empty -m "First commit"
! git describe
git describe --always --dirty
""")

Output

$ git init -q numfmt
$ cd numfmt
$ printf '__version__ = "1.2.0"\n' > numfmt.py
$ git add numfmt.py
$ git commit -q -m "Release 1.2.0"
$ git tag -a v1.2.0 -m "numfmt 1.2.0"
$ git describe
v1.2.0
# Three more commits after the release
$ printf 'def words(n): ...\n' > words.py
$ git add words.py
$ git commit -q -m "Start words()"
$ git commit -q --allow-empty -m "Handle zero in words()"
$ git commit -q --allow-empty -m "Handle negative numbers in words()"
$ git describe
v1.2.0-3-g04a2159
$ git log --oneline v1.2.0..HEAD
04a2159 Handle negative numbers in words()
1d8674b Handle zero in words()
0ed8a4c Start words()
# An uncommitted change makes the build different from every commit
$ printf '# work in progress\n' >> words.py
$ git describe --dirty
v1.2.0-3-g04a2159-dirty
# Only the nearest tag, or the long form even on a tag
$ git describe --abbrev=0
v1.2.0
$ git describe --long v1.2.0
v1.2.0-0-gacaa4b7
# No tag yet: --always falls back to the commit ID
$ git init -q ../fresh
$ cd ../fresh
$ git commit -q --allow-empty -m "First commit"
$ git describe
fatal: No names found, cannot describe anything.
[exit status 128]
$ git describe --always --dirty
e03cb2e

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 describe.py

On a tagged commit it prints the tag, v1.2.0. Three commits later it printed v1.2.0-3-g04a2159: the tag, the number of commits since it (the same three that git log v1.2.0..HEAD lists), and g for Git followed by the abbreviated ID of the current commit. --dirty adds -dirty when the working tree has changes that are not committed, so a build made from them can never pass for the commit. --abbrev=0 prints only the nearest tag, and --long prints the long form even on a tag. A repository without tags makes git describe fail, and --always falls back to the commit ID.

A build script can embed git describe --dirty --always in the program’s version string. Every name it produces can be pasted back into Git: git show v1.2.0-3-g04a2159 shows that exact commit. Add --tags when your project tags releases with lightweight tags, and --match 'v[0-9]*' when other tags, such as deployment markers, must be skipped.

Choosing the number: Semantic Versioning 2.0.0

Semantic Versioning, SemVer for short, is a short public specification for version numbers of the form MAJOR.MINOR.PATCH. It starts from a public API, the functions, commands or formats that users rely on, and each part of the number says how a release changed it. From 1.4.2:

  • PATCH, to 1.4.3, when the release only fixes bugs, without changing the API;
  • MINOR, to 1.5.0, with PATCH back to 0, when it adds to the API in a backward compatible way, or marks something as deprecated;
  • MAJOR, to 2.0.0, with MINOR and PATCH back to 0, when it changes or removes anything users rely on.

A release that does several things takes the largest step: a bug fix shipped with a removed function is 2.0.0. Major version zero, 0.y.z, is initial development, during which anything may change at any time; 1.0.0 is the release that defines the public API. A pre-release adds a hyphen and dot-separated identifiers after the patch number, as in 2.0.0-rc.1, and comes before the release it leads to. Build metadata adds a plus sign, as in 2.0.0+build.5, and is ignored when versions are compared.

Comparing versions, called precedence in the specification, follows exact rules: numbers compare as numbers; a pre-release comes before the normal version; pre-release identifiers compare one at a time, numbers numerically and words in ASCII order, numbers before words; and a longer list wins when everything before it is equal. This program implements those rules in under 40 lines and checks itself against the specification’s own example. Run it, then edit the list and run it again:

SemVer precedence, and what is not a version Python · semver/semver_order.py
"""Orders version numbers by the precedence rules of Semantic Versioning 2.0.0 (item 11 of the specification)."""

import re
from functools import cmp_to_key

IDENTIFIER = re.compile(r"[0-9A-Za-z-]+")


def parse(version):
    """Splits "1.0.0-rc.1+build.5" into ((1, 0, 0), ["rc", "1"], ["build", "5"]), or raises ValueError."""
    rest, plus, build = version.partition("+")
    core, dash, pre = rest.partition("-")
    numbers = core.split(".")
    if len(numbers) != 3 or not all(n.isascii() and n.isdigit() for n in numbers):
        raise ValueError("needs MAJOR.MINOR.PATCH, three numbers")
    if any(len(n) > 1 and n.startswith("0") for n in numbers):
        raise ValueError("a number may not start with 0")
    pre_ids = pre.split(".") if dash else []
    build_ids = build.split(".") if plus else []
    for part in pre_ids + build_ids:
        if not IDENTIFIER.fullmatch(part):
            raise ValueError(f"identifier {part!r} must be ASCII letters, digits or hyphens, and not empty")
    for part in pre_ids:
        if part.isdigit() and len(part) > 1 and part.startswith("0"):
            raise ValueError(f"numeric identifier {part!r} may not start with 0")
    return tuple(int(n) for n in numbers), pre_ids, build_ids


def compare(a, b):
    """-1, 0 or 1. Build metadata is ignored, so 1.0.0+5 and 1.0.0+9 compare equal."""
    (core_a, pre_a, _), (core_b, pre_b, _) = parse(a), parse(b)
    if core_a != core_b:
        return -1 if core_a < core_b else 1
    if not pre_a or not pre_b:
        # A pre-release comes before the normal version with the same numbers.
        return (len(pre_a) < len(pre_b)) - (len(pre_a) > len(pre_b))
    for x, y in zip(pre_a, pre_b):
        if x == y:
            continue
        if x.isdigit() and y.isdigit():
            return -1 if int(x) < int(y) else 1
        if x.isdigit() != y.isdigit():
            return -1 if x.isdigit() else 1  # numbers come before words
        return -1 if x < y else 1  # words compare in ASCII order
    return (len(pre_a) > len(pre_b)) - (len(pre_a) < len(pre_b))


tags = ["2.0.0", "1.10.0", "2.0.0-rc.1", "2.0.0-beta.11", "1.9.0", "2.0.0-alpha", "2.0.0-beta.2", "2.0.0-alpha.1"]
print("SemVer order:", " < ".join(sorted(tags, key=cmp_to_key(compare))))

spec = "1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0"
expected = spec.split(" < ")
print("The specification's own example sorts the same:", sorted(reversed(expected), key=cmp_to_key(compare)) == expected)
print("1.0.0+build.5 against 1.0.0+build.9:", compare("1.0.0+build.5", "1.0.0+build.9"))

for text in ["v1.2.3", "1.2", "1.02.0", "1.0.0-rc.01", "1.0.0-", "1.0.0-rc_1"]:
    try:
        parse(text)
        print(f"{text}: valid")
    except ValueError as problem:
        print(f"{text}: not SemVer ({problem})")

Output

SemVer order: 1.9.0 < 1.10.0 < 2.0.0-alpha < 2.0.0-alpha.1 < 2.0.0-beta.2 < 2.0.0-beta.11 < 2.0.0-rc.1 < 2.0.0
The specification's own example sorts the same: True
1.0.0+build.5 against 1.0.0+build.9: 0
v1.2.3: not SemVer (needs MAJOR.MINOR.PATCH, three numbers)
1.2: not SemVer (needs MAJOR.MINOR.PATCH, three numbers)
1.02.0: not SemVer (a number may not start with 0)
1.0.0-rc.01: not SemVer (numeric identifier '01' may not start with 0)
1.0.0-: not SemVer (identifier '' must be ASCII letters, digits or hyphens, and not empty)
1.0.0-rc_1: not SemVer (identifier 'rc_1' must be ASCII letters, digits or hyphens, and not empty)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 semver_order.py

It puts the versions it shares with the Git session in the same order as Git did once versionsort.suffix was set. The last lines show strings that are not SemVer versions. v1.2.3 is one of them: the specification’s own answer is that v1.2.3 is a tag name and the version is 1.2.3. The v prefix is a widespread habit, so the sessions on this page keep it.

One more rule settles a common argument. If a release breaks compatibility by mistake, for example in a minor version, the specification says to fix it in a new release that restores compatibility, document the faulty version and never modify a release that has shipped. It is the same advice as for tags.

Publishing a GitHub Release

A GitHub Release is a page in a repository built on a Git tag. It adds a title, release notes and files to download, called assets, and GitHub attaches the repository’s contents at that tag as a zip file and a tarball by itself. Each file must be under 2 GiB, a release can hold up to 1,000 assets, and there is no limit on the total size or the download bandwidth. Anyone who can read the repository can see its releases; managing them needs write access.

Not affiliated

Based on public information about GitHub. MySmartCoPilot is not affiliated with GitHub, and GitHub’s real features may differ from what this lesson describes.

On the web: open Releases, then Draft a new release; choose the tag, or type a new one and pick the branch to create it on; choose the previous tag; press Generate release notes; attach files; and publish, or save a draft. This is a pre-release marks a release as not ready for production. When you leave Set as latest release unticked, GitHub picks the latest release by Semantic Versioning.

With GitHub’s command-line tool, gh, after pushing an annotated tag:

  • gh release create v1.2.0 --verify-tag --generate-notes dist/*.tar.gz makes the release from the existing tag, writes the notes and uploads the files. Without --verify-tag, a tag that does not exist on GitHub yet is created for you from the latest state of the default branch, which is easy to do by accident; the gh manual says to make an annotated tag locally and push it first.
  • --notes-from-tag uses the tag’s own message as the notes, and --prerelease and --draft set those flags.

Generated release notes list the pull requests merged since the previous release, the contributors and a link to the full changelog. A file named .github/release.yml groups them under headings by label and leaves some out:

.github/release.yml YAML · github/release.yml
# .github/release.yml: how GitHub groups merged pull requests when it generates release notes.
# Pull requests are grouped by their labels; "*" catches every one that matched no category above it.
changelog:
  exclude:
    labels:
      - skip-release-notes
  categories:
    - title: Breaking changes
      labels:
        - breaking-change
    - title: New features
      labels:
        - enhancement
    - title: Bug fixes
      labels:
        - bug
    - title: Other changes
      labels:
        - "*"

Here a pull request labelled skip-release-notes never appears, and "*" collects every pull request that matched no category above it. A label naming convention such as breaking-change also helps the person who picks the next version number.

YAML Validator & Formatter Check .github/release.yml for indentation and syntax errors before you commit it.

A repository or organisation can also turn on immutable releases. Once such a release is published, its assets cannot be changed or deleted, its tag cannot be moved, and the tag cannot be deleted while the release exists; if the release is deleted, the tag name still cannot be used again. Each immutable release also gets a release attestation, a cryptographically verifiable record of its tag, commit and assets that users can check. The title and notes stay editable. Create the release as a draft, attach every file, then publish, because nothing can be added afterwards.

Exercise: tag three releases

release_check.py makes a practice repository the first time you run it: release-practice/origin.git and a clone, release-practice/numfmt, whose history has v1.3.0 and three commits after it. Your task is to tag each of the three commits with the version it should get, as annotated tags, and push them to origin. Run the file again after each step; it checks every tag in the clone and on the shared repository.

release_check.py, run before any tag exists Python · exercise/release_check.py
"""Release-tagging practice. The first run makes release-practice/: a shared repository (origin.git) and a clone of
it (numfmt) with three unreleased commits after v1.3.0. Tag each of them with the version it should get, as an
annotated tag, push the tags, and run this file again: it checks the tags in the clone and on the shared repository.
"""

import os
import subprocess
import sys
from pathlib import Path

PRACTICE = Path("release-practice")
CLONE = PRACTICE / "numfmt"
COMMITS = [
    ("Release 1.3.0", "1.3.0"),
    ("Fix: negative amounts lost their minus sign", "1.3.1"),
    ("Add words(), which spells out an amount", "1.4.0"),
    ("Remove group_indian(); call group() instead", "2.0.0"),
]
# The practice history is the same on every computer: one author, fixed times, no signing or hooks of yours.
WHO = {"GIT_AUTHOR_NAME": "Asha Rao", "GIT_AUTHOR_EMAIL": "asha@example.com",
       "GIT_COMMITTER_NAME": "Asha Rao", "GIT_COMMITTER_EMAIL": "asha@example.com"}
SAFE = ["-c", "commit.gpgSign=false", "-c", "tag.gpgSign=false", "-c", "core.hooksPath=no-hooks"]


def git(*args, cwd=CLONE, when=None):
    env = {**os.environ, **WHO}
    if when:
        env["GIT_AUTHOR_DATE"] = env["GIT_COMMITTER_DATE"] = f"@{when} +0530"
    done = subprocess.run(["git", *SAFE, *args], cwd=cwd, env=env, capture_output=True, text=True)
    return done.stdout.strip() if done.returncode == 0 else None


def make_practice():
    PRACTICE.mkdir()
    git("init", "-q", "--bare", "-b", "main", "origin.git", cwd=PRACTICE)
    git("clone", "-q", "origin.git", "numfmt", cwd=PRACTICE)
    for n, (message, _) in enumerate(COMMITS):
        (CLONE / "CHANGES.txt").write_text("".join(f"{m}\n" for m, _ in COMMITS[: n + 1]))
        git("add", "CHANGES.txt")
        git("commit", "-q", "-m", message, when=1767240000 + 86400 * n)
    git("tag", "-a", "v1.3.0", "-m", "numfmt 1.3.0", git("rev-list", "--max-parents=0", "HEAD"), when=1767240000)
    git("push", "-q", "--follow-tags", "origin", "main")
    print(f"Made {CLONE}: three commits since v1.3.0 wait for their tags.")


if not PRACTICE.exists():
    make_practice()

ids = dict(line.split(" ", 1)[::-1] for line in git("log", "--format=%H %s").splitlines())
remote = dict(line.split("\t")[::-1] for line in (git("ls-remote", "--tags", "origin") or "").splitlines())
passed = 0
for message, version in COMMITS[1:]:
    tags = (git("tag", "--points-at", ids[message]) or "").split()
    tag = f"v{version}"
    if not tags:
        print(f"✗ {message}: not tagged yet")
    elif tag not in tags:
        print(f"✗ {message}: tagged {', '.join(tags)}, which is not the number this change calls for")
    elif git("cat-file", "-t", tag) != "tag":
        print(f"✗ {message}: {tag} is a lightweight tag; a release gets an annotated one (git tag -a)")
    elif remote.get(f"refs/tags/{tag}") != git("rev-parse", tag):
        print(f"✗ {message}: {tag} is right, but origin does not have it yet")
    else:
        print(f"✓ {message}: {tag}, annotated, on origin")
        passed += 1
print(f"{passed} of 3 checks pass")
sys.exit(0 if passed == 3 else 1)

Output (exit status 1)

Made release-practice/numfmt: three commits since v1.3.0 wait for their tags.
✗ Fix: negative amounts lost their minus sign: not tagged yet
✗ Add words(), which spells out an amount: not tagged yet
✗ Remove group_indian(); call group() instead: not tagged yet
0 of 3 checks pass

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 release_check.py

First hint: read the three commit messages with git log --oneline v1.3.0..main, decide which part of the version each one raises, and count from the tag before it, not from 1.3.0 each time. git tag -a <name> <commit> -m "numfmt <version>" tags a commit that is not the current one.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 git cat-file -t v2.1.0 prints commit. What does that tell you about the tag v2.1.0?

    Choose one answer.

    Show the answer to question 1

    Answer: It is a lightweight tag: the name points straight at a commit

    An annotated tag is an object of its own, so git cat-file -t prints tag for it. A lightweight tag is only a name in refs/tags, and the object it names is the commit itself.

  2. Question 2 of 6 You commit, run git tag -a v1.4.0 -m "numfmt 1.4.0" and then git push origin main. Does the shared repository now have the tag v1.4.0?

    Choose one answer.

    Show the answer to question 2

    Answer: No: pushing a branch sends no tags unless you push the tag or use --follow-tags

    git push sends the refs you name. git push origin v1.4.0 sends the tag by name, and --follow-tags (or push.followTags) also sends the annotated tags that point into the history being pushed.

  3. Question 3 of 6 Ravi fetched v1.0.0 yesterday. Today the maintainer moved v1.0.0 to another commit and force-pushed it. What does Ravi's plain git fetch do with his v1.0.0?

    Choose one answer.

    Show the answer to question 3

    Answer: Nothing: he keeps the old v1.0.0, and fetch prints no warning about it

    Plain fetch only adds tags it does not have yet, so the old tag stays without a word. git fetch --tags tries to update it and is refused with "would clobber existing tag". That is why a published tag must never move.

  4. Question 4 of 6 A build reports its version as v3.0.1-7-g9c2e41a-dirty. What was it built from?

    Choose one answer.

    Show the answer to question 4

    Answer: Commit 9c2e41a, seven commits after the tag v3.0.1, with changes that were not committed

    git describe prints the nearest annotated tag, the number of commits since it, g and the abbreviated ID of the commit, and with --dirty the suffix -dirty when the working tree has changes that are not committed.

  5. Question 5 of 6 numfmt is at 1.4.2. The next release adds an optional places parameter and marks the old round2() function as deprecated, without removing it. Under Semantic Versioning 2.0.0, what is its version?

    Choose one answer.

    Show the answer to question 5

    Answer: 1.5.0

    A backward compatible addition raises MINOR, and so does marking public API as deprecated; PATCH goes back to 0. Removing round2() later would be a breaking change and need 2.0.0.

  6. Question 6 of 6 Put these versions in Semantic Versioning order, lowest first.

    Give each item its position, from 1 (first).

    Show the answer to question 6

    Answer:

    1. 2.0.0-beta.2
    2. 2.0.0-beta.11
    3. 2.0.0-rc.1
    4. 2.0.0

    beta.2 and beta.11 share beta, and 2 is less than 11 as numbers. beta comes before rc in ASCII order, and every pre-release comes before the normal version 2.0.0.

Interview questions

What is the difference between a lightweight and an annotated tag, and which one should mark a release?

A lightweight tag is only a name in refs/tags that points straight at a commit; nothing records who made it or why. An annotated tag is an object of its own: it stores the tagger, the time and a message, can carry a signature, and the name points at that object, which points at the commit. git cat-file -t shows the difference, printing commit for a lightweight tag and tag for an annotated one. Releases should get annotated tags: they say who released what and why, they can be signed, git describe uses them by default and git push --follow-tags sends them. Lightweight tags suit private bookmarks. Either kind must never move once others have fetched it.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A maintainer moved the v2.3.0 tag to a bug-fix commit and force-pushed it the day after the release. Two build machines now produce different binaries for "v2.3.0". Explain what happened on each machine, then how you would recover and stop it happening again.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Your library is at 1.4.2. The next release removes a function deprecated since 1.2.0, fixes two bugs and adds an optional parameter. What version is it under Semantic Versioning, how would you ship it so that users are not surprised, and what if it already went out as 1.5.0?

    Model answer, Pro. First step and follow-ups

Run the sessions yourself

Behind every output on this page is a small Python script: it types real Git commands, shows each one after a $, and prints Git’s answer underneath. To repeat a run on macOS or Linux, keep gitsession.py beside the script and start the script with Python 3, as in python3 retag.py. The helper works in a throwaway folder named learner, which doubles as the home directory, so nothing in your own Git configuration changes, and it pins the time of each commit and tag, which is why your IDs come out identical to these.

gitsession.py: the helper the sessions use Python · gitsession.py
"""Plays a terminal session with real Git: each command is printed after "$ ", then everything it printed.

The session runs in a new folder called learner/, which is also its home folder, so your own Git settings change
nothing, and GIT_CONFIG_NOSYSTEM=1 makes Git skip the computer's system-wide settings file too. Its ~/.gitconfig
holds only a name, an email address and init.defaultBranch = main, the settings the lesson on installing Git sets
up. In the output, "~" stands for that folder. Commit and tag times are fixed, because an object's ID covers the
time it was made: with the same files, messages, names and times, every run makes the same IDs. A command can still
set its own time, as in a shell: GIT_COMMITTER_DATE="@1767326400 +0530" git commit ...

Each line of a script is one /bin/sh command (macOS and Linux), except "cd", which the session does itself. A line
that starts with "# " is printed as a comment. A command written as "! command" is meant to fail: its exit status
is printed and the session goes on. Any other command that fails stops the session with exit status 1.

session() can also be given:
  user     the name and email address of the person at the keyboard (Asha Rao, asha@example.com by default);
  setup    commands that build the starting point first, without being shown (they are in the example's code);
  mask     (pattern, replacement) pairs for text that is new on every run, such as how long a command took;
  prepare  a function that receives the new home folder before any command runs, to put files into it;
  path     more folders in which to look for commands, such as the folder of a downloaded tool.
"""

import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

HOME = Path("learner").resolve()
# One fixed moment in Git's own date format: seconds since 1970, then the time zone (India, UTC+05:30).
FIXED_TIME = "@1767240000 +0530"


def _environment(path):
    # Git's own folder comes first, so every command runs that Git directly (on macOS, /usr/bin/git is a small
    # launcher that looks Git up again on every call).
    git_folder = subprocess.run(["git", "--exec-path"], capture_output=True, text=True, check=True).stdout.strip()
    folders = [git_folder, *(str(Path(p).resolve()) for p in path), os.environ.get("PATH", "/usr/bin:/bin")]
    env = {
        "PATH": os.pathsep.join(folders),
        "HOME": str(HOME),
        "XDG_CONFIG_HOME": str(HOME / ".config"),
        "GIT_CONFIG_NOSYSTEM": "1",
        "GIT_AUTHOR_DATE": FIXED_TIME,
        "GIT_COMMITTER_DATE": FIXED_TIME,
        "LANG": "C",
        "LC_ALL": "C",
        "TERM": "dumb",
    }
    if "TMPDIR" in os.environ:
        env["TMPDIR"] = os.environ["TMPDIR"]
    return env


def _with_tilde(text):
    """Writes the home folder as "~", however the operating system spells its path."""
    for spelling in sorted({str(HOME), os.path.realpath(HOME)}, key=len, reverse=True):
        text = text.replace(spelling, "~")
    return text


def _commands(script):
    for line in script.strip().splitlines():
        if line.strip():
            yield line.strip()


def session(script, user=("Asha Rao", "asha@example.com"), setup="", mask=(), prepare=None, path=()):
    """Runs the script's commands one by one, starting in a new home folder."""
    shutil.rmtree(HOME, ignore_errors=True)
    HOME.mkdir()
    (HOME / ".gitconfig").write_text(f"[user]\n\tname = {user[0]}\n\temail = {user[1]}\n[init]\n\tdefaultBranch = main\n")
    if prepare:
        prepare(HOME)
    env = _environment(path)
    folder = HOME
    for command in _commands(setup):
        if command.startswith("# "):
            continue
        if command.startswith("cd "):
            folder = (folder / command[3:].strip()).resolve()
            continue
        done = subprocess.run(command, shell=True, cwd=folder, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        if done.returncode != 0:
            sys.exit(f"setup command failed: {command}\n{_with_tilde(done.stdout)}")
    folder = HOME
    for command in _commands(script):
        if command.startswith("# "):
            print(command)
            continue
        meant_to_fail = command.startswith("! ")
        if meant_to_fail:
            command = command[2:]
        print("$", command)
        if command.startswith("cd "):
            folder = (folder / command[3:].strip()).resolve()
            continue
        done = subprocess.run(command, shell=True, cwd=folder, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        output = _with_tilde(done.stdout)
        for pattern, replacement in mask:
            output = re.sub(pattern, replacement, output)
        if output and not output.endswith("\n"):
            output += "\n"
        print(output, end="")
        if done.returncode != 0:
            print(f"[exit status {done.returncode}]")
            if not meant_to_fail:
                sys.exit(1)
        elif meant_to_fail:
            sys.exit(f"{command} was meant to fail, but it worked")

Key takeaways

  • A tag names one commit and stays put; branches move, tags do not.
  • Lightweight tags are bare names; annotated tags (git tag -a or -m) are objects with a tagger, a time, a message and an optional signature. Releases get annotated tags.
  • Pushing a branch sends no tags: push the tag by name, or use --follow-tags or push.followTags, and avoid --tags.
  • Never move a tag that others have fetched: their git fetch keeps the old one without a word. Release a new version instead.
  • git describe --dirty names any build as tag, commits since and commit ID, such as v1.2.0-3-g04a2159-dirty.
  • SemVer: PATCH for fixes, MINOR for compatible additions and deprecations, MAJOR for breaking changes; a pre-release comes before its release, and versionsort.suffix makes Git list them that way.
  • A GitHub Release adds notes and files to a tag; use gh release create --verify-tag, configure notes with .github/release.yml, and consider immutable releases.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.