2026-07-24 · 8 min read · Raw Drafts / Versioning / Product / App Store / Engineering
The Build Shipped. The Number Didn't.
Two camps of version numbering, one App Store rejection, and the quiet story every release tells. What happens when a marketing version is a promise only memory enforces.
The build compiled. The tests passed. Xcode Cloud did its slow, expensive thing on a Mac I will never see, signed the app, and handed it to Apple. And then, at the very last step — the step where nothing is supposed to go wrong anymore — it bounced:
90186: Invalid Pre-Release Train. The train version '1.5.1' is closed for new build submissions.
90062: The value for key CFBundleShortVersionString [1.5.1] must contain a higher version
than that of the previously approved version [1.5.1].Nothing was broken. The code was fine. The robots were happy. What stopped the release was a number I had typed by hand months earlier and then, understandably, forgotten. "1.5.1" had already shipped once, been approved, and quietly closed the door behind it. The train had left. I was standing on the platform holding a byte-for-byte newer app that the store considered *older*, because the only thing the store looks at — the number — hadn't moved.
I want to talk about that number, because it turns out to be one of those small, boring things that is secretly a whole philosophy.
There are two numbers, and only one of them is for you
Every iOS app carries two versions, and Apple is unusually honest about the split. There is `CFBundleShortVersionString` — the "marketing version," the `2.1` you see on the App Store page. And there is `CFBundleVersion` — the "build," a private tally that only needs to keep going up. One is a story told to humans. The other is bookkeeping for machines.
My mistake lived exactly in the gap between them. My CI faithfully stamped a fresh build number on every upload — an incrementing integer nobody reads. It never touched the marketing version, because I had decided, at some point, that *that* number was a human decision. A thing I would remember to change when the release "meant" something.
I did not remember. The bug was never in the code. The bug was that a story about the product lived in a file only I knew to edit.
And that would be the whole lesson — *automate the number you keep forgetting* — except that fixing it forced me to answer a bigger question I'd been avoiding. Because I don't have an app. I have a small constellation of them.
One idea, six surfaces
The thing I'm building is a map of Wi-Fi — where it is, whether it's any good, how to get on it. But "the thing" is not one artifact. It's a phone app for people looking for a network. A second app for the venues who own those networks. A lighter café version of that. A menu-bar app for the Mac. A website. A browser extension. Seven-ish surfaces, each a different window onto the same idea, each shipping to a different store with a different clock.
So: what number do they all wear?
This is where I discovered the two camps, and that I'd been standing between them without choosing.
Camp one: everyone gets their own number
The first camp says a version number belongs to *the app*, and only that app. It moves when that app moves, and sits still when it doesn't. If the phone app gets a feature and the admin app doesn't, the phone app goes to 2.4 and the admin app stays at 2.1, and that's not a mismatch — that's the truth.
This is what most consumer software actually does, even the software you'd assume is coordinated. Open the About screen on Slack for iOS and Slack for desktop; they won't match. Spotify's apps drift. 1Password on your phone and 1Password in your browser are on different numbers, right now, as you read this. Nobody minds, because nobody is cross-referencing them. The number answers one question — *what changed in this thing?* — and answers it honestly.
The cost is that no one can tell you what version *the whole thing* is. There is no "the whole thing." There's a phone app at 2.4 and a website deployed twelve minutes ago and an extension pending review in a queue you don't control. If a user files a bug and says "I'm on 2.1," you have to ask "2.1 of what?" The product, as a single object with a single state, doesn't exist. It's a family that happens to share a surname.
Camp two: everyone wears the same number
The second camp says the version belongs to *the release* — the moment — and every app wears it like a uniform. When you ship, everything ships, all stamped with the same string, whether or not each piece actually changed.
This is the camp of tightly-bound software. JetBrains puts `2024.1` on every IDE it makes; PyCharm and the others move together, on a calendar. Angular famously dragged every one of its packages onto a single shared number on purpose, so that "which versions of these go together?" would stop being a question. Microsoft's Office apps march in lockstep within a release channel. Ubuntu doesn't even pretend it's about features — `24.04` is just *April 2024*, wearing a version number's clothes.
Here the number stops describing an app and starts describing a *time*. It's less "this is what changed" and more "this is the season we're in." Which is genuinely useful: a support person can ask "what version?" and get one answer that locates the user in your whole history. Compatibility becomes trivial — same number, meant to work together. The product exists as a single object again.
The cost is a small, recurring lie. To keep the uniforms matching, you ship apps that didn't change — push a build, write a changelog that says nothing, send it through a review process that does not care that nothing is different — purely so a number can stay in formation. You've turned the version into a group photo, and now everyone has to show up even if they have nothing to say.
The thing neither camp admits
Here's what I found underneath both: even the unified camp is pretending, a little.
Apple ships iOS, watchOS, and macOS together every autumn — the tightest release train in the industry — and still doesn't give them the same number. iOS 18, watchOS 11, macOS 15. They align the *cadence* and let the integers diverge, because forcing the integers to match would be a fiction nobody needed. And underneath *any* scheme, the App Store quietly insists on its own truth: that private build number, per app, must always climb, and can never repeat. You can paint "same version everywhere" across the storefront, but the machinery beneath it refuses to be unified. It counts each app separately, forever.
Which means the choice between the two camps was never really about correctness. Both are true; they're just true about different things. Independent numbering tells the truth about the *code*. Unified numbering tells the truth about the *release*. One answers "what changed?" The other answers "when did you ship?" The right pick is simply: which question do you actually get asked?
For me, the answer was obvious the moment I said it out loud. Nobody asks me what changed in the café app specifically. They ask what "WiFi.ee" is up to. The thing I'm building is experienced as one thing, so it should probably *count* as one thing.
What I did, and the part that actually mattered
So I picked the second camp, with a calendar flavor — one product version, shared across the surfaces, moved on release rather than per-feature. But that decision, the philosophical one I'd been agonizing over, turned out to be the smaller half of the fix.
The larger half was this: the version I keep forgetting should not be a number I type.
The build number never got stuck, all those months, for exactly one reason — no human was in its loop. CI read it from somewhere and stamped it, every time, without asking me to remember anything. The marketing version got stuck because I had lovingly reserved it as a *human decision*, which is another way of saying I had built a single point of failure and the single point was me.
So the real change was a boring one. One source of truth for the product version. The build reads it and injects it — the same way it already injected the number that never failed — instead of trusting me to open a project file at the right moment with the right intention. In Xcode Cloud it's one line I didn't have before, sitting next to the one that was already quietly saving me:
xcrun agvtool new-marketing-version "$PRODUCT_VERSION"That's it. That's the whole repair. The camps make for a better argument, but the outage was never caused by the philosophy. It was caused by leaving a promise in a place where only memory enforced it.
The number is a promise
I've come to think a version number isn't a fact at all. It's a promise — to the store, to your users, to the version of you doing support at 11pm — about what this thing is and how it relates to the last thing. Independent or unified is just a choice about *who* you're making the promise to: the code, or the release.
But a promise you have to remember to keep is not really a promise. It's a hope. The whole discipline, whichever camp you join, is to take the number out of your hands and give it to the machine — so that the story your product tells about itself keeps being told even on the day you forget to tell it.
The train was closed. The build was fine. The only thing that had aged was a number I'd left somewhere only I could reach. I moved it into the light, and let the machine hold it instead.
Related reading
AI authorship note
This essay began with Kris Haamer hitting an App Store rejection for a closed pre-release train after shipping a newer build under an already-approved marketing version. AI supported structuring the two camps of version numbering, drafting, editing and implementation. Kris supplied the WiFi.ee multi-surface context, chose the unified calendar-flavored version strategy, and approved the essay for publication.