Why this matters
A commit is identified by a long code of letters and numbers, such as 8235efed3202563445d19a30046256b7c38e7941. Nobody can remember that, and nobody can say it in a message. A tag gives a commit a short name that does not change, such as v1.1.1. Someone can ask "does version 1.1.1 have the crash fix?" and you can answer with one command.
A release is the public version of a tag. On GitHub, a release is a page attached to a tag. It holds a title, notes that describe what changed, and optionally files for people to download. Users of your project look at releases to decide which version to install.
This lesson covers three things: how to create a tag, how to choose a version number, and how to publish a release. It assumes you know how to make commits and push them to GitHub, which the earlier lessons in this module cover.
A tag is a name that stays put
A branch is a name that moves. Each time you commit on main, the name main moves forward to the new commit. A tag is a name that does not move. It points at one commit, and it keeps pointing at that commit until someone deletes it or forces it to a new place.
That difference is the reason to use tags for releases. Suppose version 1.1.1 is the commit you tested and shipped. If you only remember "the latest commit on main", that answer changes every day. A tag v1.1.1 always means the same snapshot of the code.
The practice below uses a small notes app. The history has four commits:
mkdir tags-practice && cd tags-practice
git init -q -b main
git config user.name Ana
git config user.email ana@example.com
printf 'Notes app\n' > README.md
git add . && git commit -q -m "Start notes app"
printf 'print("notes v1")\n' > app.py
git add . && git commit -q -m "Add first screen"
printf 'print("notes v1.1")\n' > app.py
git commit -qam "Add search"
printf 'print("notes v1.1.1")\n' > app.py
git commit -qam "Fix crash on empty search"
git log --onelineYour commit IDs will differ from the ones shown later in this lesson. The messages will match.
Two kinds of tag
Git has two kinds of tag. The difference matters for releases.
A lightweight tag is only a name. It points straight at a commit, and that is all it stores. You create one by leaving out the message:
git tag lightweight-test
git cat-file -t lightweight-testcommitgit cat-file -t prints the type of the object a name points at. A lightweight tag points at a commit.
An annotated tag is a small record of its own. It stores the name, the person who made it, the date, a message, and the commit it points at. You create one with -a (for annotated) and -m (for the message):
git tag -a v1.0.0 -m "First usable release" HEAD~2
git cat-file -t v1.0.0tagThe type is tag, not commit, because the name points at the tag record, and the tag record points at the commit. HEAD~2 in the command above means two steps before the newest commit, which is "Add first screen". Here is the raw record:
git cat-file -p v1.0.0object b8935fa07456c155fb2abaa0a4dde95b56db1e8f
type commit
tag v1.0.0
tagger Ana <ana@example.com> 1793874623 +0000
First usable releaseThe first line names the commit. The tagger line shows who made the tag and when. The last line is the message. A lightweight tag has none of this.
For releases, use annotated tags. Two reasons:
- The tagger and date tell you who published the version and when.
- Some tools read the message of an annotated tag. The
ghcommand shown later in this lesson can copy an annotated tag's message into the release notes.
Lightweight tags are useful for quick private marks, such as "I was here before the refactor". Delete the test tag now, so the practice repository only has release tags:
git tag -d lightweight-testTagging an earlier commit
A tag does not have to go on the newest commit. You can name any commit by its ID, or by a relative name such as HEAD~1, which means "the commit one step before the newest". In the practice repository, "Add search" is the commit before "Fix crash on empty search":
git tag -a v1.1.0 -m "Add search" HEAD~1
git tag -a v1.1.1 -m "Fix empty search crash"The second command has no commit at the end, so the tag goes on HEAD, the newest commit. Now look at the history with tags shown:
git log --oneline --decorate8235efe (HEAD -> main, tag: v1.1.1) Fix crash on empty search
e52711b (tag: v1.1.0) Add search
b8935fa (tag: v1.0.0) Add first screen
020dde2 Start notes appThe text in brackets is the list of names that point at each commit. HEAD -> main shows that you are on main, and that main points at the newest commit. The three tags sit on three different commits.
Use git show on a tag to see both the tag record and the commit it points at:
git show --no-patch v1.0.0tag v1.0.0
Tagger: Ana <ana@example.com>
Date: Thu Nov 5 10:30:23 2026 +0000
First usable release
commit b8935fa07456c155fb2abaa0a4dde95b56db1e8f
Author: Ana <ana@example.com>
Date: Thu Nov 5 10:30:23 2026 +0000
Add first screen--no-patch hides the list of changed lines, so only the headers and messages print.
Version numbers
A version number is a label that tells users how much changed between two releases. The most common scheme is called semantic versioning, usually shortened to SemVer. It has three numbers, written MAJOR.MINOR.PATCH:
- MAJOR goes up when a change breaks people who use the project. A user who upgrades must change their own code or settings.
- MINOR goes up when you add something new and nothing breaks.
- PATCH goes up for a bug fix that changes nothing else.
Each time a number goes up, the numbers to its right reset to zero. From 1.4.7, a new feature gives 1.5.0, a breaking change gives 2.0.0, and a bug fix gives 1.4.8.
Here is the practice history read with these rules. 1.0.0 is the first release. 1.1.0 added search, which is a new feature, so the minor number goes up. 1.1.1 fixed a crash, so the patch number goes up. If the next change removed a function that users called, the release would be 2.0.0.
SemVer is a convention, not a rule that Git checks. Git accepts any tag name. Most projects write tags with a leading v, such as v1.1.1, so that the name is clearly a version. Some projects use a pre-release marker for versions that are not ready, such as v2.0.0-rc.1, where rc means release candidate. The full rules are on the semver.org site.
Sorting trap
Tags are listed in alphabetical order by default. Alphabetical order is wrong for version numbers. Create two more tags to see it:
git tag -a v1.9.0 -m "Nine" HEAD
git tag -a v1.10.0 -m "Ten" HEAD
git tag -l 'v1.*'v1.0.0
v1.1.0
v1.1.1
v1.10.0
v1.9.0v1.10.0 comes before v1.9.0, because the text 1 sorts before 9. Ten is larger than nine, so it should come last. Ask Git to compare the numbers as versions:
git tag -l --sort=version:refname 'v1.*'v1.0.0
v1.1.0
v1.1.1
v1.9.0
v1.10.0Use --sort=version:refname any time you list release tags. To get the newest first, add a minus sign: --sort=-version:refname.
Delete the two extra tags so that the practice repository matches the rest of the lesson:
git tag -d v1.9.0 v1.10.0Describing where you are
Once tags exist, Git can describe a commit in terms of the nearest tag before it. The command is git describe, with --tags to include tags that are not annotated:
git describe --tagsv1.1.1If the newest commit has no tag, the answer includes how many commits came after the tag, and the short ID of the newest commit. Make one commit after v1.1.1 and try again:
printf 'print("notes v1.1.2")\n' > app.py
git commit -qam "Fix title typo"
git describe --tagsv1.1.1-1-gbc50c7bThe text means: one commit after v1.1.1, and the newest commit starts with bc50c7b. The letter g stands for Git. Build scripts often use this output to print a version string for a development build. Tag the commit, and git describe prints the tag name again:
git tag -a v1.1.2 -m "Fix title typo"
git describe --tagsv1.1.2Pushing tags to GitHub
Tags are not sent to the remote by a normal git push. The remote is the copy of the repository on GitHub, and it only gets the tags you ask for. Push one tag by name:
git push origin v1.1.2To https://github.com/YOUR-NAME/YOUR-REPO.git
* [new tag] v1.1.2 -> v1.1.2origin is the name Git gives the remote you cloned from or added. The first line of the real output names the remote's address, which is your GitHub repository's address. The line [new tag] confirms that the tag now exists on the remote.
To push every tag you have made, use --tags:
git push origin --tagsTo see which tags the remote has, ask it directly:
git ls-remote --tags origin0419a70fa0cc29767611055e8d58162f5ce12ac4 refs/tags/v1.0.0
b8935fa07456c155fb2abaa0a4dde95b56db1e8f refs/tags/v1.0.0^{}
9dc5dc223adcd4b41f4c901c63f25ddd1c1bbeac refs/tags/v1.1.0
e52711b02e556454cd696960e1571af4cd7fc586 refs/tags/v1.1.0^{}
dd6b3a4965ee72f2c0b01cbf0095cd84343948da refs/tags/v1.1.1
8235efed3202563445d19a30046256b7c38e7941 refs/tags/v1.1.1^{}
e08093ae55c33b5547da0bc965f2b0da26f63f1a refs/tags/v1.1.2
bc50c7b0abe15e84b193ec225e0b2e81e9d09e44 refs/tags/v1.1.2^{}Each annotated tag appears twice. The first line is the tag record. The line ending in ^{} is the commit it points at. Lightweight tags appear once.
Creating a GitHub release
A release is created on GitHub, after the tag is pushed. Open the repository on GitHub, go to the Releases section, and start a new release. Choose the tag, write a title and notes, and publish. The page then shows the title, the notes and the commit the tag points at.
The same thing can be done from the terminal with the GitHub CLI, a program called gh. Its help text lists the options. Read it on your own machine with:
gh release create --helpThese are the options a beginner needs:
--titlesets the title shown on the release page, such as1.1.1.--notestakes the notes as text.--notes-filereads them from a file.--notes-from-taguses the message of an annotated tag as the notes.--verify-tagstops the command if the tag does not exist on the remote yet.--draftsaves the release without publishing it, so you can check it first.--prereleasemarks a version that is for testing, not for general use.
A typical command for a patch release looks like this:
gh release create v1.1.2 --title "1.1.2" --notes-from-tag --verify-tagThe command needs you to be logged in with gh auth login, and it needs the current folder to be a clone of the GitHub repository. Because this lesson's practice repository has no GitHub remote, the command was not run here. The options come from the help text printed by gh version 2.99.0.
The help text also says what happens when the tag is missing. Without --verify-tag, the command creates the tag for you, from the newest commit on the default branch. That is convenient, but it can tag a commit you did not test. Pushing the tag first, and then running the release command with --verify-tag, makes the tagged commit an explicit choice.
Attach files to a release by listing them after the command. Each file can have a label after a #:
gh release create v1.1.2 --notes-from-tag --verify-tag "dist/notes-app.zip#Notes app for Linux"Fixing an old version
Sometimes a bug is found in version 1.1.1 after the project has moved on. Users of 1.1.1 need the fix, but main may already hold changes that are not ready for release. The usual answer is a patch release built from the old tag, on a branch of its own.
Create a branch that starts at the tag. The name release-1.1 follows a common pattern, one branch for each line of releases:
git switch -c release-1.1 v1.1.1git switch -c NAME START creates a branch called NAME at START, and moves you onto it. Make the fix and commit it:
printf 'print("notes v1.1.1 patched")\n' > app.py
git commit -qam "Fix save on empty note"Tag the commit with the next free patch number. Version 1.1.2 already exists on main, so the next free number is 1.1.3. Reusing 1.1.2 for a different commit would confuse anyone who already has that name. Then push the branch and the tag together:
git tag -a v1.1.3 -m "Fix save on empty note"
git push origin release-1.1 v1.1.3To https://github.com/YOUR-NAME/YOUR-REPO.git
* [new branch] release-1.1 -> release-1.1
* [new tag] v1.1.3 -> v1.1.3The history now has two lines. main holds the next version, and release-1.1 holds the patched old version. The log on the release branch shows the fix sitting on top of the old tag:
git log --oneline --decorate -38feac2a (HEAD -> release-1.1, tag: v1.1.3, origin/release-1.1) Fix save on empty note
8235efe (tag: v1.1.1) Fix crash on empty search
e52711b (tag: v1.1.0) Add searchorigin/release-1.1 is Git's record of where that branch was on the remote the last time you pushed or fetched. It is not a separate copy of the code.
Create the GitHub release for v1.1.3 in the same way as any other release. Its notes should describe only this fix, because users of the 1.1 line need to know what changed since 1.1.1.
Common mistakes
Pushing a tag that does not exist. The name must match a tag in your folder exactly. Typing v9.9.9 when no such tag exists gives this error:
error: src refspec v9.9.9 does not match any
error: failed to push some refs to '...'The second line ends with the address of your remote, shortened here.
Run git tag -l to list the tags you have, and copy a name from that list.
Moving a tag by accident. If you create a tag with a name that already exists, Git refuses:
fatal: tag 'v1.1.1' already existsTo move a tag on purpose, you must pass -f (force). The tag then points at a new commit on your computer. Pushing that moved tag to GitHub is refused, because the remote already has the old one:
To https://github.com/YOUR-NAME/YOUR-REPO.git
! [rejected] v1.1.1 -> v1.1.1 (already exists)
error: failed to push some refs to '...'
hint: Updates were rejected because the tag already exists in the remote.Moving a tag that people have already downloaded causes trouble. Anyone who fetched the old v1.1.1 now has a different commit under the same name. Their copy and the new copy no longer match, and it is hard to tell which one a bug report refers to. The usual fix is to leave the old tag alone and create a new version, such as v1.1.2.
If you still need to move a tag that was never shared, force both the local tag and the remote tag:
git tag -f -a v1.1.1 -m "Fix empty search crash" HEAD
git push --force origin v1.1.1The forced push reports the change:
To https://github.com/YOUR-NAME/YOUR-REPO.git
+ dd6b3a4...4ab09d2 v1.1.1 -> v1.1.1 (forced update)Deleting a tag on the remote. Removing a tag from GitHub takes a separate command. Deleting only your local copy does not change GitHub:
git push origin --delete v1.1.1To https://github.com/YOUR-NAME/YOUR-REPO.git
- [deleted] v1.1.1Think before you do this. A release page that points at a deleted tag is broken, and people may already depend on the name.
Tagging a commit that is not tested. A tag records a choice. Make the tag after the tests pass on the commit you intend to ship, not on whatever is newest when you remember to do it.
Practice
Exercise 1. Make a lightweight tag called marker on the newest commit in the practice repository. Then check whether it is lightweight or annotated. What does git cat-file -t print?
Show the answer
Run git tag marker and then git cat-file -t marker. It prints commit, which means the tag points straight at a commit and stores nothing else. Delete it afterwards with git tag -d marker.
Exercise 2. A project is at version 2.3.4. Which number changes for each of these, and what is the new version? (a) A new export feature is added, and nothing that existed before has changed. (b) A bug is fixed. (c) The function load_settings is renamed, so existing code that calls it breaks.
Show the answer
(a) The minor number goes up, giving 2.4.0, because a new feature was added and nothing broke. (b) The patch number goes up, giving 2.3.5, because a bug fix does not change anything else. (c) The major number goes up, giving 3.0.0, because existing code breaks and must be changed. The minor and patch numbers reset to zero.
Exercise 3. Your repository has four release tags: v1.2.0, v1.10.0, v1.9.1 and v1.9.0. Write the command that lists only these, oldest first, and write its output. Then say which tag plain git tag -l shows first.
Show the answer
Run git tag -l --sort=version:refname 'v1.*'. The output is:
v1.2.0
v1.9.0
v1.9.1
v1.10.0Plain git tag -l shows v1.10.0 first, because it compares the text one character at a time, and 1 comes before 2.
Exercise 4. You tagged v2.0.0 on your laptop and ran git push origin main. The tag is not on GitHub. Why, and what command fixes it?
Show the answer
git push origin main sends commits on main only. Tags are not sent by a normal push. Run git push origin v2.0.0 to send that one tag, or git push origin --tags to send all tags you have made. Check the result with git ls-remote --tags origin.
Exercise 5. Explain in plain words why a release should point at a tag, not at "the newest commit on main", and what goes wrong if the tag is not pushed before you create the release on GitHub.
Show the answer
A tag names one fixed commit, so the release always contains the same code, however many commits come after it. "The newest commit on main" changes each time someone commits, so the release could contain code nobody tested. If the tag is not pushed first, gh release create without --verify-tag makes a new tag from the newest commit on the default branch, which may not be the commit you meant to ship.
Summary
- An annotated tag (
git tag -a NAME -m "message") stores the name, the tagger, the date and the message, and points at a commit. Use it for releases. - Version numbers follow
MAJOR.MINOR.PATCH: breaking changes raise the major number, new features raise the minor number, and bug fixes raise the patch number. - List release tags with
git tag -l --sort=version:refname, because the default order putsv1.10.0beforev1.9.0. - Push tags explicitly with
git push origin TAGNAMEorgit push origin --tags. A normal push does not send them. - A GitHub release is a page attached to a tag. Create the tag and push it first, then publish the release with the website or
gh release create.