Skip to content
Log in

Theory

GitHub releases and versions: tags, SemVer, changelogs

What a git tag is, how to read a version number like v1.4.1 (SemVer in plain words), how GitHub releases and generated release notes work, how release notes differ from a changelog, how to check whether a fix is in a version, and why merged, released and live for every user can be three different moments.

~11 min read · 11 exercises · 250 points

Before you start

Which version is the fix in?

Back on CatChow's product team, support has a request about the checkout fix: they want to tell customers "fixed in version so-and-so", like app update notes. The honest answer for now is that #134 isn't in any version at all.

In this lesson you'll learn what tags and version numbers mean, how GitHub releases and release notes work, and why merged, released and live for every user can be three different moments.

A tag: a fixed name for one commit

A tag is a fixed name for one commit. A branch moves forward with every new commit; a tag stays exactly where it was put. That's why versions are tags: v1.3.0 must mean the same code tomorrow and in a year.

Creating one takes two commands:

git tag -a v1.3.0 -m "Cat treats bundle"
git push origin v1.3.0

-a makes an annotated tag, with its own author, date and message; tags aren't pushed with ordinary pushes, so the second command sends it to GitHub.

At CatChow, v1.3.0 marks 8be2f70, the cat treats bundle. main has moved five commits past it since, including the checkout fix #134 (d51c7e8). So #134 has been live since its deploy, but it isn't in any version yet: no tag points at a commit that contains it.

main and its only recent tagv1.3.0 marks 8be2f70. Everything merged after it, including #134, is on the live site but not in any version.
  1. 8be2f70Add cat treats bundlev1.3.0
  2. 6f2c8a1Raise free-delivery threshold to 1000 UAH (#133)
  3. a7d93e4Rewrite delivery page copy (#132)
  4. d51c7e8Fix checkout button after cart edits (#134)
  5. 3b8e1f6Add autumn sale banner (#136)
  6. 9c4a2d7Add loyalty points behind a flag (#155)main

Commit graph, oldest first: main and its only recent tag 1. commit 8be2f70 on main "Add cat treats bundle", tagged v1.3.0 2. commit 6f2c8a1 on main "Raise free-delivery threshold to 1000 UAH (#133)" 3. commit a7d93e4 on main "Rewrite delivery page copy (#132)" 4. commit d51c7e8 on main "Fix checkout button after cart edits (#134)" (highlighted) 5. commit 3b8e1f6 on main "Add autumn sale banner (#136)" 6. commit 9c4a2d7 on main "Add loyalty points behind a flag (#155)"

To check which tags contain a commit, run git tag --contains d51c7e8; an empty answer means “not released in any version yet”. A tag is like a bookmark pinned to one page while new pages keep being added at the end.

  • Do
    “#134 is live, but not in a version yet: v1.3.0 points at an earlier commit.”

    Separates live from released.

  • Don't
    “v1.3.0 is the newest tag, so it includes everything in main.”

    A tag stays on one commit; it doesn't follow main.

Never move a published tag

Technically, a tag can be deleted and recreated somewhere else; git even has git tag -f to force it. But once a tag is published, you never move it.

An orange ribbon bookmark is pinned firmly to one page of a notebook while a pencil keeps adding new pages at the far end.
A tag stays on its commit; never move a published one.

People and tools rely on v1.3.0 meaning the same code forever. Support has told customers “fixed in 1.3.0”. A partner may have built against it. Release notes, changelogs and compare links all point at it. Move the tag, and every one of those statements silently changes meaning. Anyone who already fetched the old tag keeps the old position, so two people can now look at “the same” version and see different code.

So when new work needs a version, make a new version. The checkout fix landed after v1.3.0? It goes into the next tag, v1.4.0, which will contain everything up to its own commit. If a release turns out to be broken, publish a fix as a new PATCH version, such as v1.4.1, rather than rewriting the old one.

Version numbers are cheap. Trust in what a version number means is not.

  • Do
    Tag v1.4.0 on the newest commit of main; leave v1.3.0 where it is.

    New work, new version.

  • Don't
    git tag -f v1.3.0 on today's main, then push it again.

    Every “fixed in 1.3.0” becomes untrue.

Reading a version number: SemVer in plain words

Semantic versioning, SemVer, writes a version as MAJOR.MINOR.PATCH, and each number tells you what kind of change happened:

  • PATCH goes up for fixes: 1.4.0 → 1.4.1.
  • MINOR goes up for new features that break nothing: 1.4.1 → 1.5.0.
  • MAJOR goes up for changes that break something people rely on: 1.5.0 → 2.0.0.

When a number goes up, the ones to its right reset to zero. After 1.3.0, a fix gives 1.3.1, a new feature 1.4.0, a breaking change 2.0.0.

In MAJOR.MINOR.PATCH, after 1.3.0 a fix gives 1.3.1, a new feature that breaks nothing gives 1.4.0, a breaking change gives 2.0.0.

The formal rules at semver.org are written for software that other programs depend on. A website has to decide what “breaking” means for itself. CatChow's rule: MAJOR only if old links, saved carts or partner integrations stop working.

Two more conventions. The v in v1.4.0 is just a common habit for tag names; the version itself is 1.4.0. And a suffix marks a pre-release, a test version before the real one: 1.5.0-beta.1 comes before 1.5.0.

  • Do
    After v1.4.0, a gift note field at checkout → v1.5.0.

    New feature: MINOR up, PATCH reset.

  • Don't
    After v1.4.0, a footer link fix → v1.5.1.

    A fix only raises PATCH: v1.4.1.

Comparing versions: numbers, not alphabet

Is 1.4.10 older than 1.4.9? Alphabetically it would be, because “1” comes before “9”. In SemVer it isn't.

Compare versions part by part, left to right, each part as a whole number: MAJOR first, then MINOR, then PATCH. Ten is more than nine, so 1.4.10 is newer than 1.4.9. Any 1.x is older than 2.0.0, however big its other numbers.

Pre-releases sit just before the version they lead to: 1.5.0-beta.1 is newer than every 1.4.x but older than 1.5.0 itself. So in order:

v1.4.1 → v1.4.9 → v1.4.10 → v1.5.0-beta.1 → v1.5.0 → v2.0.0

Don't trust list order blindly. GitHub's tags page doesn't always sort tags by version, and a plain alphabetical sort gets 1.4.10 wrong. In a terminal, git tag --sort=v:refname compares the numbers as numbers, though pre-release suffixes need extra configuration to land before their release. When in doubt, compare the parts yourself.

  • Do
    1.4.9 < 1.4.10: compare 9 and 10 as numbers.

    Each part is a whole number.

  • Don't
    “1.5.0-beta.1 is newer than 1.5.0, it has more characters.”

    A pre-release comes before its release.

Publishing a GitHub release

A GitHub release is a page built on a git tag: the version number, release notes written for people, and files to download. The tag marks the exact commit; the release explains what changed since the previous one. All releases live under Releases in the repository.

As of 2026 the steps are: Draft a new release, type a new tag such as v1.4.0, target main (GitHub creates the tag when you publish), pick the previous tag, and press Generate release notes. GitHub lists every pull request merged since that tag, with its title and author, plus a Full Changelog compare link, so good pull request titles pay off twice.

Below the text are the assets: GitHub always adds the source code as zip and tar.gz files. Then two checkboxes: Set as a pre-release for test versions, and Set as the latest release, which gives the newest full release the Latest badge. In a terminal: gh release create v1.4.0 --target main --generate-notes.

The two newest tags in catchow/website, Thursday evening
  • v1.4.0

    Latest · on 9c4a2d7 · release notes by Daniel and Sofia

  • v1.3.0

    on 8be2f70 · no release page, tag only

At CatChow, publishing a release deploys nothing: every change was already live when it was merged. The release names and describes what's already there.

  • Do
    gh release create v1.4.0 --target main --generate-notes

    Tag, target and generated notes in one go.

  • Don't
    “The release is published, so the banner is finally on the site.”

    At CatChow the deploy happened at merge time.

Release notes for humans, and a changelog

A changelog is a record of what changed in each version, written for the people who use it. Good release notes are that record for one version. The list GitHub generates is raw material, not the finished text: support won't understand “Add loyalty points behind a flag”, and must not tell customers about work that's still hidden.

What Generate release notes produced · shown above

So put a short “For customers and support” part on top, in plain words:

  • what customers will notice (free delivery now starts at 1000 UAH);
  • what was fixed (the checkout button no longer stays grey);
  • nothing about hidden work behind a flag that's off.
A developer hands a long technical scroll to a colleague, who turns it into a short card with simple pictures for a smiling support agent.

Keep the generated list below it for engineers.

Some teams keep a CHANGELOG.md file with sections like Added, Changed and Fixed; others use the release pages as their changelog. A small config file, .github/release.yml, can make the generator group pull requests by label, so everything labelled bug lands under “Fixes”.

Read every line with one question: could support forward this to a customer as it is?

  • Do
    “Fixed: the checkout button no longer stays grey after you change your cart.”

    Plain words support can forward.

  • Don't
    “* Add loyalty points behind a flag by @catchow-daniel in #155”

    Engineer-speak, and it leaks hidden work.

Merged, released, visible: three different moments

“Is it live?” hides three separate questions:

  • Merged: is the change in main (or develop)?
  • Released: is it in a named version, a tag with release notes?
  • Visible: can customers actually use it?
A cat-food parcel moves through three doors in a row: a workshop door, a door tied with a ribbon, and an open shop door with customers inside.
Before answering whether it's live, ask: merged, released, or visible?

How far apart they are depends on the team. At CatChow, merged means live within minutes, and a release comes later as a named snapshot: #134 was live from last Wednesday and became part of v1.4.0 on Thursday. Flags split released from visible even there: #155 is in v1.4.0, and no customer can see it while loyalty-points is off.

In an app team on git flow, all three are far apart. A feature merged into develop waits for the release branch, then for the release and store review. Even after approval, the stores can roll a version out gradually to a growing share of users, so “released” can still mean “only some users so far”. And nobody sees anything until they update.

So before answering “is it live?”, ask yourself which of the three you're being asked about, and answer that one.

  • Do
    “#155 is merged and released in v1.4.0, but hidden: the flag is off.”

    All three moments, named.

  • Don't
    “It's in v1.4.0, so every customer can use it.”

    A flag or a gradual rollout can still hide it.

The compare view and the hotfix version

When the loyalty menu link leaked on Friday, the fix followed GitHub flow: a small pull request into main, reviewed, checked on the preview, squash-merged as e5f81a3 and deployed. In GitHub flow a hotfix isn't a special branch type, just a small pull request that jumps the queue. Nothing else had been merged since v1.4.0, so it became a PATCH release: v1.4.1. Had a new feature slipped in too, it would honestly be v1.5.0.

To see exactly what one version added over another, use the compare view: v1.4.0...v1.4.1 on GitHub, or git log --oneline v1.4.0..v1.4.1 in a terminal. It shows the commits reachable from the second tag but not from the first. Here that's one commit, e5f81a3 (#158).

That's also why release notes list only what's new since the previous version. #134 isn't in the v1.4.1 notes, yet it is in v1.4.1: it was already in v1.4.0, and v1.4.1 sits later on the same line, so it contains everything v1.4.0 does.

  • Do
    git log --oneline v1.4.0..v1.4.1 → one commit, #158

    Exactly what the new version added.

  • Don't
    “#134 isn't in the v1.4.1 notes, so v1.4.1 doesn't have it.”

    Notes list what's new, not everything inside.

Is the fix in this version? How to check

“Is #134 in v1.4.1?” has a reliable answer, and three ways to get it:

  1. The commit page. Open the commit of #134, d51c7e8. As of 2026 GitHub lists the tags that contain it: v1.4.0 and v1.4.1, but not v1.3.0. In a terminal, git tag --contains d51c7e8 prints the same list.
  2. The history. Look at the history of main up to the commit tagged v1.4.1; if d51c7e8 is on that line, the version contains it.
  3. The release pages, in order. Find the first version whose notes mention the fix, then check that no later release reverts it. First appearance plus no revert since means it's in.
main with two new tagsEach tag marks one commit. v1.4.1 sits later on the same line, so it contains everything in v1.4.0 plus e5f81a3.
  1. 8be2f70Add cat treats bundlev1.3.0
  2. 6f2c8a1Raise free-delivery threshold to 1000 UAH (#133)
  3. a7d93e4Rewrite delivery page copy (#132)
  4. d51c7e8Fix checkout button after cart edits (#134)
  5. 3b8e1f6Add autumn sale banner (#136)
  6. 9c4a2d7Add loyalty points behind a flag (#155)v1.4.0
  7. 0d7b3c9Check flag before showing menu linkfix/loyalty-menu-link
  8. e5f81a3Hide loyalty menu link when flag is off (#158)mainv1.4.1

Commit graph, oldest first: main with two new tags 1. commit 8be2f70 on main "Add cat treats bundle", tagged v1.3.0 2. commit 6f2c8a1 on main "Raise free-delivery threshold to 1000 UAH (#133)" 3. commit a7d93e4 on main "Rewrite delivery page copy (#132)" 4. commit d51c7e8 on main "Fix checkout button after cart edits (#134)" (highlighted) 5. commit 3b8e1f6 on main "Add autumn sale banner (#136)" 6. commit 9c4a2d7 on main "Add loyalty points behind a flag (#155)", tagged v1.4.0 7. branch fix/loyalty-menu-link starts from commit 9c4a2d7 8. commit 0d7b3c9 on fix/loyalty-menu-link "Check flag before showing menu link" 9. commit e5f81a3: fix/loyalty-menu-link squash-merged into main "Hide loyalty menu link when flag is off (#158)", tagged v1.4.1

Two checks look tempting but don't answer the question. The v1.4.1 notes alone list only what's new since v1.4.0. And a Merged badge only says the change is in main, not which version holds it.

The answer support can forward: “fixed in 1.4.0, and in every version since”, as long as nothing reverted it. That “unless” matters: a later version can take a change back out.

  • Do
    git tag --contains d51c7e8 → v1.4.0, v1.4.1

    A direct, reliable answer.

  • Don't
    Search the v1.4.1 notes for #134 and answer “no”.

    Notes list only what's new in that version.

Ready to practise?

Answer the lesson's exercises and earn 250 points the first time you finish it.

Start the exercises