A Git tag is a name for a commit. A GitHub Release is a hosted record attached to a tag, adding a title, release notes, downloadable assets, and a notion of which release is current.
The tag is Git. The Release is GitHub. They are related but independent: deleting a Release leaves the tag, and deleting a tag leaves a Release pointing at something that no longer exists.
Tags versus Releases
Section titled “Tags versus Releases”| Git tag | GitHub Release | |
|---|---|---|
| Where it lives | Your repository, and every clone | GitHub’s database |
| Created by | git tag | GitHub UI, CLI or API |
Travels with git clone | Yes, with --tags | No |
| Can carry a description | Annotated tags can | Yes, full Markdown |
| Can carry binaries | No | Yes |
Visible to git | Yes | No |
The practical consequence: a Release cannot exist without a tag, but the tag can be created for you at publish time. And because Releases live outside Git, migrating a repository to another host moves your tags automatically and your Releases not at all.
What makes a Release worth publishing
Section titled “What makes a Release worth publishing”A Release with no notes and no assets is a tag with extra steps. The value is in three things.
Release notes tell a human whether to upgrade. That is the entire job. A reader wants to know: what changed, does anything break, and is there anything I must do.
Assets are the compiled artefacts — binaries, packages, archives — that people download without cloning or building. GitHub attaches source archives automatically, but those are the source, not a built product.
Latest semantics give you a stable URL that always resolves to the current version, which is what installation scripts and package managers consume.
Versioning
Section titled “Versioning”Most projects should use semantic versioning: MAJOR.MINOR.PATCH, where major
means breaking, minor means additive, patch means fixes.
Its value is not precision — it is that it turns “should I upgrade?” into a question a machine can answer. A tool can safely take patch releases automatically and flag major ones for a human.
Two conventions worth following: prefix tags with v (v1.4.2), because it makes tags obvious in a
ref list; and be honest about major versions. Shipping a breaking change as a minor release to avoid
“looking unstable” is how consumers learn to distrust your versioning and pin everything forever.
Not every project suits SemVer. Applications with no API, and tools consumed only by humans, are
often better served by date-based versioning (2026.08.1). What matters is that the scheme is
documented and consistent.
Creating a Release
Section titled “Creating a Release”From the terminal
Section titled “From the terminal”The most direct path, and the one worth learning:
git tag -a v1.4.0 -m "Release 1.4.0"git push origin v1.4.0
gh release create v1.4.0 \ --title "v1.4.0 — Retry handling" \ --notes-file CHANGELOG-1.4.0.mdWhat it doesCreates an annotated tag, pushes it, then publishes a Release from that tag with a title and notes read from a file.
Why we run itSeparating tag creation from the Release makes the two-layer structure visible. The tag is Git; the Release is GitHub, and it is attached to the tag that already exists.
Expected resultTag push output, then a URL for the published Release.
gh release create will also create the tag for you if it does not exist, which is convenient and
slightly obscures what is happening — useful once you understand the two layers.
Attaching built artefacts:
gh release create v1.4.0 \ --title "v1.4.0" \ --notes-file notes.md \ dist/tool-linux-amd64 \ dist/tool-darwin-arm64 \ dist/tool-windows-amd64.exeFiles listed after the flags are uploaded as assets. You can also upload later:
gh release upload v1.4.0 dist/checksums.txtGenerated notes
Section titled “Generated notes”GitHub can draft notes from the merged pull requests since the previous release:
gh release create v1.4.0 --generate-notesThis produces a list of merged pull requests and new contributors. It is a genuinely good starting point and a poor finished product — it describes what merged, not what changed for the user.
The output is shaped by .github/release.yml, which groups pull requests by label and excludes
noise:
changelog: exclude: labels: [ignore-for-release, dependencies] categories: - title: Breaking changes labels: [breaking] - title: Features labels: [feature] - title: Bug fixes labels: [bug] - title: Other changes labels: ["*"]Configuring this well makes generated notes close to publishable, and it rewards the labelling discipline from the Issues lesson.
Pre-releases, drafts and latest
Section titled “Pre-releases, drafts and latest”Draft releases are not visible publicly and have no tag until published. Use them to accumulate notes and assets during a release process.
Pre-release marks a version as not production-ready — v2.0.0-rc.1. Pre-releases are visible
but excluded from “latest”.
Latest is the one that matters for automation. By default GitHub treats the most recent non-prerelease, non-draft release as latest, and exposes it at a stable URL:
https://github.com/OWNER/REPO/releases/latestgh release view --json tagName,publishedAt --jq .tagNamegh api repos/OWNER/REPO/releases/latest --jq '.tag_name'This is what an install script should consume. Hardcoding a version in documentation guarantees the documentation is wrong after the next release.
Automating releases
Section titled “Automating releases”Publishing by hand is fine until it is not: the moment a release requires building artefacts for three platforms, it should be a pipeline.
The shape is consistent regardless of CI system:
- A tag push triggers the workflow.
- The workflow builds artefacts for each target.
- It generates or reads release notes.
- It creates the Release and uploads the assets.
- It publishes checksums, and ideally signs or attests the artefacts.
In GitHub Actions this is a workflow triggered on tag push, using the built-in token to create the release. The details belong to a future pillar; what matters here is the principle: the tag is the trigger, and the artefacts are built from exactly the tagged commit — never from a working directory that might contain something else.
The CLI is usable inside CI in the same way it is locally, provided a token is available:
gh release create "$GITHUB_REF_NAME" \ --generate-notes \ --verify-tag \ dist/*--verify-tag refuses to create the Release if the tag does not already exist, which prevents a
mistyped tag name silently creating a new one.
Secure artefacts
Section titled “Secure artefacts”If people download and execute what you publish, you are part of their supply chain.
Checksums let a downloader verify integrity. Publish a checksums.txt alongside the assets.
Signing lets them verify authorship, not just integrity. Signed tags cover the source; signing the artefacts covers the binaries.
Attestations record how an artefact was built and by which workflow, which is stronger than a signature alone because it binds the artefact to a build process rather than to a key. The current CLI exposes verification for these directly:
gh release verify v1.4.0gh release verify-asset v1.4.0 tool-linux-amd64Reading Releases programmatically
Section titled “Reading Releases programmatically”gh release list --limit 10gh release view v1.4.0 --json tagName,publishedAt,assets --jq '.assets[].name'gh release download v1.4.0 --pattern '*linux-amd64'gh api repos/OWNER/REPO/releases --paginate --jq '.[] | select(.prerelease | not) | .tag_name'gh release download --pattern is the one to remember — it is how an install script fetches the
right asset for a platform without scraping a page.
Writing release notes people read
Section titled “Writing release notes people read”Generated notes list merged pull requests. Useful notes answer three questions, and the gap between them is about four sentences of human writing.
Do I need to upgrade? Security fix, data-loss bug, or a feature you want — say which, first.
Will it break anything? The most important question, and the one most often buried. Breaking changes belong at the top, not in a list of thirty entries.
What must I do? Migration steps, config changes, deprecations to act on.
A structure that works:
## Highlights
Retries are now enabled by default for idempotent requests. Transient connectionfailures that previously surfaced as errors are handled transparently.
## Breaking changes
- `Client(timeout=...)` now takes seconds rather than milliseconds. Existing callers passing `5000` should pass `5`.
## Upgrading
No action required unless you set `timeout` explicitly.
## What's changed
<!-- generated list goes here -->**Full changelog**: https://github.com/OWNER/REPO/compare/v1.3.0...v1.4.0The first three sections are written by a person and take five minutes. The fourth is generated. That ratio — a short human summary above a long generated list — is what makes notes worth reading.
Release checklists
Section titled “Release checklists”For anything people depend on, a repeatable sequence prevents the small omissions that cause most release problems.
Before tagging:
- The default branch is green.
- Version numbers in code, package metadata and documentation agree with the tag you are about to create.
- Breaking changes are identified and the version reflects them.
- The changelog is written.
- Dependencies are current enough not to be an immediate follow-up.
After publishing:
- Installation instructions still work — actually run them, from the published artefact.
- Checksums are attached.
- The
latestURL resolves to the new release. - Anything downstream that pins a version knows it exists.
Step 1 of the second list catches more real problems than everything else combined. Testing the built artefact from a clean environment is a different test from testing the source tree you built it in.
Yanking a bad release
Section titled “Yanking a bad release”Sometimes a release ships broken. There is no unpublish that undoes distribution — anyone who already downloaded it has it — so the options are narrower than people expect.
Mark it as a pre-release. Removes it from latest, so installers stop selecting it, while leaving
it available to anyone who explicitly wants that version.
gh release edit v1.4.0 --prereleasePublish a fix quickly. Usually the right answer. v1.4.1 superseding v1.4.0 within the hour is
better than deliberating about the broken one.
Delete it only when the artefacts themselves are dangerous — a leaked credential baked into a binary, say. Deleting breaks anyone pinned to that version, and the Git tag survives unless you delete that separately.
gh release delete v1.4.0 --cleanup-tagReleases and downstream consumers
Section titled “Releases and downstream consumers”A release is an interface, and the people consuming it are making assumptions worth honouring.
Do not move a tag. Re-pointing v1.4.0 at a different commit after publication means two people
with the same version have different code. This is the tag equivalent of a force push, and it breaks
the one guarantee a version number carries.
Keep old releases. Someone is pinned to v0.9. Deleting it to tidy up breaks their build for no
benefit.
Be predictable about cadence if people schedule around you. Not necessarily frequent — predictable.
Publish checksums and signatures. If people execute what you publish, you are part of their supply chain whether or not you think of yourself that way.
Common mistakes
Section titled “Common mistakes”Publishing a Release with no notes. A tag would have been cheaper.
Generated notes shipped unedited. They list merged pull requests, not user-visible change.
Breaking changes in a minor version. Teaches consumers to distrust your versioning.
Deleting a tag that a Release points at. Leaves the Release broken.
Hardcoding versions in install instructions. Use the latest URL.
Uploading artefacts built from a working directory. Build from the tagged commit, in CI.
Marking a release candidate as latest. Points every automatic installer at unstable code.
Assuming Releases migrate with the repository. Tags do; Releases need the API.
Exercise
Section titled “Exercise”- In your practice repository, create an annotated tag
v0.1.0and push it. - Publish a Release from it with
gh release create v0.1.0 --generate-notes. - Build any small file — even a text file — and attach it with
gh release upload. - Create a second release
v0.2.0-rc.1marked--prerelease. - Run
gh api repos/OWNER/REPO/releases/latest --jq '.tag_name'and confirm it returnsv0.1.0, not the release candidate. - Download your asset with
gh release download v0.1.0.
Step 5 is the important one: it demonstrates that “latest” is a computed property with rules, not simply the most recent thing you published.
What you learned
Section titled “What you learned”- A Release is a hosted record attached to a Git tag; the tag is Git and the Release is GitHub.
- Releases do not travel with
git cloneand do not migrate with the repository. - Annotated tags are the right choice for anything published, and can be signed.
- Generated notes are a good draft and a poor final product;
.github/release.ymlshapes them. - “Latest” excludes drafts and pre-releases, and its stable URL is what automation should consume.
- Artefacts should be built from the tagged commit in CI, with checksums and ideally attestations.
A minimal release process
Section titled “A minimal release process”For a project that currently just pushes tags, this is the smallest process worth having:
- Confirm the default branch is green.
- Create an annotated tag:
git tag -a v1.4.0 -m "Release 1.4.0". - Push it:
git push origin v1.4.0. - Publish with generated notes:
gh release create v1.4.0 --generate-notes --verify-tag. - Edit the notes — add two or three sentences saying what changed and whether anything breaks.
- Attach any built artefacts, plus a checksums file.
- Verify the install instructions work, from the published artefact rather than your source tree.
Steps 5 and 7 are the ones that distinguish a useful release from a tag with extra steps. The generated notes list merged pull requests; only a human can say what that means for someone deciding whether to upgrade. And testing the published artefact catches the packaging mistakes that testing your working directory never will.
--verify-tag refuses to create the release if the tag does not already exist, which prevents a
mistyped tag name silently creating a new one.
Releases and Discussions
Section titled “Releases and Discussions”A Release can create a Discussion for itself, which is a small feature with a disproportionate effect on how release feedback works.
gh release create v1.4.0 \ --generate-notes \ --discussion-category "Announcements"The Release is published and a Discussion thread is created in the named category, linked to it.
Why this is worth doing on any project with users: without it, feedback on a release has nowhere to go. People file Issues saying “1.4.0 broke my setup” before establishing whether it is a bug, or they comment on unrelated threads, or they say nothing and quietly stop upgrading.
A release thread gives that conversation a home, keeps it out of the Issue tracker until it becomes work, and produces a searchable record per version — which is genuinely useful when someone asks six months later whether anyone else saw a problem with 1.4.0.
The category must exist and must be one that permits the creation, so an Announcements category —
where only maintainers can start threads but anyone can reply — is the natural fit.
gh release view v1.4.0 --json tagName,url --jq .urlThe Discussion appears linked from the Release page, so readers arriving at the notes find the conversation without looking for it.