A maintainer can publish v2.4.0 at 09:00, discover at 09:15 that the Windows binary contains a debug build, and still have every download link pointing at the bad file. Deleting or replacing the file may fix the immediate problem, but it also leaves users, package maintainers, and support staff asking which v2.4.0 they actually installed.
The preventable mistake is treating a Git tag, a GitHub release page, and a downloadable artifact as if they were one object. They are connected, but they answer different questions. A reliable release process makes each connection explicit: which commit was approved, what users should download, what changed, and how someone can verify the result days later.
A tag marks source history; a release publishes a delivery
A Git tag is a named reference to a point in repository history. If v1.8.0 points to commit 8f4c2ab, the tag tells Git which source revision represents that version. It is useful to developers checking out code, CI systems building from a known revision, and maintainers comparing one version with another.
A GitHub release is a GitHub object based on a tag. GitHub describes releases as deployable software iterations packaged for a wider audience to download and use. A release adds a title, release notes, publication state, and downloadable assets. GitHub also automatically provides a source ZIP download for the tagged source.
| Question | Git tag | GitHub release |
|---|---|---|
| What source revision is this? | Answers it directly by naming a commit. | Answers it through its associated tag. |
| What should an end user download? | Usually nothing beyond source code. | Can provide platform-specific binaries, installers, checksums, and documentation. |
| What changed for users? | Does not contain a user-facing changelog by itself. | Contains release notes and links to issue or pull request context. |
| When was it announced? | The tag date and release date can differ. | Has its own release publication date. |
The operational rule is simple: create a tag when you need an immutable-looking name for a source point; create a GitHub release when people need a supported download or a public delivery record. For downloadable software, do both.
Use one tag as the release boundary, not a suggestion
Consider a command-line tool named acme-sync. Its team has merged fixes, built binaries for macOS, Linux, and Windows, and wants to ship version v1.8.0. The critical decision is made before the release page exists: select one commit that every artifact will represent.
git switch main
git pull --ff-only origin main
git status
git log -1 --oneline
git tag -a v1.8.0 -m "acme-sync 1.8.0"
git push origin v1.8.0
The git status check matters because a local build can accidentally include uncommitted changes. The git log -1 check gives the reviewer a visible commit summary before the tag is created. An annotated tag records a tag message and tagger information, making it a better release marker than an unannotated convenience tag.
Do not let the release workflow silently choose a moving branch such as main. “Build from main” is ambiguous the moment another merge lands. “Build from tag v1.8.0” is testable. Your CI job should check out the tag, produce the files, and report the commit SHA used in its build log.
This distinction becomes expensive after a hotfix. If version v1.8.0 was built from one commit but the release notes describe a later commit, support cannot reproduce a customer’s behavior from the version label alone.
Write release notes for the person deciding whether to upgrade
Release notes are not a dump of every merged pull request. They are the upgrade contract for a user who sees v1.8.0 and needs to decide whether to install it. Start with behavior and risk, then link to deeper implementation detail where appropriate.
For a CLI release, a useful notes file might look like this:
## Highlights
- Added `acme-sync doctor` to diagnose missing credentials and configuration.
- Reduced repeated API retries when a project is archived.
## Breaking changes
- `--token-file` now requires an absolute path.
- The deprecated `ACME_SYNC_KEY` environment variable has been removed.
## Fixes
- Fixed Windows paths containing spaces in `acme-sync export`.
## Upgrade
Run `acme-sync --version` after installation. Existing configuration files remain
compatible with this release.
Keep a separate Breaking changes heading even when it has only one bullet. A user scanning a release page should not have to infer incompatibility from a vague “configuration cleanup” note. If there are no breaking changes, say so; the absence is useful information.
Include issue or pull request links when they help maintainers trace context, but do not make ticket IDs the main content. “Fixed Windows paths containing spaces” is actionable. “Fix #482” is only actionable for someone already following issue 482.
Choose assets as if users will never clone the repository
A source archive is useful for developers, but a release asset should solve a real installation path. For acme-sync, the team may publish three archives: acme-sync_1.8.0_darwin_arm64.tar.gz, acme-sync_1.8.0_linux_amd64.tar.gz, and acme-sync_1.8.0_windows_amd64.zip.
The filename must contain enough information to select the right file without opening it. At minimum, use project name, version, operating system, and architecture. Avoid a file named release.zip: browser downloads make it easy to accumulate five files with that name and no way to distinguish them.
- Publish the executable or installer intended for each supported platform.
- Publish a checksum file, such as
SHA256SUMS, alongside the binaries. - Include a short install or extraction instruction in the release notes if no installer exists.
- State unsupported combinations plainly, such as “Linux ARM64 is not published for this release.”
Build artifacts from the checked-out tag, not from an arbitrary CI workspace. The second-order benefit is incident response: when a user reports a defect in acme-sync_1.8.0_linux_amd64.tar.gz, you can rebuild from v1.8.0 and investigate the same source state.
Make checksum verification part of publishing, not an optional appendix
A release asset has two separate verification problems. First, did the uploaded file survive download without corruption? Second, does that file correspond to the release you intended to ship? A SHA-256 checksum directly helps with the first problem and gives users a stable value to compare for the second.
shasum -a 256 dist/acme-sync_1.8.0_darwin_arm64.tar.gz
shasum -a 256 dist/acme-sync_1.8.0_linux_amd64.tar.gz
shasum -a 256 dist/acme-sync_1.8.0_windows_amd64.zip
Save those lines in SHA256SUMS and upload that file as a release asset. Then test a downloaded file rather than trusting the build directory:
shasum -a 256 -c SHA256SUMS
The overlooked requirement is independence. If a release page has one binary and one checksum file created by the same pipeline, the checksum catches accidental changes and transfer errors, but it is not a complete defense against a compromised build environment. Teams with higher assurance requirements should add their own signing and provenance controls. Even without those controls, checksums remain worthwhile because they turn “the download seems wrong” into a specific, repeatable check.
Also verify the tagged source locally. If your project signs annotated tags, use git tag -v v1.8.0 in a checkout that has the relevant verification key configured.
Create the release only after assets and notes are ready
GitHub lets you view Releases and Tags separately in the repository interface. That separation reflects a useful workflow: first establish the tag, build from it, review the assets and notes, then publish the release that points to it.
For a prepared tag and notes file, GitHub CLI keeps the publish command auditable in shell history or CI logs:
gh release create v1.8.0 \
dist/acme-sync_1.8.0_darwin_arm64.tar.gz \
dist/acme-sync_1.8.0_linux_amd64.tar.gz \
dist/acme-sync_1.8.0_windows_amd64.zip \
dist/SHA256SUMS \
--title "acme-sync 1.8.0" \
--notes-file RELEASE_NOTES.md
The important procedural choice is not the command itself. It is refusing to publish until the inputs are ready: the tag exists remotely, the build was made from that tag, the checksum file covers every binary, and a human has read the notes from an upgrader’s perspective.
If your team needs a review window, keep the release unpublished while assets and notes are checked. Do not announce a download URL in a chat message before you have completed the post-publication verification. A copied URL is difficult to retract, and users rarely notice follow-up corrections.
Inspect the published result with gh release view
A successful publish command proves that GitHub accepted a request. It does not prove that the visible release has the right tag, state, notes, or asset list. Inspect the release immediately with gh release view, which GitHub documents as a way to view repository releases through the GitHub CLI.
gh release view v1.8.0
For a check suitable for scripts and CI logs, request the fields you care about:
gh release view v1.8.0 \
--json tagName,targetCommitish,publishedAt,isDraft,isPrerelease,assets
Review four things in that JSON output. Confirm tagName is v1.8.0; confirm the release is not still a draft; confirm it was not accidentally marked as a prerelease; and compare every asset name against the build manifest. If an asset is missing, the release is incomplete even if its web page looks polished.
Then inspect the source side from a fresh checkout or a clean CI job:
git fetch --tags origin
git show --no-patch --format=fuller v1.8.0
git rev-parse v1.8.0^{commit}
Record the resulting commit SHA in the release pipeline log. That one value links the public release, the Git tag, and the exact source revision used to produce the binaries.
Protect the release boundary instead of relying on memory
Tags are Git references, so their safety is a repository-control problem rather than a release-notes problem. GitHub’s tag protection approach has been moving toward repository rulesets, and a practical rule is to restrict who can create, update, or delete tags matching your release pattern, such as v*.
The goal is not bureaucracy. It is preventing a version label from being repointed after artifacts have been published. If v1.8.0 changes from commit A to commit B, the release page may still look normal while source checks, rebuilds, and downstream automation disagree about what version 1.8.0 means.
| Control | Failure it reduces | Practical owner |
|---|---|---|
Ruleset for v* tags |
Accidental or unauthorized tag creation, update, or deletion. | Repository administrator |
| CI builds from the tag | Binaries built from a later branch commit. | Release workflow maintainer |
| Checksum asset review | Missing, stale, or mismatched downloadable files. | Release manager |
gh release view after publishing |
Incorrect release state, tag, or asset inventory. | Person publishing the release |
For a small project, one person may perform all four roles. The control still matters because a written process catches the errors that experience does not: wrong architecture, wrong tag, stale notes, and a release left as a draft.
A repeatable release checklist for every downloadable version
Use this checklist for a patch release as well as a major release. A routine v1.8.1 can cause the same distribution confusion as v2.0.0; the number of changed lines is not a reason to skip verification.
- Choose the approved commit and confirm the working tree is clean with
git status. - Create and push an annotated version tag, such as
v1.8.0. - Check out that tag in CI and build every supported binary or installer.
- Run the project’s tests against the tagged source and execute a basic version check such as
acme-sync --version. - Name assets with product, version, operating system, and architecture.
- Generate
SHA256SUMSfor every downloadable binary and verify it against downloaded files. - Write release notes with highlights, breaking changes, fixes, and upgrade guidance.
- Create the GitHub release with the tag, notes, binaries, and checksum file.
- Run
gh release view v1.8.0 --json tagName,targetCommitish,publishedAt,isDraft,isPrerelease,assets. - Open one asset URL as a user would, download it, verify its checksum, and test the installed program.
Put this list in the repository as docs/release-checklist.md or beside the release workflow. A checklist stored only in a senior maintainer’s memory disappears precisely when somebody else has to ship an urgent fix.
Set up the next release this week
Start with one release rehearsal rather than trying to automate everything at once. Pick the latest existing tag, run gh release view against it, and compare the displayed assets with the files you believe users should install. That audit often exposes unclear naming, missing checksums, or notes that do not mention a breaking change.
Then make three small repository changes:
- Add a release notes template containing
Highlights,Breaking changes,Fixes, andUpgrade. - Add checksum generation and verification to the CI job that builds release artifacts.
- Create a repository ruleset for your chosen version-tag pattern, such as
v*, and document who is allowed to publish.
Finally, make gh release view the last command in your release runbook, not an investigative command used after a complaint. A tag gives your team a stable source reference. A GitHub release gives users a supported delivery. The checklist is what keeps those two promises attached to the same software.