Shipping

Version state belongs in exactly one file

A version repeated across build files, manifests and tags is one fact with several caches. Decide which copies are derived and forbid everything else.

MobileByteSensei 10 min read
One master gauge block, and the working gauges that are only ever set from it
releaseconventionsautomation

Count where a version number appears in a project that ships to app stores. The build file. The platform manifest. The second platform's manifest. A release note. A tag. A changelog heading. A constant somewhere in the code that renders it on a settings screen.

Seven places, one fact, and no agreed answer to which one is true.

The case this post is about

One version number, and the question of which copies of it are allowed to exist:

$ bump minor
  marketing 2.3.1 -> 2.4.0
  build     418   -> 419
  schema    7     (unchanged)
$ version-for store   2.4.0 (419)
$ version-for db      7

Before one manifest owned it, the marketing version appeared in a build file, a plist, a release note template and a dashboard constant. All five agreed, because somebody had updated all five.

The interesting part is the third line. Schema version is also a version and must not move when marketing does — and in the five-copy world it had been bumped in sympathy twice, which is a database migration nobody wrote.

This post follows those three fields, because they have different rules and a single "the version" hides that.

The symptom

They agree, right up until a release goes out at two different numbers on two platforms.

That is the headline failure and it is not the common one. The common one is quieter: a release happens, everything ships, and one of the seven copies was never updated — usually the one furthest from the build, like the string rendered on an about screen. Nothing fails. The build does not care. The store does not care. A user sees a version that has not existed for three months, and the only person who will ever notice is the one trying to reproduce a bug report.

Working gauges that have each drifted a little, and no longer agree with one another

The second shape is the interrupted bump. A release is prepared, some copies are updated, something goes wrong, and the release is abandoned. Now the repository is in a state no single commit intended: three files say the new version and four say the old one. There was no mistake, exactly — there was a multi-step edit that does not have a transaction around it, and it got interrupted in the middle, which a multi-step edit eventually will.

The third is what it does to automation. Anything that wants to know the version — a CI job, a release note generator, a crash reporter, a deploy script — has to pick a file to read. Each of them picks a reasonable one and they do not all pick the same one. Now the question "what version is this" has several answers depending on who is asking, and because they agree most of the time, the disagreement surfaces as an inexplicable one-off.

The fourth is the archaeology. Six months later, you need to know what version a particular commit shipped as. The answer is in whichever of the seven was authoritative at the time, which nobody recorded, and the copies that disagree give you no way to tell which was leading and which was lagging.

Why the obvious fix failed

A pre-release checklist. Every project has one and it works until the day somebody is shipping a hotfix at speed, which is exactly the day two numbers diverge. A checklist that enumerates seven files is also a document that goes stale the moment an eighth appears — and nothing notices that the eighth is missing from it.

A script that updates all seven. Materially better, and it has a subtle problem worth naming: it makes the seven copies easy to maintain rather than unnecessary. The duplication is still there, the script now has to know about every consumer, and adding a new consumer means remembering to add it to the script — which is the original problem, moved one level up and made less visible.

Derive everything from the git tag. Elegant, and it breaks in the place that matters most: you need the version before the tag exists. The build that produces the artifact you are about to tag has to know what to call itself, and at that moment the tag is not there. Tag-derived versioning works beautifully for everything downstream of the tag and cannot answer for the build itself.

Let the store be the source of truth. It is where the number actually matters, and it is unreadable at build time, different per platform, and subject to review delays. You would be asking an external, eventually-consistent system a question you need answered synchronously and offline.

The actual mechanism

Keep the version in one file that nothing builds from directly, and make every other appearance of it generated.

The second half is what people skip. A single source of truth with six hand-maintained copies is not a single source of truth — it is a convention with six opportunities to drift. The copies have to be derived, which means they are outputs, and outputs have a property hand-edited files do not: you can regenerate them and compare.

Every working gauge set against the master, not against the last one that was set

Three rules make it hold.

One writer. Exactly one tool bumps the version, and it writes to the manifest and nothing else. If two things can write it, you have two sources again with a race between them.

Every consumer reads, never writes. A build file, a CI workflow, a release script — all of them read the manifest. None of them is permitted to contain the literal. That is checkable: a scan for version-shaped literals outside the manifest finds every violation, and it finds them at commit time rather than at release time.

Derived files are regenerated and verified, not edited. The platform manifests, the about-screen constant, the changelog heading — generated from the one value. The verification is the cheap and valuable part: regenerate, diff against what is committed, and fail if they differ. That single check catches the interrupted bump, the forgotten seventh file, and the well-meaning hand edit, all with the same mechanism.

The sub-case that complicates this in practice is that not every version-shaped thing is the same version. A marketing version, a build number that must increase monotonically per upload, and a schema version are three different facts that all look like versions and have genuinely different rules — the build number cannot be derived from the marketing version because the store requires it to increase even when the marketing version does not change.

Three standards that look alike on the bench and govern entirely different quantities

Conflating them is the most common way this design goes wrong, and it goes wrong in the direction of a build rejected at upload after everything else succeeded. So the manifest holds them as separate named fields with their own rules, and the thing that makes it tractable is that they are still in one file: three facts, one place, rather than three facts scattered across seven.

The fix

VERSION_MANIFEST            ← the only hand-edited version state
  marketing, build, schema  ← separate fields, separate rules

one bump tool               → writes the manifest, nothing else
every consumer              → reads the manifest

The bump tool is the only writer, and the fields have different rules because they answer different questions — conflating them is what produces a build number that resets or a schema version that drifts:

type Manifest = { marketing: string; build: number; schema: number };

export function bump(m: Manifest, kind: "patch" | "minor" | "major" | "build"): Manifest {
  // BUILD is monotonic, forever. It never resets with a marketing change, because
  // stores reject a build number they have already seen.
  if (kind === "build") return { ...m, build: m.build + 1 };

  const [maj, min, pat] = m.marketing.split(".").map(Number);
  const marketing =
    kind === "major" ? `${maj + 1}.0.0` :
    kind === "minor" ? `${maj}.${min + 1}.0` :
                       `${maj}.${min}.${pat + 1}`;

  // SCHEMA is untouched here on purpose. It moves when the on-disk format
  // changes, which is unrelated to what users are told the version is.
  return { ...m, marketing, build: m.build + 1 };
}

/** Consumers READ. No consumer computes a version from another consumer's value. */
export const versionFor = (target: "store" | "db" | "api", m: Manifest) =>
  target === "store" ? `${m.marketing} (${m.build})`
  : target === "db"  ? m.schema
  :                    m.marketing;

A minor release, and what each consumer gets from the one write:

$ bump minor
  marketing 2.3.1 -> 2.4.0
  build     418   -> 419
  schema    7     (unchanged)

$ version-for store   2.4.0 (419)
$ version-for db      7

Schema staying at 7 is the point. A single version string forces every consumer to move together, so a marketing release implies a database migration that nobody wrote. derived files → regenerated from it a check → regenerate, diff, fail on difference a scan → version-shaped literals outside the manifest are an error


The scan is what keeps the design true over time, and it is worth being specific about why. Every
rule above can be followed perfectly today and quietly broken in six months by somebody adding a new
file with a version in it — reasonably, because they are writing a new consumer and typing the number
is the obvious thing to do. The scan turns that from a silent new source of truth into a failed
check on the commit that introduced it.

There is one more decision this forces that is easy to leave implicit: **when the bump happens.**
Bump at the start of release preparation and every commit between then and the actual release claims
a version that has not shipped, so a crash report from that window points at a release nobody can
find. Bump at the end and the artifact you just built carries the previous number, which is worse.

The resolution is that the bump is its own commit and nothing else rides with it. That makes the
window one commit wide instead of a working day, and it makes the question "which commit became
this version" answerable by looking rather than by remembering.

## How we proved it

**The manifest exists and is single.** Version state for the whole project lives in one file with
named fields, and the release tooling reads from it rather than from any build file.

**The regenerate-and-diff check is the real evidence**, because it has a demonstrable failure mode:
hand-edit a derived file and the check fails on exactly that file. A control that can be shown going
red on a deliberate violation is a control; one that has only ever been green is an assertion.

**What I have not measured** is drift prevented. The before state was several files that agreed, and
I have no record of how often they would have diverged, so I cannot give you a rate. What I can say
is that the failure class it removes — a release shipping at two numbers, a stale string on an about
screen — is one I have seen, and that the check costs nothing per commit.

**What I have not solved:** the store side. The build number has to increase monotonically against
what the store has already accepted, and the manifest does not know what the store has accepted. If
an upload is rejected and retried, or somebody uploads out of band, the manifest's idea of the next
build number can be behind reality, and the symptom is a rejection at upload with everything else
green. Reconciling against the store before a release is the obvious completion and I have not built
it.

![One value bumped, and every derived copy following from it](/posts/version-manifest-single-source/anim-derive.gif)

**And a limitation in the scan:** it looks for version-shaped literals, which means it has a view
about what a version looks like. A scheme that does not match that shape slips past it, and a file
full of version-shaped numbers that are not versions produces noise. It is a heuristic sitting
underneath an otherwise exact mechanism, and that asymmetry is worth knowing about rather than
trusting.

## What this generalizes to

The useful question about a duplicated fact is not *where should it live*. It is **which copies are
allowed to exist, and what makes them follow**.

Most "single source of truth" arrangements fail at the second half. The source gets chosen, written
down, agreed — and the existing copies stay exactly where they were, now with a document saying they
are not authoritative. A copy that is not derived is not a cache, it is a second source with a
politeness convention attached, and conventions lose to whoever is in a hurry.

So for each copy, pick one: delete it, derive it, or accept it as a genuine second fact with its own
rules and its own name. All three are fine. What is not fine is the fourth option that happens by
default — leaving it hand-maintained and calling the other one authoritative — because that is
indistinguishable from having solved the problem right up until the moment it is not.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS