A repository can have a perfectly valid v2.4.0 Git tag and still ship a broken GitHub Release: the tag may point at the intended commit while the uploaded binary was built from an uncommitted local workspace. That mismatch is expensive because users download the Release asset, not your reassuring tag name.

The useful mental model is simple: Git records source history; GitHub Releases publish a specific version of that history for people to consume. Treating those as the same object works until you add release notes, downloadable archives, platform binaries, prereleases, or automation.

1. A tag is a Git reference; a Release is a published package record

A Git tag is a named pointer to a point in repository history. For example, v1.8.0 can identify commit 8c4f6d2. The tag lives in Git, can be fetched with git fetch --tags, and can exist without GitHub at all.

A GitHub Release is GitHub metadata built around a tag. GitHub describes releases as deployable software iterations based on Git tags. A Release can include a title, long-form notes, a publication date, draft or prerelease status, and attached files such as mytool_1.8.0_linux_amd64.tar.gz.

Question Git tag GitHub Release
What does it identify? A commit or other Git object A publishable version associated with a tag
Created with git tag gh release create or the GitHub web interface
Can it contain release notes? Only an annotation message, if annotated Yes, with a title and body
Can users download uploaded binaries? No Yes, through release assets
Can its date differ from the other? Yes Yes; tag and Release can be created at different times

The date distinction matters during incident response. If v1.8.0 was tagged on Monday but the Release was published on Thursday after validation, “when did we release it?” has two legitimate answers. Record both when your deployment or compliance process needs a timeline.

2. Use a decision rule instead of creating Releases for every tag

Not every tag deserves a GitHub Release. Internal teams often tag every deployable commit, every weekly build, or every environment promotion. Publishing all of them as Releases turns the Releases page into a noisy stream where users cannot tell a stable version from an implementation checkpoint.

Use this rule: create a GitHub Release when someone outside the delivery pipeline needs a stable, human-readable artifact of that version. “Outside” can mean an open-source user downloading a CLI, another team consuming a package, a support engineer investigating a customer version, or an auditor reviewing what was shipped.

  • Create only a tag for CI checkpoints, temporary experiments, and deployment markers that no user should download.
  • Create a tag plus a draft Release when the version is selected but notes, binaries, or approval are incomplete.
  • Create a published Release when a user can safely install, download, or depend on the version.
  • Create a prerelease for versions such as v2.0.0-rc.1 that testers should use but most users should not treat as the default stable line.

The tradeoff people skip is maintenance: every published Release creates a support promise. If you attach a macOS archive, users reasonably expect its checksum, provenance, upgrade path, and known limitations to be discoverable later. Do not attach a file merely because the upload command makes it easy.

3. Start with one explicit release target commit

A repeatable workflow begins by choosing the commit before choosing the version label. In this example, assume main contains the approved changes and you want to publish v1.8.0. First update local references and capture the exact commit SHA.

git fetch origin --tags
git switch main
git pull --ff-only origin main
git status --short
git rev-parse HEAD

git status --short should print nothing before you build release artifacts locally. An empty working tree prevents a classic failure: source code at commit 8c4f6d2, but an archive compiled with an extra local fix that no one else can reproduce.

Next, verify the commit is actually the commit you intend to publish. A short log gives reviewers enough context without asking them to parse the entire graph.

git log --oneline -5
git show --stat --oneline HEAD

For a release branch workflow, replace main with a branch such as release/1.8. The important part is not branch naming. It is recording one immutable SHA and making the tag, build, notes, and release all refer back to that SHA.

4. Create and push an annotated tag before making the Release

Use an annotated tag for a version people will consume. Unlike a lightweight tag, an annotated tag carries a tagger identity, date, and message. That makes git show v1.8.0 more useful six months later when someone asks why a version exists.

VERSION=v1.8.0
COMMIT=$(git rev-parse HEAD)

git tag -a "$VERSION" "$COMMIT" \
  -m "Release $VERSION"
git show "$VERSION"
git push origin "$VERSION"

Inspect the output of git show before pushing. Confirm the tag name, the commit displayed below it, and the annotation. After pushing, compare the remote ref directly rather than assuming a successful push means the correct SHA was used.

git ls-remote --tags origin "refs/tags/$VERSION"
git rev-list -n 1 "$VERSION"

For an annotated tag, git ls-remote may show both the tag object and a second line ending in ^{} , which is the dereferenced commit. The practical verification is that git rev-list -n 1 "$VERSION" equals the SHA captured in $COMMIT.

If your project signs tags, verify the signature before publication:

git verify-tag "$VERSION"

A signature check is useful only when your team has established which signing keys it trusts. It proves that Git can validate the tag against a key available to the verifier; it does not, by itself, prove that every uploaded release asset was built from that tag.

5. Build artifacts from the tag, not from your current directory

The safest release build starts by checking out the tag you just pushed. This forces the build input to be the same source revision users can inspect with Git. If your repository has a build command, run it after checkout and place outputs in a clean directory such as dist/.

git switch --detach "$VERSION"
rm -rf dist
mkdir dist

# Replace this with your project's deterministic build command.
# Example: make release
make release

git status --short
find dist -maxdepth 1 -type f -print

The final git status --short should still be empty unless your build intentionally generates tracked files. If it is not empty, stop and determine whether the build changed source, lockfiles, generated code, or configuration. Publishing after “just one generated change” is how the tag-to-binary relationship becomes ambiguous.

Create checksums beside the assets. A checksum is not a signature and does not establish provenance, but it gives users and your release reviewer a concrete way to detect a truncated or substituted download.

cd dist
sha256sum * > SHA256SUMS
cat SHA256SUMS
cd ..

On systems where sha256sum is unavailable, use the platform’s SHA-256 utility and preserve the same filename convention. The key workflow property is that the checksum file is generated after the final artifacts exist and is uploaded with those exact artifacts.

6. Create the GitHub Release with gh CLI and an existing tag

Because the tag already exists remotely, use --verify-tag. It makes the command fail rather than silently creating a tag you did not mean to create. This is a small guardrail with a large payoff: the release command cannot accidentally point a new version label at whatever branch happens to be selected.

gh auth status

gh release create "$VERSION" \
  --verify-tag \
  --title "$VERSION" \
  --generate-notes \
  dist/*

--generate-notes asks GitHub to create release notes from the repository’s changes. Read the generated result before treating it as final documentation. Generated notes can summarize merged pull requests well, but they cannot know migration steps, changed environment variables, or an operational rollback instruction unless you add those details.

If publication requires approval, create a draft first:

gh release create "$VERSION" \
  --verify-tag \
  --draft \
  --title "$VERSION" \
  --generate-notes \
  dist/*

A draft is useful when artifact review and publication are separate responsibilities. It is not a substitute for verifying the assets: a draft can still contain the wrong files. For a release candidate, add --prerelease and use an explicit tag such as v2.0.0-rc.1.

7. Inspect the published Release as structured data

A browser page is convenient, but structured output is better for a repeatable checklist and CI logs. Use gh release view to retrieve the Release by tag and print its key fields.

gh release view "$VERSION" \
  --json tagName,targetCommitish,isDraft,isPrerelease,publishedAt,url \
  --jq '.'

gh release view "$VERSION"

Check four things in the JSON output: tagName is the intended version, isDraft and isPrerelease match the decision you made, publishedAt is plausible, and url leads to the expected repository Release page.

Then inspect the uploaded assets in the human-readable output. You should see each platform artifact and SHA256SUMS. GitHub automatically supplies source archive download links for a Release, but those archives are not a replacement for your built artifacts or their checksums.

Do not use “Latest” as the main verification criterion. “Latest” is a presentation label users may rely on, while your release process needs stronger assertions: exact tag, exact target commit, exact filenames, and exact hashes.

8. Verify the tag, Release, and downloaded asset form one chain

Verification should answer three separate questions. Did Git identify the intended source? Did GitHub publish a Release for that tag? Did the asset you downloaded match the hash generated from the release build?

  1. Verify the local tag resolves to the recorded source commit.
  2. Verify the remote repository advertises that tag.
  3. Verify GitHub has a Release with the same tag name.
  4. Download the checksum manifest and asset from the Release.
  5. Run SHA-256 verification against the downloaded files.
git rev-list -n 1 "$VERSION"
git ls-remote --tags origin "refs/tags/$VERSION"

gh release view "$VERSION" \
  --json tagName,targetCommitish,url

mkdir -p /tmp/release-check
gh release download "$VERSION" \
  --dir /tmp/release-check

cd /tmp/release-check
sha256sum -c SHA256SUMS

The gh release download command downloads assets from the named Release. When sha256sum -c SHA256SUMS reports each file as OK, you have checked the downloaded files against the manifest you published.

There is an important boundary here: checksums verify file equality, not that the build itself was trustworthy. The practical next step for mature projects is to make the build run in CI from the pushed tag, then upload only CI-produced artifacts. That removes an individual laptop from the trusted release path.

9. Put this release checklist into a script this week

The workflow becomes dependable when it is boring enough to run the same way for v1.8.0, v1.8.1, and the next major version. Put the commands in scripts/release.sh, require a clean tree, and make the script accept one version argument rather than asking a maintainer to edit commands under time pressure.

  • Choose the source SHA and inspect the last five commits.
  • Require an empty git status --short.
  • Create and push an annotated tag.
  • Build from a detached checkout of that tag.
  • Generate and upload SHA256SUMS with every distributable file.
  • Create the Release with gh release create --verify-tag.
  • Inspect it with gh release view.
  • Download the assets and run sha256sum -c SHA256SUMS.

Assign ownership for the one decision automation cannot make: whether the release is stable, prerelease, or draft. Everything else can be made mechanical. When a support request arrives with “I installed v1.8.0,” your team should be able to trace that sentence from GitHub Release, to tag, to commit, to checksum, without relying on anyone’s memory.