---
title: "Lightweight Tags, Silent Loss: Why Your Bare Tags Don't Ship"
canonical: https://dxdev.com/blog/2026-09-07_git-tag-annotation-requirement/
datePublished: 2026-08-30
---
The audit was clean: 211 wrapper merges checked, zero missing a tag on origin. Ninety days of history, nothing untagged. So why were two production releases still shipping with tags that didn't work the way we needed them to?

We'd already closed this gap once. A ticket filed weeks earlier had found releases reaching master with no tag pushed to origin at all, and four fixes landed to close it: refuse a release push with no tag, mandate `git tag -a` in the runbook, push master and its tag together, verify the tag actually landed. The auditor script confirmed all four were holding. Existence and origin-reach, both green.

But `git cat-file -t` on the actual tags told a different story. One tag, cut August 25, and another, cut August 27, both came back `commit` instead of `tag`. That's the tell for a lightweight tag: a plain ref pointing straight at a commit, with none of the object wrapper that `git tag -a` creates. Both had shipped weeks after the runbook said not to do this.

Here's the mechanism. `git tag X.Y.Z` and `git tag -a X.Y.Z -m "..."` look interchangeable at the command line, and for most local purposes they are. But `push.followTags`, the setting we relied on to get tags to origin without a separate `git push --tags` step, only carries annotated tags. A lightweight one is invisible to it. In our history, 47 of the 55 tags in the 3.36x series were lightweight, so that setting alone never protected anyone who tagged by hand.

The reason these two still reached origin lightweight is that the earlier fix worked exactly as designed and it wasn't enough: the combined push, the earlier fix itself, forces master and the tag over together as one step, regardless of tag type. The push-master-and-tag-together guard checks that a tag exists and that it lands on origin. It never checks what kind of tag it is. That check lived only as prose in an error message a correct push never triggers, so nobody read it.

The pre-push guard had a real gap: two questions asked, a third one that mattered left unasked.

The fix is about ten lines in that same hook, plus four new cases in the guard script it calls, covering the combinations that actually occur:

- annotated tag, local only: fine, proceed
- annotated tag, already on origin: fine
- lightweight tag, still local: **block**, with the fix path in the error, `git tag -a -f`
- lightweight tag, already on origin: **warn only**

That branch matters. A local lightweight tag is free to fix, force-recreate it as annotated and nothing downstream has seen it yet. A lightweight tag that's already reached origin is a different situation: someone may have fetched it, and force-retagging something other people already have is worse than leaving a lightweight tag in place. So the guard blocks the fixable case and only warns on the one that would require rewriting history other people might be holding.

The retitle mattered too. The ticket had started as "hotfix tags stopped being annotated," which described a problem four other tickets had already closed. Rerunning the auditor first, before touching any code, was what kept this from turning into a rebuild of guard logic that was already correct. It narrowed to the one question the guard still didn't ask, and that's the only thing that shipped.
