Two builds shared one version tag, and we found out right after a release we had just verified. The tag could no longer answer the one question we needed it to answer: which build is actually in production?
On June 29, we shipped a 2 hour 40 minute release that restored the Show Seed toggle, made seed numbers visible in every round of the public view, and added a per participant group Seed/Rank Override. The feature work was verified live. The release record carried a single version number. But the versioning path had already created ambiguity around that number.
The deploy did not explode. There was no compiler failure to chase and no production exception pointing at a bad line. We had two builds associated with one version identifier. If somebody asked whether a particular fix was live, the tag was supposed to be the shortest path to an answer. Instead, it obscured the answer.
I treated the tag as decoration
We had been treating versioning as release decoration. The code is the thing that runs, and the tag is a human friendly name attached afterward.
That idea falls apart the moment we need to reconstruct a production state. A tag is part of the release record. It connects a deployed artifact to a commit, a set of changes, verification work, and the claim that this is what is live. Once two builds can share it, all of those links become suspect.
We had checked the public behavior of the feature itself. The risk was that a later failure would send us backward through deployment history with a false key. We could see the version number, but we could not use it as a unique identifier for the code behind the behavior we were investigating. That turns a precise question, “when did this change ship?”, into archaeology.
Looking at the wrong end first
We went looking at the deployment target first, which was the wrong end of the problem. What actually mattered was the version calculation. The next release number was being derived by grepping merge messages. That looked workable while merge text and release history happened to move in lockstep.
They are not the same record. Merge messages describe development history. They can be written in different forms, omitted, reordered by the shape of a branch, or simply fail to carry the one marker a parser expects. A grep over that text is an inference about releases. It is not the release ledger.
Git tags are the ledger. A tag is the artifact we deliberately place on a specific commit to denote a release. The source that names the prior release must be the same source that records prior releases.
So the hardening rule was small and specific: derive the next release version from Git tags, not from a merge message grep.
old: inspect merge messages, infer the latest versionnew: inspect Git tags, identify the latest version, derive the next versionThe change left the application schema and deployment alone and corrected the boundary where release metadata becomes a decision. The new calculation asks the repository’s release history for the latest release, then advances from that value. It no longer tries to reconstruct release history from prose written for a different purpose.
Why the other fixes lose
Leaving the grep in place and adding a stricter message convention would keep the same flaw. Merge prose would still be authoritative, and every path into the branch would have to produce exactly the right string.
An independent counter would move the problem elsewhere. A counter can be unique, but it still has to be reconciled with the commit it represents. A tag attached to a commit already gives us that relationship.
The question is “what was the previous release?” The answer should come from the set of previous release tags.
What changed after the collision
We filed the hardening work as soon as we saw the collision, instead of treating it as an annoying release anomaly. The note in the work record is blunt: derive the next release version from Git tags, not merge message grep.
Automation fails in the gaps between two records that look equivalent until they are not. Our merge history and our release history were close enough to look interchangeable, right up until one duplicated a version number.
A version number should identify one build, and the release logic is now built around making that true. If you want to check your own setup, look at where your next version number comes from. Ours came from a search of merge messages, and two builds ended up sharing a tag.