A version string looks like a single fact and
is really two promises with two different audiences. The part a human
reads — 1.9.1 — is a claim about how much has
changed since last time, and it is aimed at a person. The part bolted on
after it — the +74 — is a monotonic ticket the
app store uses to decide which upload is newer, and it is aimed at a
machine. We have learned to keep the two apart on purpose, down to who is
even allowed to change each one. The marketing number is a promise only a
human gets to make; the build number is a receipt an agent stamps on
every cut. Blur that line and you get one of two failures: a store
rejection, or a version history that quietly lies about what it
contains.
Two numbers wearing one costume
Start with the anatomy, because the confusion
begins in the syntax. In a Flutter pubspec the version is a
single line, and it reads like 1.9.1+74: everything before
the plus is the marketing version, and the integer after
it is the build number. That one line then fans out into
two fields per platform. On iOS it becomes
CFBundleShortVersionString (the string a user sees) and
CFBundleVersion (the build). On Android it becomes
versionName (the string a user sees) and
versionCode (the build). One authored line, four rendered
fields, and a hard split running down the middle of all of them: the
user-facing half is a string that can say almost anything, and the
machine-facing half is an integer whose entire job is to go up.
That split is not cosmetic. The two halves are read by different readers, for different reasons, at different times — and the mistakes we have actually made all come from forgetting that and treating the pair as one number you bump together whenever it “feels like a new version.” It is worth pulling them apart and looking at each one on its own terms.
The promise: a number only a human gets to make
The three-part marketing version is a message, and semantic versioning spells out the grammar. Bumping the last digit says “we fixed things and nothing you rely on moved.” Bumping the middle one says “there is something genuinely new, and it will not break you.” Bumping the first says “the shape changed; read the notes.” That is a promise to a person about the size of a change — and like any promise, it is only worth something if it is not made carelessly. Inflate it on a whim and the number stops meaning anything; every release starts looking major and the signal drowns.
So in our studio the marketing version does not move on its own. An agent that cuts builds all week never touches it. “Ship it,” “go to production,” “the sprint is done” — none of those authorise a bump, and that is deliberate, not an oversight. Only an explicit human instruction — “cut this as 1.10,” “bump to 2.0” — moves the promise, because only a human is in a position to make it. Absent those exact words, the default is to leave the marketing version precisely where it was and advance the build number underneath it. The honest cost of this rule is a little friction: now and then a genuinely notable release sits under an unassuming number for a day because nobody said the magic words out loud. We take that trade every time, because the failure it prevents — a version line that inflates itself — is the one you cannot walk back.
The receipt: a number the machines actually read
The build number has no opinion about how big
a change is. Its only contract is to be larger than the last one the store
saw. Google Play is blunt about it: the version code is the integer that
orders your uploads, higher meaning newer, and it will not accept one it
has already seen. Apple uses CFBundleVersion the same way to
tell iterations of a build apart within a marketing version. Crucially,
neither of these is the number a shopper reads on the listing — they
exist so the distribution system can put your uploads in order, nothing
more.
Which is exactly why an agent can own this one outright. Every staging or production cut bumps the build by one, monotonically, no permission required, because there is no promise at stake — only a counter to advance. There is no judgement call in “the next integer is one higher than the last integer.” A machine is perfect at that and a human doing it by hand is just a slower, more error-prone machine. The whole point of separating the two numbers is that this one can be fully automated precisely because it means so little on its own. A build number is a receipt, not a headline.
When the two stores disagree
Here is the trap that actually bites. Because iOS and Android are two independent pipelines, it is tempting to let their build numbers drift — Android sitting at 74, iOS at 71, whatever each happened to reach on its own. It works right up until it does not. Play tells you a version code has already been used, you bump Android to 75 and move on, and now the very same release exists as build 75 on one store and 71 on the other. Six weeks later you are holding a crash report tagged “build 73,” and there is no honest way to know which platform’s 73 it means. The number that was supposed to identify an exact artifact now identifies two different ones.
So our rule is unforgiving on purpose: the build number is a single fact shared by both stores, and if one platform forces a bump, the other is re-cut and re-uploaded at the new number even if its own upload had already succeeded. A build you only had to re-cut because its sibling moved is a mild annoyance for an afternoon. A release history where the two stores silently mean different artifacts by the same integer is a debugging tax you keep paying for the life of the app. This is one of the seams we mapped in one codebase, two stores: the code is genuinely shared, but the numbers on the far end have to be kept in lockstep by hand, because nothing about “one codebase” makes two separate store consoles agree with each other.
Why we took the promise away from ourselves
The deeper reason this is a rule and not a loose preference: automation is very good at making a number go up and very bad at knowing whether it should. An agent advancing the build number is doing bookkeeping. An agent advancing the marketing number would be making a claim to users on its own authority — and a piece of software announcing “this is a major release” about itself is not a claim software is in any position to make. So we split ownership at exactly the line where judgement begins. The machine owns the receipt because a receipt is mechanical. The human owns the promise because a promise is a decision.
It is the same instinct behind keeping a staging build byte-identical to the one we ship, which we wrote about in staging has to lie exactly like production — the things that must not drift are the things you deliberately remove from casual reach. And it is the mirror image of the identifiers a rename can never touch, from the hidden cost of renaming a shipped app: a bundle ID or package name is frozen for the life of the listing, the build number is engineered to move on every single cut, and the marketing version moves only when a person decides it should. Three kinds of number, three different rules about who may change them and how often — and most release accidents are just one of those numbers being handled by the wrong rule.
So we treat the version string as what it actually is: a promise and a receipt, stapled together and read by audiences who never overlap. Keep the receipt monotonic and identical across both stores and the machines stay happy and the crash reports stay legible. Keep the promise in human hands and it keeps meaning something to the person on the listing. Every versioning failure we have actually hit reduces to confusing the two — inflating a receipt into a headline, or letting a promise ride on autopilot. A build number should be free to run; a version number should cost a human decision. The rest of how we try to keep our releases honest lives on the WizusLabs Engineering blog.
Notes
This is a first-hand account of one
studio’s release-versioning conventions for its own apps, not a
universal standard. It contains no metrics — no counts of
releases, no rejection rates, no time saved — because we did not
measure those things and inventing them would defeat the point of the
piece. The version format shown (a Flutter pubspec line like
1.9.1+74 splitting into per-platform fields) and the store
behaviours described — that Google Play will not accept a version
code it has already seen, that Apple orders build iterations by
CFBundleVersion — are documented by the platform and
tooling owners and cited below; store and SDK rules change over time, so
verify the specifics against the current documentation before relying on
them. The ownership split itself — that a human authorises the
marketing version while automation advances the build number, and that the
build number is re-cut to stay identical across stores — is our own
working rule, recorded in our engineering notes, not a platform
requirement.
Sources
The version-field semantics this post relies on are documented by the platform and tooling owners (living documents; accessed 2026-07-24):
- Apple — CFBundleShortVersionString: the user-visible release version string (the marketing version) shown against the app.
- Apple — CFBundleVersion: the build version that identifies an iteration of the bundle and is used to distinguish and order builds within a release version.
- Android Developers — Version your app: that
versionNameis the user-visible version string, whileversionCodeis a positive integer Google Play uses to determine which release is more recent and which must increase with each update. - Dart — pubspec version field: the
major.minor.patch+buildformat that Flutter maps onto the per-platform version and build fields. - Semantic Versioning 2.0.0: what incrementing the major, minor, or patch component communicates about the size and compatibility of a change.
Keep reading: all posts on the WizusLabs Engineering blog.