Version Numbers Are a Promise

Here’s a version number: 1.3.3.

Where does it live? In the app’s Info.plist, stamped at build time. In the GitHub release, tagged at publish time. In the Release Manager’s configuration, updated after a successful release. In the local git repository, as a tag on a commit. In the cached JSON file on disk, persisted between sessions.

That’s five places. Five opportunities for them to disagree.

Last week, the Jorvik Notes Editor had version 1.3.2 in the installed binary, 1.3.3 in the Release Manager’s configuration, v1.2.8 as the highest git tag, and 1.3.4 in the GitHub release that was created after a subsequent fix. Four numbers in four places, none of them the same.

The app worked fine. The blog posts deployed correctly. The editor was perfectly functional. But the version was a lie — or rather, it was four different truths that couldn’t all be right simultaneously.

What a version number is supposed to mean

Semantic versioning gives us a contract: MAJOR.MINOR.PATCH. Major means breaking changes. Minor means new features, backward compatible. Patch means bug fixes. The number goes up, monotonically, and each increment means something specific about the relationship between this version and the previous one.

For a solo developer shipping free utilities, the contract is simpler. The version number means: this is the Nth iteration of this thing, and it’s the one you should be running. If the installed version matches the released version, you’re current. If it doesn’t, you’re behind (or, more worryingly, ahead — running a build that was never released).

The version number is a coordination mechanism between the build, the release, and the user’s machine. It’s how the update checker knows whether to notify. It’s how the system audit knows whether the installation is consistent. It’s how I know, at a glance, whether the thing running on my Mac is the thing I published to GitHub.

When the numbers disagree, the coordination breaks. The update checker either nags about an update that doesn’t exist or stays silent about one that does. The audit shows red where it should show green. And I lose confidence in the one thing version numbers are supposed to provide: a reliable answer to “is this the right build?”

How the numbers diverge

The divergence is never dramatic. It’s always a small thing — a step that didn’t complete, a callback that didn’t fire, a tag that wasn’t created.

Build without release. You run a build-and-test pipeline to verify your changes compile and pass. The build stamps the version into the Info.plist. But you don’t proceed to release. The built binary has a version number; the released binary has a different one (or doesn’t exist yet). If you then copy the built binary to /Applications for testing, your installed version disagrees with the released version.

Release without tag. The release pipeline uploads the zip to GitHub, but the local git tag creation fails — perhaps the working directory had uncommitted changes, or the tag already existed from a previous attempt. GitHub shows the release. Your local repo doesn’t have the tag. The audit’s source check, which compares local tags to the configuration, reports a discrepancy.

Config update without release. The Release Manager’s version sync pulls the latest release tag from GitHub on launch. If a release was partially created — perhaps the upload succeeded but the release creation returned an error on the first attempt — the sync might pick up a version that was never fully published. The config says one thing; GitHub’s release list says another.

Tag without release. You create a git tag locally but don’t run the pipeline. Or the pipeline fails at the notarisation stage and you fix the issue and re-run, creating a new tag. Now there are two tags — the failed one and the successful one — and depending on how you query for the “latest” tag, you might get either.

Each of these scenarios is individually unlikely. Together, over the course of shipping seventeen apps across dozens of releases, they add up.

The git describe trap

One specific divergence deserves its own section because it’s a trap that looks like correct behaviour.

git describe --tags --abbrev=0 is the standard way to find the most recent tag on the current branch. It walks backwards from HEAD through the commit graph and returns the first tag it finds. The problem is that “first” here means “nearest ancestor by commit distance,” not “highest version number.”

Consider this history:

commit A ← v1.1.5
commit B
commit C ← v1.1.4
commit D ← HEAD

If the tag v1.1.4 was created on a commit that’s closer to HEAD than v1.1.5, git describe returns v1.1.4. The higher version tag exists. It’s on a commit in the history. But it’s further away in graph distance.

This sounds like an edge case, but it’s not. It happens whenever you create a fix release on a branch and then merge, or whenever you tag retroactively, or whenever tags are created by different processes (manual vs automated) at different points in the commit history. I hit it with the Release Manager itself: v1.1.5 existed as a local tag, but git describe returned v1.1.4 because v1.1.4 was topologically closer to HEAD.

The fix is to stop asking “what’s the nearest tag?” and start asking “what’s the highest version?” — which is a different question with a different command:

git tag -l 'v*' --sort=-v:refname | head -1

This lists all version tags, sorts them by semantic version in descending order, and returns the first one. It doesn’t care about graph topology. It just gives you the biggest number. That’s what I actually wanted. It took an embarrassingly long time to realise I was asking the wrong question.

The five sources of truth

After sorting out the immediate problems, I sat down and mapped every place a version number exists for a given app:

  1. The built Info.plist — stamped during the Verify Build stage of the pipeline
  2. The GitHub release — created during the Release stage
  3. The Release Manager’s configuration — updated after a successful release
  4. The local git tag — created after the GitHub release
  5. The installed app’s Info.plist — whatever the user has in /Applications

In a successful release, the flow is:

Build (stamps plist) → Release (creates GitHub release + local tag) → Config update (via callback) → User installs (copies plist to /Applications)

If every step completes, all five sources agree. If any step fails, they diverge. And the divergence cascades — a config that says 1.3.3 when the installed binary says 1.3.2 makes the audit show red, which makes me think I need to update, which leads me to re-run the pipeline, which might overwrite a perfectly good release with an identical one.

The solution isn’t to eliminate the redundancy. The version needs to exist in all five places because each one serves a different purpose. The build plist is what the running app reports. The GitHub release is what the public sees. The config is what the Release Manager’s audit checks against. The git tag is what the source check uses. The installed plist is what the user has.

The solution is to make the pipeline atomic — or as close to atomic as you can get when the “transaction” spans a local filesystem, a git repository, a GitHub API, and a configuration file.

Making it reliable

The Release Manager now does this in order:

  1. Build and stamp the version into the plist
  2. Sign and notarise
  3. Package with the correct display name
  4. Upload to GitHub as a release
  5. Create a local git tag (force-overwriting if the tag already exists from a failed attempt)
  6. Call onVersionReleased to update the configuration
  7. Save the configuration to disk

Steps 4 through 7 happen in the release stage, in that order. If step 4 fails, steps 5-7 don’t execute. If step 5 fails, the release exists on GitHub but the local tag is missing — this is acceptable because the audit’s GitHub fallback will find the release anyway. If step 6 fails (which would require a bug in the callback wiring), the config is stale but the next launch’s version sync will correct it.

The callback — onVersionReleased — was the piece that was missing. The pipeline engine invoked it, but the store never connected it. So steps 1-5 worked, step 6 silently did nothing, and the config stayed at whatever version it had before the release. This meant the audit would show a version mismatch until the next launch triggered a GitHub sync.

One line of code to wire the callback. Hours of confusion about why the version was sometimes right and sometimes wrong.

The promise

A version number is a promise to three parties.

To the user, it promises that 1.3.4 is newer than 1.3.3, which is newer than 1.3.2, and that updating from any lower number to this one will give them the latest code. If the number is wrong, the update checker lies to them.

To the developer, it promises that the code tagged v1.3.4 in git is the code that was built, signed, notarised, and uploaded as release 1.3.4 on GitHub, and that the binary in /Applications with 1.3.4 in its plist is the product of that exact pipeline run. If any link in that chain is broken, you can’t reproduce what the user is running.

To the tooling, it promises a consistent, comparable, sortable identifier that can be used for automated checks. The audit, the update checker, the version sync — they all depend on the assumption that the number means the same thing everywhere it appears.

Breaking that promise doesn’t cause a crash. It doesn’t cause data loss. It doesn’t even cause a visible bug, most of the time. It causes something worse: uncertainty. “Is this the right version?” shouldn’t be a question you have to answer by reading five different sources and comparing them manually. The number should just be right, everywhere, always.

That’s what the pipeline is for. Not just to automate the tedious parts of shipping, but to ensure that when I say an app is at version 1.3.4, it actually is — in the binary, in the config, in the tag, in the release, and in the user’s /Applications folder.

Version numbers are cheap to assign and expensive to verify. The assignment takes one line of code. The verification takes a system audit, a GitHub API query, a git tag sort, a plist read, and a process table scan.

I’d rather automate the verification than trust myself to get it right manually. Because I’ve tried trusting myself, and the Notes Editor ended up at four different versions simultaneously. That’s not a version. That’s a multiple-choice question.