AI-AGENTS · CODING-AGENTS

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.

.github/workflows/version-bump.yml
- 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: false

That 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?”