Tag It, Ship It: Git Tags and Releases Done Right
· Jerwin Arnado · 18 min read ·
A tag is the cheapest promise you can make in Git: this exact commit is version 1.4.2.
Everything downstream trusts that promise. Composer resolves ^1.4 against it, your deploy
script checks it out, your changelog starts from it, and a client asking “what’s running
in production?” gets answered by it. Most teams treat tagging as an afterthought, a
git tag v1.4.2 someone types from whatever branch they happen to be on. Then one day
1.4.2 on Packagist and 1.4.2 on the server turn out to be different code.
This is the release discipline I use across my Laravel packages and client apps. It’s not complicated, but it is strict, and almost every rule exists because I’ve seen it broken.
Lightweight vs annotated tags
Git has two kinds of tags, and they are not interchangeable.
git tag v1.4.2 # lightweight: just a name pointing at a commit
git tag -a v1.4.2 -m "Release v1.4.2" # annotated: a real object with metadata
A lightweight tag is a ref, like a branch that never moves. No author, no date, no message. An annotated tag is a full Git object: it records who tagged it, when, and why, and it can carry a signature. You can see the difference directly:
git cat-file -t v1.4.2 # "tag" for annotated, "commit" for lightweight
git show v1.4.2 # annotated tags print the tagger and message first
git help tag says it plainly: annotated tags are meant for release, lightweight tags
for private or temporary labels. Several Git commands act on that distinction:
git describeignores lightweight tags by default. On an untagged commit it prints something likev1.4.2-3-g4962b2f: the nearest annotated tag, how many commits since, and the short SHA. That string makes a great build identifier, and it silently skips your lightweight tags unless you pass--tags.git push --follow-tags(more below) only pushes annotated tags.- Signing only works on annotated tags.
So the rule is simple: every release tag is annotated. Lightweight tags are fine for “bookmark this commit before I try a scary rebase,” and nothing else.
Version names: Semantic Versioning
A tag name is a version number, and the version number should mean something. The
standard is Semantic Versioning 2.0.0: MAJOR.MINOR.PATCH.
| Bump | When | Example |
|---|---|---|
| MAJOR | You broke the public API | 1.4.2 → 2.0.0 |
| MINOR | New, backward-compatible functionality | 1.4.2 → 1.5.0 |
| PATCH | Backward-compatible bug fixes | 1.4.2 → 1.4.3 |
Two extensions are worth knowing:
- Pre-release goes after a hyphen:
2.0.0-beta.2,2.0.0-rc.1. A pre-release sorts before its final release, so2.0.0-rc.1 < 2.0.0. Use dot-separated identifiers (rc.1, notrc1). Numeric parts then compare as numbers, sorc.10correctly sorts afterrc.9. - Build metadata goes after a plus:
1.4.2+build.517. It is ignored when comparing versions, so two builds that differ only in metadata count as the same version. I rarely put it in tags. It belongs in artifacts.
The v prefix. Strictly, v1.4.2 is not a semantic version. The semver FAQ says so,
and adds that prefixing with v is a common way to show the name is a version.
Composer, npm, Go modules and GitHub’s tooling all understand v1.4.2. Pick one style
per repo and never mix them. I use v everywhere because it makes globs like v* in CI
triggers and tag protection rules unambiguous.
One practical tweak: Git sorts tag names alphabetically by default, which puts v1.10.0
before v1.2.0. Tell it to sort by version, and to put pre-releases before their final:
git config --global tag.sort -version:refname
git config --global versionsort.suffix -
git tag -l 'v1.*'
# v1.10.0
# v1.2.0
# v1.0.0
# v1.0.0-rc.1
# v1.0.0-beta.2
Signed tags
An annotated tag records a tagger name and email, and, just like commit authorship, anyone can type any name there. A signed tag proves the tag came from a key you control. On a published package that matters: the tag is the release.
If you already set up commit signing from the signing post, tags reuse the same configuration, SSH or GPG:
git tag -s v1.4.2 -m "Release v1.4.2" # -s implies an annotated tag
git tag -v v1.4.2 # verify the signature
To sign every annotated tag without remembering -s:
git config --global tag.gpgSign true
Despite the name, tag.gpgSign follows gpg.format, so it signs with your SSH key if
that’s what you configured. One SSH-specific catch: git tag -v verifies an SSH
signature only if you’ve given Git an allowed-signers file
(gpg.ssh.allowedSignersFile). Without one, it refuses to verify. GitHub verifies
against the keys on your account either way and shows Verified on the tag.
Tag the right commit
The most common tagging bug isn’t a wrong command. It’s a wrong commit. A tag cut from
your feature branch, or from a local main that’s three commits behind, ships something
nobody reviewed.
My rule: tag only from the release branch, only after CI is green on that exact commit.
For most of my projects that’s main, as in the trunk-based setup.
git switch main
git pull --ff-only # make sure local main IS origin/main
git log -1 --oneline # is this the commit CI just passed?
git tag -s v1.4.2 -m "Release v1.4.2"
If CI passed on a merge commit from your feature branch workflow, that merge commit is what you tag. Not the branch tip from before the merge, not a later commit someone pushed while you were reading the CI log.
Pushing and deleting tags
Tags don’t travel with a normal git push. That surprises people the first time, and
there are three ways to fix it:
git push origin v1.4.2 # push one tag, explicitly. Precise, always safe.
git push --tags # push EVERY local tag. Including your junk ones.
git push --follow-tags # push commits plus the annotated tags that point into them
--tags is the one to avoid. It pushes every tag in your local repo, so that
before-rebase bookmark from last month goes public. --follow-tags is the sane
default: it only sends annotated tags that point at commits you’re pushing, and it skips
lightweight ones. Make it permanent:
git config --global push.followTags true
Now git push after tagging a release sends both the commit and the tag, and your local
bookmarks stay local.
Deleting is two steps, because local and remote are independent:
git tag -d v1.4.2 # delete locally
git push origin --delete refs/tags/v1.4.2 # delete on the remote
Use the full refs/tags/ name on the remote delete. If a branch and a tag ever share a
name, it removes any doubt about which one goes.
Never move a published tag
Once a tag has been pushed, treat it as immutable. Don’t git tag -f it to a new commit,
and don’t delete it and re-create it with the same name. If v1.4.2 is broken, release
v1.4.3.
Why so strict? Because you can’t recall a tag from everyone who already has it:
- Clones don’t follow the move.
git fetchwon’t overwrite a tag that already exists locally. It rejects the update (“would clobber existing tag”) unless the user passes--force. Your teammates and CI caches silently keep the old commit under the same name. - Lockfiles pin the SHA. A
composer.lockrecords the exact commit behindv1.4.2. Projects locked before the move install the old code, projects locked after get the new code, and both report the same version. That’s the worst bug report you can get. - Registries and mirrors cache. Packagist, Go’s module proxy and every CDN in between may already have the old archive.
The undoing-things post has the same principle for commits: rewrite what’s private, add new history for what’s public. The only exception is a tag that was pushed by mistake seconds ago and that nobody could have fetched yet. Even then, I’d rather burn the version number.
Automating the version bump
If your commits follow Conventional Commits,
the next version number is a function of your history: any breaking change is a MAJOR,
any feat is a MINOR, otherwise a PATCH. You don’t need to decide it by hand. Three tools
do this well, and they differ mostly in when the release happens:
| Tool | How it works | Good fit |
|---|---|---|
| release-please (Google) | Watches main, keeps a “release PR” open that bumps the version and updates CHANGELOG.md. Merging that PR creates the tag and the GitHub Release. |
Apps and packages where a human should press the button |
| semantic-release | Runs in CI on every push to the release branch, computes the version from commits, then tags, publishes and creates the release with no human step. | Libraries with high release frequency and strong tests |
| changesets | Developers add a small markdown “changeset” file with each PR saying what bumps and why. changeset version applies them; changeset publish publishes and tags. Doesn’t read commit messages. |
JS monorepos with many packages |
For PHP packages I lean on release-please: the release PR is a natural review point, and
the changelog it writes is grouped by commit type with no hand-editing. One gotcha with
any bot-driven flow on GitHub: a tag created by a workflow using the default
GITHUB_TOKEN does not trigger other workflows. If your tag-push pipeline (below)
never fires after a bot release, that’s why. Use a GitHub App token or a PAT for the
release step, or do the build in the same workflow.
GitHub Releases are not tags
A tag is Git. A Release is a GitHub feature layered on top of a tag: a title, release notes, downloadable assets, and a “Latest” badge. You can have tags without releases (fine for internal apps) but not a release without a tag.
The gh CLI covers everything I do from the terminal:
# Tag already pushed? Create the release from it, with generated notes.
gh release create v1.4.2 --verify-tag --generate-notes
# Notes from a file instead (e.g., this version's CHANGELOG section)
gh release create v1.4.2 --verify-tag --notes-file notes/v1.4.2.md
# A release candidate: marked as pre-release, never shown as "Latest"
gh release create v2.0.0-rc.1 --verify-tag --prerelease --generate-notes
# Stage it as a draft, review the page, publish later
gh release create v1.4.2 --verify-tag --draft --generate-notes
# Attach build artifacts; "#text" sets a display label
gh release create v1.4.2 --verify-tag --generate-notes 'dist/app.zip#Application bundle'
Always pass --verify-tag. Without it, if the tag doesn’t exist on the remote,
gh quietly creates a lightweight tag on the tip of the default branch. That’s exactly
the “wrong commit, unannotated, unsigned” tag this whole post is trying to prevent.
With --verify-tag, a typo in the version aborts instead.
--generate-notes builds notes from the merged PRs and contributors since the previous
release. It’s good enough for most apps. If you’d rather reuse the annotated tag’s own
message, there’s --notes-from-tag.
GitHub also offers immutable releases as a repository setting. Once a release is published, its tag and assets can’t be changed or deleted. For a published package, turn it on. It enforces the “never move a tag” rule for you.
CHANGELOG.md still matters
Release notes live on GitHub. A CHANGELOG.md lives in the repo, ships inside the package,
and survives a move to another host. I keep one in the
Keep a Changelog format: newest first, an Unreleased
section at the top, and changes grouped under fixed headings.
## [Unreleased]
## [1.4.2] - 2026-11-12
### Fixed
- Module stubs now respect the configured namespace.
## [1.4.0] - 2026-10-03
### Added
- `--api` flag to scaffold an API-only module.
### Deprecated
- `make:module --plain`; use `--minimal` instead.
The headings are Added, Changed, Deprecated, Removed, Fixed, and Security. Write entries for humans (“what changes for me?”), not as a dump of commit subjects.
GitLab, briefly
GitLab has the same split: tags in Git, Releases as a platform feature. With the glab CLI:
glab release create v1.4.2 --notes-file notes/v1.4.2.md
As with gh, if the tag doesn’t exist, GitLab creates it (from --ref), so push your
signed, annotated tag first.
Protect your tags
If anyone with write access can delete or move v* tags, your “never move a tag” policy
is just a hope. Both big hosts let you enforce it:
- GitHub: Settings → Rules → Rulesets → New ruleset → New tag ruleset. Target tags
matching
v*, then enable Restrict updates, Restrict deletions, and Block force pushes. Add Restrict creations if only maintainers or your release bot may cut versions, and put them in the bypass list. (The older “protected tags” feature has been replaced by rulesets.) - GitLab: Settings → Repository → Protected tags. Add the wildcard
v*and set Allowed to create to Maintainers. Protected tags can’t be updated, and only Maintainers can delete them (through the UI or API).
Ship on a tag push
Once tags are trustworthy, they make the perfect release trigger. Pushing v1.4.2 should
be the only manual step. CI tests the tagged commit, builds an artifact, and publishes the
release. Here’s the GitHub Actions workflow I use as a starting point for a PHP project:
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write # needed to create the release and upload assets
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpunit
- name: Build archive
run: git archive --format=zip --prefix="app-${GITHUB_REF_NAME}/" -o "app-${GITHUB_REF_NAME}.zip" HEAD
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
if [[ "$GITHUB_REF_NAME" == *-* ]]; then
gh release create "$GITHUB_REF_NAME" "app-${GITHUB_REF_NAME}.zip" \
--verify-tag --generate-notes --prerelease
else
gh release create "$GITHUB_REF_NAME" "app-${GITHUB_REF_NAME}.zip" \
--verify-tag --generate-notes
fi
A few details are doing real work here:
tags: ['v*']means branch pushes never trigger this workflow. Only version tags do.permissions: contents: writeis the minimum the default token needs to create a release. Keep the rest of the token read-only.- The tests run again on the tagged commit. Green CI on
mainshould already have covered it, but the release is the one run that has to be right. git archivebuilds the zip from the checked-out tag and honoursexport-ignorein.gitattributes, so tests and dev config stay out of the download. See the gitattributes post.- Any hyphen in the tag (
v2.0.0-rc.1) marks the release as a pre-release, which matches the semver rule.
To stop a broken release from going out at all, you can add a pre-push hook that runs the test suite. It’s slower to push, but you find out before the tag is public.
Deploy by tag, roll back by tag
For apps (not packages) the tag is also your deploy unit. Deploy v1.4.2, not “whatever
main is right now.” Then the answer to “what’s in production?” is one word, and it
matches a release page with notes.
This pairs naturally with atomic deployments: check out the tag into a fresh release folder, build, then flip the symlink.
git clone --depth 1 --branch v1.4.2 [email protected]:me/app.git "releases/v1.4.2"
# build, smoke-check, then:
ln -sfn releases/v1.4.2 current
git clone --branch accepts a tag as well as a branch. Naming release folders after
tags makes rollback self-documenting. Rolling back means pointing current at
releases/v1.4.1, or, if that folder’s been pruned, redeploying the v1.4.1 tag.
No reverts, no hotfix branch at 2am. The bad version stays tagged and documented. You
fix forward in v1.4.3 once things are calm.
Ecosystem notes: Composer and npm
Composer and Packagist read your Git tags as versions. There’s no upload step for a
PHP package: Packagist watches the repo, and every tag that parses as a version
(v1.4.2 or 1.4.2) becomes an installable release. Branches show up as dev-main
and similar. That’s how my Laravel Module Maker
gets published. Pushing the tag is the release. Consequences:
- Don’t put a
versionfield incomposer.json. Composer’s docs recommend omitting it for packages published from a VCS, because the tag is the source of truth. A hard-coded version will eventually disagree with it. - Moving a tag is extra-dangerous here, for the lockfile reason above.
- Packagist’s GitHub integration updates on push. If a new tag doesn’t appear, check the package’s update hook on Packagist before re-tagging anything.
npm goes the other way: the version lives in package.json, and npm version writes
it to Git for you. In a clean Git repo, npm version patch (or minor, major,
prerelease) bumps package.json, commits, and creates a v-prefixed annotated tag.
Follow it with git push --follow-tags (or plain git push with push.followTags set)
and the tag goes up with the commit. For a Vue app this is the quickest way to get proper
tags with zero extra tooling.
Caveats and best practices
- One tag, one meaning, forever. Fix forward with a new patch version. Never retag.
- Annotated (ideally signed) for every release. Lightweight tags are for local bookmarks.
- Tag the commit CI passed, from an up-to-date release branch, never from a
feature branch or a stale local
main. - Pre-releases are cheap.
-rc.1on a risky major costs nothing and catches the “works on my machine” class of bug beforeLatestpoints at it. - Tags are not backups. A tag on a deleted, unmerged branch keeps the commit alive, but that’s a side effect. Don’t rely on it.
- Version the API, not the effort. A one-line change that breaks a signature is a MAJOR. A month of internal refactoring with no public change is a PATCH.
Cheat sheet
# One-time setup
git config --global push.followTags true
git config --global tag.gpgSign true # sign tags by default (SSH or GPG)
git config --global tag.sort -version:refname
git config --global versionsort.suffix -
# Cut a release
git switch main && git pull --ff-only
git tag -s v1.4.2 -m "Release v1.4.2"
git push # commit + annotated tag, via followTags
gh release create v1.4.2 --verify-tag --generate-notes
# Inspect
git tag -l 'v1.*' # list, version-sorted
git show v1.4.2 # tagger, message, signature, commit
git tag -v v1.4.2 # verify signature
git describe # e.g. v1.4.2-3-g4962b2f
git tag --contains <sha> # which releases include this fix?
# Mistakes (unpublished tags only)
git tag -d v1.4.2
git push origin --delete refs/tags/v1.4.2
Tags cost nothing to create, and that’s why they get misused. Treat each one as a public, permanent statement about a specific commit. Annotate it, sign it, protect it, and let CI do the rest.