1222 words
6 minutes
Nobody Picks the Version Number Anymore
阅读中文版

For months now, no human on our team has picked a version number. Packages go from 2.4.0 to 2.4.1 or 2.5.0 on their own; changelogs write themselves; release pages appear with downloadable builds attached. The entire decision — should this merge produce a release, and how big — is made by a robot reading the words we type in commit messages.

I recently read this machine’s config and workflow line by line (and found a bug with a story of its own). This post is the explainer: how a merged PR becomes a published package with zero human decisions, and the three rules that keep the whole thing from quietly breaking.

What a version number actually promises#

Semantic versioning is major.minor.patch — but the format is the boring half. What matters is what each position promises to the people using your package.

Think of your package as an apartment your users live in:

  • patch (2.4.0 → 2.4.1) — you fixed a squeaky hinge. Nothing about how anyone lives there changes.
  • minor (2.4.0 → 2.5.0) — you added a room. Everything is exactly where they left it; there’s just more now.
  • major (2.4.0 → 3.0.0) — you changed the locks. Their old key will not work. They must do something before they can get back in.

This is why downstream projects pin dependencies like >=2.4,<3: it means “renovate all you want — just don’t change my locks.” They auto-upgrade through your patches and minors without a second look, trusting that you labeled correctly.

Which means a wrong bump isn’t a formatting mistake. Ship a breaking change as a patch and you’ve changed someone’s locks without telling them — and their CI installs it automatically overnight.

The shipping label: conventional commits#

The robot deciding your version can’t read your code. It reads one thing: the prefix of each commit message. This convention is called Conventional Commits, and the mental model is a parcel sorting machine: it routes entirely by the label on the box, never by opening it.

Label on the commitMeansRouted to
fix: / perf:squeaky hingepatch bump
feat:new roomminor bump
feat!: or a BREAKING CHANGE: footerchanged the locksmajor bump
docs: / chore: / test: / ci:not a renovation at allno bump

Here’s the part that deserves fear: a mislabeled box doesn’t get rejected — it gets routed wrong, silently. There’s no error for labeling a breaking change as fix:. The machine cheerfully ships it as a patch, downstream auto-upgrades, and someone’s Tuesday is ruined. The label isn’t metadata about your commit; it is the input to a decision that strangers depend on.

The assembly line: following one commit through#

Let’s trace a single fix: handle empty payload from merge to published package. Our stack is Python with python-semantic-release, but the shape is the same in any ecosystem (the original semantic-release is from the JS world).

Station 0 — the trigger. The PR merges; the commit lands on main; the release workflow wakes up. Every push to main goes through this line, including ones that won’t release anything — remember that.

Station 1 — two safety gates. First: is this commit from the release bot itself? The line’s final stations push a commit back to main (you’ll see why), which would re-trigger the workflow and loop forever — so bot-authored pushes are turned away at the door. Second: did main move while we were queued? If someone else merged in the meantime, releasing now would ship a version that doesn’t match what we scanned. The line compares its checked-out commit against main’s current tip and aborts rather than releases stale code.

Station 2 — the brain. The tool walks the git history from the last release tag to now and applies the label rules. This is the entire decision, and you can watch it think in the logs:

INFO found 9 previous tags
INFO The last full version in this branch's history was 2.4.0
INFO Found 2 commits since the last release!
INFO The type of the next release release is: patch
2.4.1

A fix: in the pile, no feat:, no breaking footer → patch → 2.4.1. No meeting, no debate, no “feels like a minor to me.”

Station 3 — the stamp. The new number is written into pyproject.toml, a changelog entry is generated from those same commit messages, and both changes are committed and tagged v2.4.1 — authored by the bot identity that Station 1 knows to turn away next time.

Station 4 — the build. The package is built into a wheel and sdist. One config line (build_command) — the tool runs your build for you as part of the release.

Station 5 — the shelves. Two of them, for two audiences: a GitHub Release (where humans browse changelogs and download builds) and the package index (where pip/uv install from). The 2.4.1 wheel lands on both.

The skip path. Now, what if the merged commit was docs: fix typo? Station 2 decides no release — and the whole line shuts down early, before the build. Every downstream station must therefore be gated on “did we actually release?”, or it’ll go looking for packages that were never built. Our workflow got exactly this wrong, in a way that stayed invisible for months — that’s the other post.

The human contract#

Notice what the machine never does: it never exercises judgment. It executes your judgment, as encoded in your labels, mechanically and forever. That moves the fragile part of releasing out of the YAML and into the team’s habits. Three rules keep it standing:

Rule 1: never hand-edit what the machine owns. The version in pyproject.toml, the git tags, the changelog — the robot writes all three, and it computes each release from the previous one. Hand-editing them is like moving the hands of a self-winding watch: it doesn’t just show the wrong time, it desyncs the mechanism. If the version needs to change, say so in commit labels — that’s the only input the machine reads.

Rule 2: know your merge strategy — this is where teams actually get burned. With merge commits or rebase-merge, every individual commit lands on main and gets its label read. With squash merge, all the commits in a PR are crushed into one, and its message defaults to the PR title. The inner labels are discarded with the boxes; only the outer label on the crate survives. A team that squash-merges with casual PR titles has disconnected the entire machine without knowing it — months of merges, zero releases, and nobody can say why. If you squash, your PR titles are your conventional commits, and need the same discipline.

Rule 3: the label is a promise to strangers. Somewhere, a repo you’ve never heard of pinned <3 on your package, trusting your team to know a lock-change from a hinge-fix. Every commit message is you keeping — or quietly breaking — that promise. That’s the real reason “fix or feat?” is worth ten seconds of thought at commit time. It was never about pretty git logs.

The punchline#

Setting this up is genuinely an afternoon: a workflow file, a config block in pyproject.toml, the tool as a dev dependency, two secrets. The machine part is easy and it stays easy.

But automation didn’t remove the human judgment from releasing. It relocated it — from a release manager’s checklist, once per release, to every commit message every teammate will ever write. The version number stopped being something someone picks, and became something the whole team continuously is. The robot just does the arithmetic.