A release can look complete on GitHub while its attached binary was built from a commit nobody tested. The avoidable failure is not choosing the wrong button in the Releases UI; it is publishing user-facing artifacts before proving that the exact tagged commit passed your release checks.
The useful division of labor is simple: an annotated Git tag is the repository-level statement of what code version v1.4.0 means, while a GitHub release is the distribution page where humans find notes, source archives, and installable files. Treating them as interchangeable creates awkward release processes: either users get a bare tag with no instructions, or a polished release page becomes the only record of what was shipped.
Start with the failure boundary, not the GitHub interface
Imagine a TypeScript CLI named logship. The team merges the final fix for version 1.4.0, builds binaries on one engineer’s laptop, uploads them to a GitHub release, and then creates the tag. A later investigation finds that the uploaded Windows binary came from the previous commit, before the final fix.
GitHub displayed v1.4.0, release notes, and assets, so the release looked legitimate. But the three important identities did not match:
- the commit selected as version
v1.4.0, - the commit tested by continuous integration, and
- the commit used to build the downloadable assets.
A sound workflow makes those identities converge before publication. First create an annotated tag on a deliberate commit. Next let CI check out that tag, test it, and build from it. Only after those checks succeed should automation create the GitHub release and attach assets from that same CI run.
This ordering is more important than whether releases are drafted manually or generated by a tool. It also keeps your Git history useful outside GitHub: a clone of the repository still contains the version marker and its release message.
These are different objects with different jobs
An annotated tag is a Git object that records a tag name, a target object, tagger information, a date, and a message. In a release workflow, v1.4.0 normally points at one commit. A lightweight tag is only a named reference to a commit, which is why annotated tags are the better default for software releases: they carry release context in Git itself.
A GitHub release is platform metadata associated with a tag. It can provide a title, Markdown release notes, a pre-release setting, a “latest” designation, and downloadable assets. GitHub also provides source ZIP and tarball downloads for releases. None of that turns the GitHub release record into a substitute for the tag.
| Question | Annotated Git tag | GitHub release |
|---|---|---|
| What does it identify? | A specific Git object, usually a release commit. | A user-facing publication associated with a tag. |
| Where does it live? | In the Git repository and its remotes. | In GitHub repository metadata. |
| What should it contain? | SemVer name and a concise release message. | Upgrade notes, changes, links, binaries, checksums, and known limitations. |
| What should CI trust? | The tagged commit and the tag type. | The release only as the final publication step. |
The second-order consequence is ownership. Developers and automation need the tag for reproducible source selection. Users, support teams, and package consumers need the release page for communication and downloads. Trying to make one object serve both audiences produces either unhelpful release notes in Git or weak provenance on the release page.
Make the version contract explicit before tagging
For logship, adopt the externally visible version format vMAJOR.MINOR.PATCH: v1.4.0, not release-final-new or an unprefixed 1.4.0. The v prefix gives Git references, GitHub releases, documentation, and command-line output one consistent spelling.
SemVer is not merely a filename convention. Before the tag exists, the release owner should be able to answer why this is a patch, minor, or major version. A patch such as v1.4.1 should be a compatible bug fix; a minor such as v1.5.0 adds compatible functionality; a major such as v2.0.0 communicates a breaking public contract.
Use one source of truth for the version. In a Node project that might be package.json; in a Go binary it might be injected during the build; in a Python package it may be package metadata. The tag should match that value exactly after applying the v prefix.
- Repository version:
1.4.0 - Git tag:
v1.4.0 - GitHub release title:
logship v1.4.0 - Binary names:
logship_1.4.0_linux_amd64.tar.gz - Release notes heading:
logship 1.4.0
This small naming rule prevents a common support problem: someone reports a problem with “1.4,” while the repository has v1.4.0, the package registry has 1.4.0, and a binary is named logship-v1.4-linux. Release automation becomes much easier when it does not need to guess which string is canonical.
Create an annotated tag on the reviewed commit
Tag the commit that has completed code review and passed the ordinary branch CI checks. Do not tag a local working tree with uncommitted changes, and do not use a tag as a request for CI to discover whether the code works. The tag is the proposed release identity; the release pipeline validates that identity.
From a clean local checkout, a maintainer can create and inspect an annotated tag like this:
git switch main git pull --ff-only origin main git log -1 --oneline git tag -a v1.4.0 -m "logship v1.4.0" git show --no-patch v1.4.0 git push origin v1.4.0
The -a matters. It creates an annotated tag rather than a lightweight reference. The final inspection should show that the tag resolves to the intended commit. If your organization signs release tags, use its approved signing process and verify signatures in CI; a tag message by itself is release metadata, not proof of who created it.
Also decide who may create release tags. The research around tag protection highlights a practical risk: granting repository write access can be broader than granting authority to ship production software. Review your repository’s current tag rules and automation credentials, then ensure that the people or workflow able to push v* tags are the people allowed to begin a release.
Validate the tag in CI before calling it a release
Configure a workflow to run when tags matching your release convention are pushed. The pipeline must check out the tag-triggered revision, run the same tests that protect the main branch, and add release-specific checks. For logship, those checks can include version equality, tests, a production build, and smoke-testing the generated binary.
The key test is not “did a workflow run after a tag push?” It is “did the workflow build the commit selected by this exact tag?” A minimal shell check can confirm that the reference is an annotated tag:
test "$(git cat-file -t "$GITHUB_REF_NAME")" = "tag" git show --no-patch "$GITHUB_REF_NAME" npm ci npm test npm run build
For a Node project, add a project-specific version comparison so that a tag named v1.4.0 cannot publish code whose package.json still says 1.3.9. Keep that policy in a checked-in script such as scripts/check-release-version.sh, rather than hiding it in a release manager’s memory.
There is an important sequencing choice here. A tag-triggered CI workflow may build artifacts, but it should not publish a public GitHub release until required tests complete. Otherwise a release event becomes the start of validation, and users can encounter a failed or incomplete publication that appears official.
Build assets once and attach checksums
After validation succeeds, build platform artifacts from the checked-out tagged commit. For a CLI, that might be Linux, macOS, and Windows archives. For a library, the distribution may instead be a package registry publish plus a source release. The rule remains the same: do not rebuild the same version from an arbitrary later checkout.
Name files predictably and publish a checksum file alongside them. A release asset set for logship v1.4.0 might look like this:
dist/logship_1.4.0_linux_amd64.tar.gz dist/logship_1.4.0_darwin_arm64.tar.gz dist/logship_1.4.0_windows_amd64.zip dist/checksums.txt
Generate checksums in the CI job that produced the archives. A user downloading an archive can compare its checksum with the value in checksums.txt; more importantly, your own support team can identify the exact artifact it asked a customer to run.
The overlooked tradeoff is retention. CI systems often treat build artifacts as temporary job output, while GitHub release assets are intended for downstream download. Publishing the CI-produced files to the release gives them a stable user location, but it should happen only after the build and test records are complete. Keep the build logs and workflow run associated with the tag so an asset can be traced back to its creation.
Publish the GitHub release after the gate passes
At this point, Git already has v1.4.0, CI has validated the tagged commit, and the workflow has the assets it built. Now create the GitHub release. The release command should refuse to create a new tag implicitly, because the release must use the tag that CI checked.
GitHub CLI supports that discipline with --verify-tag:
gh release create v1.4.0 \ dist/logship_1.4.0_linux_amd64.tar.gz \ dist/logship_1.4.0_darwin_arm64.tar.gz \ dist/logship_1.4.0_windows_amd64.zip \ dist/checksums.txt \ --verify-tag \ --title "logship v1.4.0" \ --notes-file RELEASE_NOTES.md
Run this from CI with a credential that has only the repository permissions required for publication. The important option is --verify-tag: it prevents the convenience behavior of creating a tag during release creation. That convenience is useful for an informal project, but it breaks the “tag, validate, publish” sequence because the release tool becomes a second tag creator.
Use a checked-in notes file or generated notes that a human reviews. A release page is for consumers, so include installation changes, breaking changes, migration steps, security-relevant fixes, and a link to the full comparison when useful. A commit list alone rarely answers “do I need to change my deployment?”
Handle pre-releases and “latest” deliberately
A release candidate such as v1.5.0-rc.1 is not just an ordinary release with a different title. Mark it as a GitHub pre-release so users looking for production-ready downloads are not steered toward it. The tag still gives testers an exact commit to validate, while the release setting communicates the support expectation.
Likewise, do not assume that chronological order is the same as the correct upgrade path. Suppose v2.0.0-rc.1 is published after stable v1.4.0. The release candidate should not displace the stable release for users who follow the repository’s latest-release link.
| Release type | Example tag | GitHub release setting | Typical audience |
|---|---|---|---|
| Stable patch | v1.4.1 |
Published, eligible to be latest | All production users |
| Release candidate | v1.5.0-rc.1 |
Marked pre-release | Testers and early adopters |
| Major stable release | v2.0.0 |
Published with migration notes | Users ready for the new contract |
GitHub lets maintainers mark a release as a pre-release and set a release as latest. Make both choices in automation or in a documented review checklist. Leaving those decisions to whatever the interface defaults to can turn a release ordering detail into an accidental user-facing policy.
Do not mistake a tag name for an immutable delivery record
An annotated tag object records useful information, but a tag name is still a reference that can be changed or deleted by someone with sufficient repository authority. A GitHub release can also have its notes or assets edited after publication. That means neither object alone is a complete supply-chain control.
For most teams, the practical response is not an elaborate security program. It is a small set of operational constraints:
- Restrict who can push release-pattern tags such as
v*. - Require branch CI before a commit is selected for tagging.
- Require tag-triggered CI before release publication.
- Build assets in CI from the tag-triggered checkout, not on a laptop.
- Publish checksums with every binary release.
- Record changes to release assets and release notes in your release process.
If a release fixes a security vulnerability, publishing a GitHub security advisory is a separate task from writing release notes. Release notes tell users what changed; a security advisory gives the vulnerability a dedicated disclosure and notification path. Do not rely on a vague “security fixes” bullet when affected users need a clear remediation decision.
Adopt this release workflow this week
Start with one non-critical release, such as your next patch version. The goal is not to automate every release ceremony immediately. The goal is to establish an unbroken chain from a named version to a tested commit to the files users download.
- Choose a version, such as
v1.4.1, and update the project’s canonical version value. - Merge the release-ready commit through your normal review and branch CI process.
- Create an annotated tag with
git tag -a v1.4.1 -m "project v1.4.1". - Push the tag and run a tag-triggered CI workflow that checks tag type, version match, tests, and build output.
- Generate named artifacts and a checksum file from that workflow.
- Create the GitHub release only after the workflow succeeds, using the existing tag and reviewed notes.
- Mark pre-releases and latest-release status intentionally.
The decision rule is straightforward: use an annotated tag whenever you need a durable Git-level answer to “which commit is version X?” Use a GitHub release whenever people need a curated answer to “what changed, what should I download, and should I upgrade?” Shipping both in that order gives developers provenance and gives users a usable delivery channel.