A maintainer can push v2.4.0 at 09:12, discover a packaging problem at 09:25, and publish its GitHub Release at 10:00—without moving v2.4.0 at all. That 48-minute gap is not a bookkeeping error; it is the reason a Git tag and a GitHub Release should be treated as two separate release controls.
For a versioned library, CLI, plugin, or service, the useful question is not “tags or releases?” A tag answers which exact commit is version 2.4.0?; a GitHub Release answers what should users know, download, and be notified about?. Mixing those decisions creates a familiar failure mode: the repository says v2.4.0, while the downloadable binary or release notes describe a different commit.
Use the tag as the immutable version boundary
Start with the Git object model, because it determines what can safely be automated. A Git tag identifies a particular point in repository history. For a maintained project, that means the tag should identify the commit that passed the checks you require for that version—not “roughly the code we intended to ship.”
For example, imagine acme-cli, a command-line tool with a main branch. The team is preparing version v2.4.0. Commit 8f3c2ab updates the version string, changelog, tests, and package lockfile. If CI passes for that commit, v2.4.0 should point to 8f3c2ab, even if three documentation commits land on main later that afternoon.
That stability is the tag’s job. A GitHub Release is built on a tag, but it adds a publication layer: release notes, downloadable source code, and potentially binary files. GitHub’s own documentation describes releases as being based on Git tags, not as replacements for them.
| Need | Create an annotated Git tag? | Publish a GitHub Release? |
|---|---|---|
Mark the exact commit used by version v2.4.0 |
Yes | Not required |
| Give users a changelog and a notification point | Not sufficient alone | Yes |
| Attach a signed installer, ZIP, or binary | No | Yes |
| Create an internal deployment marker | Usually yes | Only if external users need it |
Choose annotated tags for every shipped version
Use an annotated tag for versions that someone might need to inspect, build, deploy, or audit later. Unlike a lightweight tag, an annotated tag is a full Git object with tagger information, a date, and a message. That extra metadata makes it a better release record than a bare pointer named v2.4.0.
The practical convention is straightforward: use Semantic Versioning-style numbers and prefix them with v. A project that calls one version 2.4.0, another v2.4.1, and a third release-2.5 forces every script, package workflow, and human reader to handle exceptions.
git checkout main
git pull --ff-only origin main
git log -1 --oneline
# Confirm this is the commit that passed required checks
git tag -a v2.4.0 -m "acme-cli v2.4.0"
git push origin v2.4.0
The command deliberately tags the checked-out commit. If the release commit is not currently checked out, name its SHA explicitly:
git tag -a v2.4.0 8f3c2ab -m "acme-cli v2.4.0"
git push origin v2.4.0
The tradeoff people skip is that tags are cheap to create but expensive to correct socially. You can delete and recreate a remote tag, but anyone who fetched the original tag may retain the old reference. For public versions, treat a pushed version tag as immutable. If you shipped the wrong commit, publish v2.4.1 rather than silently retagging v2.4.0.
Prepare the release commit before creating the tag
A dependable workflow makes the tag the final result of preparation, not the start of it. In acme-cli, the release pull request should contain everything that changes the meaning of v2.4.0: the application version, dependency metadata, migration notes, and tests for the new behavior.
Use a short release gate before tagging. The point is not ceremony; it is to avoid discovering after the tag exists that the package identifies itself as 2.3.0 or the generated artifact contains a different commit.
- Choose the next version, such as
v2.4.0. - Update version-bearing files and user-facing changelog entries in one pull request.
- Merge only after required CI checks pass on the merge commit.
- Build and test from that exact commit when your project produces packages or binaries.
- Create and push the annotated tag.
- Publish the GitHub Release using that already-pushed tag.
This order has an important consequence: CI should validate the candidate commit before the stable tag exists. If your only meaningful test runs after a v* tag push, then the tag is acting as a test trigger rather than a statement that the version is ready. That may be acceptable for a private deployment marker, but it is a weak rule for a public release.
Publish a GitHub Release when people need a product event
After v2.4.0 exists, create a GitHub Release when there is something useful to communicate or distribute. The release should select the existing v2.4.0 tag, use a clear title such as acme-cli v2.4.0, and include notes that tell an upgrader what changed.
A useful release description is not a pasted commit log. For a CLI, organize it around the decisions users must make:
- Added: a concrete new command or capability.
- Changed: altered defaults, output, configuration, or behavior.
- Fixed: defects users may have worked around.
- Upgrade notes: required environment, config, or API changes.
- Verification: checksums or installation instructions if you attach binaries.
GitHub Releases can package the version for users with release notes and downloadable files. That makes a release especially valuable when maintainers publish platform binaries such as acme-cli_2.4.0_linux_amd64.tar.gz or an installer. Keep the filenames versioned: an asset named acme-cli-latest.zip becomes ambiguous as soon as a user reports a bug against it.
Not every tag needs this publication step. A tag created solely to deploy an internal service can remain a tag if no external user needs notes, downloads, or a GitHub notification. Creating a polished public release for every staging deployment adds noise and trains subscribers to ignore release notifications.
Use prereleases for testable versions that are not production promises
A prerelease is the bridge between “we need broader testing” and “we are ready to call this stable.” For acme-cli, use a tag such as v2.5.0-rc.1 for the first release candidate. Create it as an annotated tag using the same discipline as a stable version, then create the GitHub Release and select the prerelease option.
git tag -a v2.5.0-rc.1 -m "acme-cli v2.5.0 release candidate 1"
git push origin v2.5.0-rc.1
The tag is still a durable answer to “what did testers run?” The prerelease checkbox changes the publication signal: GitHub can tell users that the release is not ready for production and may be unstable. This matters when rc.1 carries a database migration, a rewritten authentication flow, or a changed configuration format.
Do not turn the final stable release into a renamed release candidate. When testing finishes, create a new stable tag—v2.5.0—at the final approved commit. If nothing changed after v2.5.0-rc.1, the two tags may identify the same commit. They still represent different promises: one is for evaluation; the other is the supported release.
GitHub can automatically assign the latest-release label based on semantic versioning when you do not set it manually. Check the result whenever you publish a prerelease, particularly if your project has an older stable line such as v1.9.8 that users still rely on.
Why the tag date and release date can legitimately differ
The tag date records when the tag was created; the GitHub Release date records when the release was published. GitHub explicitly notes that these dates can differ because tags and releases may be created at different times. That is not a flaw—it lets maintainers freeze code before they finish the public packaging work.
Consider this timeline for v2.4.0:
| Time | Event | What changed |
|---|---|---|
| 09:12 | Annotated tag v2.4.0 pushed |
The version-to-commit relationship is fixed. |
| 09:25 | Maintainer finds an incorrect binary filename | The tag remains correct; packaging needs repair. |
| 09:40 | Corrected binary is built from v2.4.0 |
No source commit needs to move. |
| 10:00 | GitHub Release is published | Users receive notes and the intended assets. |
The critical condition is that the repaired artifact must still be built from the tagged source. If fixing the binary requires a source-code change, stop and create v2.4.1. A later release date is fine; a release claiming v2.4.0 while shipping code from an untagged fix is not.
Separate automation triggers from publication authority
Many teams trigger builds when a tag matching v* is pushed. That is useful: the tag supplies a stable version name and a commit SHA for reproducible build inputs. But it also means anyone permitted to push such a tag may start a production-adjacent workflow.
Split the workflow into two decisions when possible:
- Tag push: build, test, generate checksums, and prepare artifacts for the tagged commit.
- GitHub Release publication: make notes and approved assets visible to users.
- Production deployment: require the environment approval or protected deployment control appropriate for your repository.
This separation reduces a common team-friction problem. A release engineer can create v2.4.0 after CI approval, while a product owner or maintainer reviews the public release notes before publication. The build is anchored to a commit; the announcement is reviewed as communication; deployment remains its own authorization boundary.
If your project uses GitHub Actions, be precise about the event your workflow consumes. A tag push and a release publication are different repository events. Choose the tag-triggered workflow when the job must build from the exact version boundary. Choose a release-triggered workflow when the job should run only after the release is deliberately published.
Keep tags useful even when your repository has no releases
The Git project’s GitHub repository is a useful reminder that tags and releases serve different audiences: a repository can have tags without publishing GitHub Releases. That is sensible for projects whose users obtain software through another distribution channel, or for repositories where tags mainly support source inspection and downstream packaging.
For example, a shared internal Terraform module might tag v1.8.0 so dependent repositories can pin an exact revision. A GitHub Release adds little if users consume the module directly from Git and do not need attached files or a public changelog page.
Conversely, a desktop app with macOS, Windows, and Linux downloads should nearly always publish a GitHub Release after tagging. The release page gives users one versioned destination for notes and artifacts. A tag list alone makes them hunt through commits or external documentation to understand whether v2.4.0 fixes their problem.
The decision rule is practical: always tag a versioned commit; publish a GitHub Release when a person outside the commit workflow needs an announcement, explanation, or downloadable output. That rule works whether “outside” means open-source users, customers, QA testers, or another internal team.
Adopt this maintainer workflow this week
You can improve a release process without introducing a release-management platform. Start by writing down one convention and enforcing it in the next version: stable versions use annotated tags named vMAJOR.MINOR.PATCH, and no stable tag moves after publication.
- Inspect the last three versions in your repository and identify whether each is a tag, a GitHub Release, or both.
- Pick one naming format, such as
v2.4.0, and document it inCONTRIBUTING.mdor your release runbook. - Create a release checklist that requires CI to pass before the stable tag is pushed.
- Define who can create version tags and who can publish public releases.
- For the next unstable build, use a tag such as
v2.5.0-rc.1and mark its GitHub Release as a prerelease. - For the next stable release, record both the tag creation time and release publication time in the release notes or operational log when the gap matters.
The result is not more process for its own sake. It is a clean chain of evidence: a version name identifies one commit, artifacts can be traced back to that commit, and the GitHub Release tells users when that version became ready for them. That separation makes a late asset fix, a delayed announcement, or a release candidate routine instead of confusing.