iOS versioning
iOS versioning is a seemingly simple problem. All you need to do is give your app a version number and you’re done, right? Not quite. In this post, I’ll explain the rules for versioning your iOS app, how Twitch uses build numbers to help debug TestFlight builds, and how to avoid changing your Info.plist file just to update your versioning.
The Rules
Every app you send to Apple has a version number (i.e. “3.9.2”, the number your customers see on the App Store) and a build number (i.e. “2861.41020488”, which differentiates new builds you supply for the same version). Apple defines these terms in Technical Note TN2420, including the following rules:
[A]s you create new releases, new Version Numbers must be added in ascending sequential order.
Build Numbers must be unique within each release train, but they do not need to be unique across different release trains.
[A]s you create and submit new builds for a release, the Build Numbers you assign to them must be in ascending sequential order.
However, Apple doesn’t define their required format, but we know from the TN2420 that CFBundleShortVersionString holds the version number and CFBundleVersion holds the build number, and their Info.plist documentation defines their required format:
CFBundleShortVersionString … The release version number is a string comprised of three period-separated integers.
CFBundleVersion … The build version number should be a string comprised of three non-negative, period-separated integers with the first integer being greater than zero. The string should only contain numeric (0–9) and period (.) characters. Leading zeros are truncated from each integer and will be ignored (that is, 1.02.3 is equivalent to 1.2.3).
Additionally, documented only on stackoverflow:
The value for key CFBundleVersion … in the Info.plist file must be no longer than 18 characters.”
Using Build Numbers for Debugging
The version number is visible to customers. If the currently released version is 1.1, releasing a version 2.0 signals a larger release than 1.2 or 1.1.1, so Twitch product owners decide what version number to use based on what we include in the new release. Build numbers aren’t user visible, so Twitch engineers embed the git commit short revision within the build number.
When Twitch engineers submit a new version to iTunes Connect, we tag that version in git. However, we release many more builds to TestFlight for internal testing. We don’t want to tag every one of those builds because it will clutter our git tags with hundreds of quickly ephemeral version numbers. Instead, we can use the git commit short revision within the build number to link a crash (which contains the build number of the crashing app) back to the exact source that produced the crash.
How to Create Build Numbers Like Twitch
The build number is a key in Info.plist. If the Info.plist is a static file, there must be a new git commit every time it changes. Thus, we can’t embed the current git commit into a static Info.plist directly because we’ll have a chicken-and-egg problem (which comes first: the Info.plist with the current git commit inside, or the current git commit with the Info.plist inside?). Xcode can dynamically generate the final Info.plist file using the C preprocessor* by setting “Preprocess Info.plist File” (INFOPLIST_PREPROCESS build setting) to “YES”. We can also use the “Info.plist Preprocessor Prefix File” (INFOPLIST_PREFIX_HEADER build setting) to prepend our git commit variable (TWBundleShortVersionString) into our Info.plist. These two settings turn our Info.plist file into a template that Xcode will use to generate the final Twitch.app/Contents/Info.plist file at build time.
According to the rules above, the build number must only contain decimal numbers separated by up to two periods, but a git commit is a hexadecimal number, so we can’t use git’s $Id$ keyword expansion within Info.plist. Also, the build number must use unique with increasing numbers for each new binary we submit to iTunes Connect. Thus, every build includes the number of minutes from an epoch — which is unique and increasing — as its major build number. Then, as its minor build number, we decimalize the git commit, and prepend a “1” onto it in case it starts with a leading “0”:
decimalize_git_hash.bash
The build number can be only 18 characters long, and we must use 1 character for our period to separate our major and minor versions, so we have 17 characters remaining for data. git-rev-parse defaults to seven characters for a short revision, so if we decimalize the largest possible seven-character hexadecimal with a leading “1” (0x1fffffff) we get 536,870,911 (9 characters long), so we have 8 characters remaining for our number of minutes.
It would be convenient to use the Unix epoch as our build number epoch, but 24,560,967 minutes (eight characters worth) have passed since then, so while we don’t NEED to define a new epoch, it makes me feel a little safer knowing there’s a buffer. It’s safe to define a new epoch for every version number since build numbers only need to be unique within a given version. However, 99,999,999 minutes (seven characters worth) is slightly less than 190 years, so we don’t need to update our epoch too often.
minutes_since_date.bash
Finally, we write our version number and build number into $SRC_ROOT/Versions/versions.h, which we prepend to Info.plist with “Info.plist Preprocessor Prefix File” (INFOPLIST_PREFIX_HEADER build setting) as mentioned above.
versions.bash
Our final version number looks like
2861.410204888
Where “2861” is the number of minutes from our epoch and “410204888” is our decimalized git commit short revision. We have a script for converting this build number into a real git commit short revision.
git_hash_from_cfbundleversion.bash
Build Phase, Dependent Target
We use versions.bash in an Xcode Run Script Build Phase to update $SRCROOT/Versions/versions.h (which we ignore in git) with every build. Each build number is unique as long as they are triggered at least one minute apart. Unfortunately, Xcode preprocesses Info.plist as the first step of a target (even before your target’s first Run Script Build Phase). To work around this problem, we use an Aggregate Target with only a Run Script Build Phase that runs versions.bash.
Then we make our app’s target dependent on the Versions target.
A Build System Hack
According to @tblodt:
the preprocessor headers are completely ignored as dependencies and changing them either manually or from a script won’t rerun the preprocessor, in both the new and old build system
But there’s a workaround:
make the script touch Info.plist
Twitch only needs this script to work on our continuous integration system, and our continuous integration system always makes a clean build, so it wouldn’t see caching issues like these.
Final Thoughts
Using Xcode’s Info.plist preprocessing and a bunch of bash, you too can have unique, incrementing, and meaningful build numbers for your apps without polluting git with a new commit just to change a version number.
* Warning: while “Preprocess Info.plist File” (INFOPLIST_PREPROCESS build setting) gives you the power of the C Preprocessor, you should still keep your files syntactically valid plist xml or Xcode won’t be able to render them in the Info.plist viewer or in the project general settings pane. Using the C Preprocessor only for variable substitution as described above is safe and syntactically valid plist xml.
In response to auibrian’s comment, I clarified how we use a dependent target to run versions.bash in the Build Phase, Dependent Target section.
In response to Paul Lettieri’s comment, I fixed a typo in the number of characters required for minutes since the epoch.
In response to @tblodt on twitter (end of thread), I noted that you must touch the Info.plist file for the build system to update the version value after a build.