Automated Versioning: Simple Rules Can Still Drift
This is the first of three deep-dives into a versioning-and-deployment standard I built for an AI agent to use across a few dozen repos. We will cover the standard itself and how simple directives quietly drifted.
The friction of each episode of drift landed differently and each led to new guardrails and tooling decisions.
The loudest and earliest of these unexpected headaches is the one we will look at here: a process bug that mangled the version numbers of a string of releases.
The standard, as designed
The premise was simple enough that it didn’t feel like it needed much scrutiny: every
package’s version comes from a git tag, computed automatically, never hand-typed. A
merge to main reads the PR’s title for a bump marker ([major], [minor], or
nothing for a patch), computes the next version, and pushes the tag.
No repo carries a version number in a file anywhere; the tag is the version, and CI is the only thing allowed to create one.
That’s the whole idea. Simple. It’s a well-worn pattern (hatch-vcs for Python does the
git-tag-to-version part; the PR-title convention is just a rule about where the bump
level comes from), and it’s exactly the kind of small, mechanical, repeat-across-every-repo
task that’s satisfying to hand to an agent: write it once, apply it everywhere, never
think about it again.
It seemed to work.
The problem
Here’s the failure mode, and it’s subtle enough that I want to walk through it slowly, because the subtlety is the whole point.
GitHub’s merge-queue protection on this org is squash-only — every PR lands on main
as exactly one new commit, regardless of how many commits the branch had. For a
multi-commit PR, that squash commit’s message is something GitHub constructs, and it’s
reasonable to assume it reflects the PR title. For a single-commit PR, though,
GitHub just uses that one commit’s own message as the squash subject — the PR title
never enters into it at all.
Uncontroversial, right? No.
If the bump marker went in the PR title, and the PR happened to be a single commit
(which a lot of small, focused PRs are), the marker was never there to find. A
[minor] in the title, invisible to a workflow reading the merged commit’s subject.
Silent patch bump, every time.
- name: Determine bump level run: | # Reads the merged commit's own message — wrong for single-commit PRs, # because the squash subject IS that commit's message, not the PR title. BUMP=$(echo "${{ github.event.head_commit.message }}" | grep -oE '\[(major|minor)\]' || true) # Ask GitHub for the actual PR title via the API instead of guessing at it # from whatever ended up as the squash commit's subject. PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number') TITLE=$(gh pr view "$PR_NUMBER" --json title --jq '.title') BUMP=$(echo "$TITLE" | grep -oE '\[(major|minor)\]' || true)This wasn’t a one-time embarrassment. It shipped four wrong releases before anyone
connected the dots: control-api #186 and #135, imp-player #11 and #27.
The imp-player case is the one that stuck with me. Someone noticed the tag was
wrong, deleted it, and hand-created the correct one — v0.3.0 instead of whatever the
workflow had actually computed. That felt like a fix. It wasn’t; the workflow itself
was never touched, and the same bug produced the same silent wrong bump again later, at
v0.5.1.
A hand-corrected tag is not a fix.
Writing it down didn’t stop it
Once the pattern was understood, I did what felt like the responsible thing: wrote it up as a gotcha in the shared cross-repo docs, with the incident attached, in June — squash subject ≠ PR title for single-commit PRs, here’s exactly how it bit us, here’s the fix.
In July, two new repos — imp-unifi-access and imp-unifi-protect — were scaffolded
by copying a sibling repo’s workflow files. Both shipped with the exact same bug. Not a
variant of it. The identical broken logic, byte-for-byte, in a brand-new repo, a month
after the fix was written down with the incident attached.
The reason is almost embarrassingly simple: copying files doesn’t involve reading docs.
Nobody scaffolding a new repo from a working sibling stops to cross-reference a gotchas
page before copying version-bump.yml. Why would they? The sibling’s workflow runs.
It looks correct. It only fails silently, on exactly the PR shape that never gets
noticed until someone goes looking for it.
A wider audit on 2026-07-09 made the scale of it concrete: 9 of the 10 repos
audited still had a CLAUDE.md that taught the broken rule outright — “the bump
level comes from the merge commit subject” — sitting a few files away from the doc that
explained why that was wrong. The fix existed. It just wasn’t anywhere a new repo would
encounter it before the bug did.
The reframe
This is the point where I stopped asking “did we document this correctly” and started asking a different question: what actually stops someone from doing the wrong thing, versus what merely explains why it’s wrong?
Those are not the same category of thing, and treating them as interchangeable is what let the bug keep shipping. A doc is read voluntarily, by someone who already suspects they need it. A rule that’s actually enforced doesn’t require that. Ranked by how hard they are to get around:
| Layer | Strength | What lives here |
|---|---|---|
| Org-wide ruleset | Cannot be violated | squash-only merges, required CI checks, tag format |
| CI / script | Violation fails loudly | a check that fails the build if it’s wrong |
| Types / code | Violation won’t compile | make the wrong shape unrepresentable |
| Skill | Applied correctly, on demand | a runnable procedure, not a paragraph to remember |
CLAUDE.md / doc |
Advisory only | the why behind a rule something else enforces |
A CLAUDE.md sits at the bottom of that ladder on purpose. It’s the right place for
why — why the PR-title convention exists, why squash merges make this fragile — but
it was never going to be the thing that stopped a copy-paste scaffold from reproducing
a known bug. Nothing about writing a gotcha down makes it un-skippable. Something else
has to.
And like it or not, copy-pasting an apparently working solution is still attractive enough not to stop and ask the question: but does it really work as described?
The fix, and the outcome
The actual fix to the bug itself was small — query the GitHub API for the real PR title instead of reading whatever the squash commit’s subject happened to be, as in the diff above. But the fix to the pattern — the thing that stops this recurring in repo number eleven — was moving the whole convention up the ladder, off the page and into something that gets run, not read.
Logic that is executed beats policy in prose that must be parsed, understood and validated.
Bonus fix
One more piece of the mechanism needed fixing at the same time: two PRs merging within seconds of each other could both read the same “latest tag” before either had pushed a new one, computing the same next version twice. The workflow needed to serialize:
concurrency: group: version-bump-main cancel-in-progress: falseThat cancel-in-progress: false matters more than it looks. The instinct with a
concurrency group is usually to cancel the older run and let the newer one win — but
cancelling a version bump doesn’t just delay a release, it loses one. The second merge
still needs the first one’s bump to have actually completed before it computes its own.
Honing the SKILL
Both of these — the PR-title fix and the concurrency guard — are now bundled into a
skill: apply-versioning. Not a doc that explains the convention, but a procedure that
installs it: the reference material (why, and every gotcha with its incident), the
exact steps to apply it to a fresh repo, and a short paragraph to drop into that repo’s
own CLAUDE.md so the why is still there for anyone who goes looking.
The difference is that a new repo no longer inherits this convention by osmosis
from whichever sibling it was copied from — it gets apply-versioning run against it
directly, and the skill is the thing that’s supposed to be un-skippable, not the memory
of a doc three repos ago.
It’s not literally impossible to skip a skill. But it’s a procedure with an obvious name, sitting in the same plugin as the other setup steps a new repo needs, rather than a paragraph competing for attention against everything else in a docs folder. That’s the whole shift: not writing the rule more clearly, but putting it somewhere a repo has to go through, not past.
Lack of clarity was not the issue; execution steps that enforced it were.
I will cover repo auditing that ensures projects are scaffolded correctly and consistently in a future article.
That was the loudest of the three failures — wrong version numbers, shipped, several times over, with a paper trail every time. The next two never shipped anything wrong but cost more than they should have and protected less than they appeared to do — and neither one would have been caught by asking “is this documented?”