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.
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:
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.
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.
- The lightweight tag v0.1.0 is only a name in refs/tags. It points straight at commit 6929d5f, "Group digits the Indian way".
- 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.
- 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:
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:
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-tagsthe default withgit 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:
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:
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:
"""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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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.gzmakes 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; theghmanual says to make an annotated tag locally and push it first.--notes-from-taguses the tag’s own message as the notes, and--prereleaseand--draftset 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: 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.
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-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.
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.
-
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.
-
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?
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.
"""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 -aor-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-tagsorpush.followTags, and avoid--tags. - Never move a tag that others have fetched: their
git fetchkeeps the old one without a word. Release a new version instead. git describe --dirtynames any build as tag, commits since and commit ID, such asv1.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.suffixmakes 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
- git-tag: create, list, delete or verify tags (The Git project)
- git-describe: give an object a human-readable name (The Git project)
- git-push: --follow-tags and --tags (The Git project)
- git-fetch: updating tags is refused without --force (The Git project)
- git-config: versionsort.suffix and push.followTags (The Git project)
- Semantic Versioning 2.0.0 (Tom Preston-Werner and contributors (semver.org))
- About releases (GitHub)
- Managing releases in a repository (GitHub)
- Automatically generated release notes (GitHub)
- Immutable releases (GitHub)
- GitHub CLI manual: gh release create (GitHub)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress