Automatic Versioning with Xcode and Git

09 Jul 2024
13 minute read

When you upload a new app to the App Store, Apple requires you to have a version and build id. Additionally, you are required to format them correctly, and also increment them properly. Xcode doesn’t do this for you automatically, so lots of people have tried to come up with automated version schemes. This is my approach.

Logos for Xcode and Git dance on a background of branching version history.
Logos for Xcode and Git dance on a background of branching version history. Image by Dall-E.

The Problem

Lets say you get a crash report from one of your TestFlight users. You can download this crash from App Store Connect, or directly from Xcode, which is great. If the crash is properly instrumented, you can see exactly the line of code where your app crashed. Unfortunately, if the version that crashed is different from your current working version (and it probably is), the line numbers aren’t guaranteed to match, so it will show you the wrong line.

But you’ve got the version and build number, right? All you have to do is check out the version of the code that corresponds to that particular build. But Xcode won’t tell you that. Although Xcode has excellent integration with Git, it doesn’t go so far as to link your Apple version and build id with anything in Git.

Intended Audience

Before I go any further, I have a couple caveats. This particular implementation is something I came up with for myself, and it should work for anyone in my situation. That is:

  • You’re an independent developer, working on a relatively small code base
  • You’re developing for iOS
  • You’re using Git for version control

I haven’t tested this with Xcode Cloud. I have doubts it will work with that system. Apple seems to be pushing Xcode cloud pretty hard, but as a small independent developer I don’t see any advantage to not compiling my code on my own MacBook.

This system works for Xcode 15. It will probably work for Xcode 16, but I can’t guarantee that. Back in Xcode 13, Apple made some pretty big changes to how version information is saved in your app, and some of the older solutions out there are outdated as a result.

Source of Truth

There are two numbers associated with every version of every app on the App Store. The first is the Version, otherwise known as the Marketing Version. This is the dotted number most people are familiar with, eg ‘1.2.3’. The second number is the Build, otherwise known as the Bundle ID. This can be anything at all, although Apple restricts it to three numbers separated by dots. It doesn’t have to be three, though. 1

There are two different places where this metadata can be stored, in Xcode and in Git. That’s always a problem, when you have any data that’s stored in multiple places, because data that’s stored in multiple places can get out of sync. What happens if Xcode thinks you’re working on version 1.2.3 but Git thinks it’s 1.2.5?

The solution to the multiple-storage problem is to define a source of truth. You declare that one location holds the true value, and any other copy of the data must stay in sync with the source. But which should we use as the source? Some people out there think Xcode should be the source of truth, and the version metadata should be copied form Xcode to Git. Other people think that Git should be responsible for version metadata, and Xcode should follow Git’s lead. I think they’re both wrong.

Before we arbitrarily determine a source of truth, we need to ask ourselves, what does this data really mean? Data should be held in a place that’s relevant to that data. Therefore, I think it’s best to split responsibility. Xcode will be the source of truth for the Marketing (semantic) version, and Git will be the source of truth for the Build (bundle) version.

Why is that? Think about what these versions mean. The semantic version, the simple version like v1.2.3, is called the Marketing version by Apple for a reason: it’s user-facing. It’s what appears in the App Store. Users are used to version numbers like v1.2, and by now they know what to expect when a version increments from 1.2 to 1.3 or 1.3 to 2.0. This version data is for the users, so it makes sense to manage it in Xcode since Xcode is the interface to the App Store and your user base.

The Build version, on the other hand, is for you, the developer. The build can be a big ugly string of numbers like 4.2321.43956 and most users ignore it. A build is, for all intents and purposes, a commit in Git. Every build version should correspond to one and only one commit hash in Git. So it makes sense for Git to be the source of truth for build versions, and we’ll copy the commit hash over to Xcode when we want to label our app with a build version.

Three Scripts

We’ll handle the syncing of our version metadata with three scripts. I made a Scripts directory at the root level of my Xcode project and put them there, so you should do that if you’re following along.

The first script is sync_git_tag.sh and it handles syncing the Marketing version from Xcode to Git.

#!/bin/bash -euo pipefail

# Get the current version from Xcode project
xcode_version=$(xcodebuild -showBuildSettings | grep MARKETING_VERSION | tr -d "MARKETING_VERSION = ")

# Check if the current git commit hash has a matching tag
if [ -z "$(git tag --list "v$xcode_version")" ]; then
  echo "No matching git tag found for version $xcode_version. Creating a new tag."
  git tag -a "v$xcode_version" -m "Version $xcode_version"
else
  echo "Git tag for version $xcode_version already exists."
fi

When this script is run, it checks to see what you’ve set your Marketing version to in Xcode, and then it checks to make sure that that version is saved in Git as a git tag. If it doesn’t find one, it creates a new tag. Marketing versions are saved in Git as tags.

Note that we wouldn’t want to use tags for build versions, since that would clutter our git history with too many tags. But teh marketing version is perfect.

You set the Marketing version from Xcode, because Xcode is the source of truth for that. That’s done through the standard method, by going to [Target] > General > Identity > Version. When you update your marketing version is up to you. Everyone has their own standard and what’s right for one developer is not necessarily what’s right for everyone.

The second script handles the build version syncing, taking Git commit hashes and converting them to bundle versions. It’s called sync_git_build.sh

#!/bin/bash -euo pipefail

export GIT_DIR="$SRCROOT/.git"
COMMIT=$( git rev-parse --short=7 HEAD )

# decimalized git hash is guaranteed to be 10 characters or fewer because
# the biggest short=7 git hash we can get is FFFFFFF and
# printf "%d" 0xFFFFFFF | wc -c. # returns "10"
DECIMALIZED_GIT_COMMIT=$( printf "%d" 0x${COMMIT} )

# Adding --match 'v*.*.*' forces git to look for tags with a version spec, skipping over
# other tags used for eg marking branches
COMMIT_COUNT="$( git describe --tags --match 'v*.*.*' | cut -d - -f 2 -s )"
COMMIT_COUNT=$(( COMMIT_COUNT + 1 ))

# Divide the decimal into two parts for readability
# We don't want the second part (after the inserted period) to start with a zero since Apple will edit that out
# So search for a good place to split the decimal. Note, this will soft-fail if there's five zeros in a row
# In that case, no decimical point is inserted and you get a (still valid) bundle version dd.xxxxxxxxx
SEPARATED_DECIMALIZED_COMMIT="$( echo ${DECIMALIZED_GIT_COMMIT} | sed -E 's/^(.*)([1-9].{4,7})$/\1.\2/' )"

BUNDLE_VERSION="${COMMIT_COUNT}"."${SEPARATED_DECIMALIZED_COMMIT}"

# Override the bundle version in our compiled Info.plist
/usr/libexec/PlistBuddy -c "Set CFBundleVersion $BUNDLE_VERSION" "$CODESIGNING_FOLDER_PATH/Info.plist" ||
  /usr/libexec/PlistBuddy -c "Add CFBundleVersion string $BUNDLE_VERSION" "$CODESIGNING_FOLDER_PATH/Info.plist"

echo "Set Bundle version $BUNDLE_VERSION"

This one is a little more complicated so I’ll walk you through it.

We can’t use the commit hash directly, since Apple requires the build version to be three numbers, using the digits 0-9 only, separated by periods. Also, the build version must increment for each successive build, but you can reset it when you update your marketing version. (I beleive that’s only for iOS, though. MacOS builds must always increment over the life of the app).

What we’re going to do is convert the hash from base-16 to base-10. That will convert the hex digits into acceptable numbers, and also allow us to convert it back when we need to.

Line 4 gets the commit has of our current commit. This is our source of truth for the build version. Then line 9 uses the bash built-in printf to convert it from hex to decimal.

Lines 13 and 14 count the number of commits since the last time we updated the marketing version. Since that gets synced from Xcode, we just need to look for the latest tag that matches our “v1.0.0” naming scheme. This is guaranteed to increment each time we make a new commit, so if we use this at the start of our build number we will satisfy that requirement.

Line 20 splits our decimal version by putting another decimal point somewhere in the middle. I like doing this because it splits up a big decimal number into two smaller, more manageable numbers. If you ever need someone to read their build number to you, this will make it a lot easier. There’s a slight gotcha when doing this, though, because if we split the number such that the first part ends with a zero, that zero will get cut off by Apple, and that will mess things up if we try to convert it back to hex. So that complex regex searches for a good spot to split the version number up.

Line 22 assembles everything into a bundle version. So a git commit hash like 14af68f gets converted into 2.168.9999.

Lastly, we overwrite this bundle version into the app’s Info.plist. We can use the PlistBuddy tool to do this. Info.plist used to be a file in your Xcode project, but in Xcode 13 Apple changed things so that that file gets dynamically built from all your relevant settings, every time you build your project. Our scripts will run after Xcode builds the Info.plist, so if we overwrite the bundle version it will go into the app.

The third and final script, hash-from-bundleversion.sh, does the reverse operation from the second script. It takes in a bundle version and converts it back into a git commit.

#!/bin/bash -euo pipefail

if [ ${#} -eq 0 ]
then
# read from STDIN
MAYBE_CFBUNDLEVERSION=$( cat )
else
MAYBE_CFBUNDLEVERSION="${1}"
fi

MAYBE_DECIMALIZED_GIT_HASH=$( echo "${MAYBE_CFBUNDLEVERSION}" | sed -E 's/[[:digit:]]+\.([[:digit:]]+)\.?([[:digit:]]+)?/\1\2/' )

DECIMALIZED_GIT_HASH=$( echo "${MAYBE_DECIMALIZED_GIT_HASH}" | egrep "^[[:digit:]]+$" ) || {
echo "\"${MAYBE_CFBUNDLEVERSION}\" doesnt look like a CFBundleVersion we expect. It should contain two or three dot-separated numbers." >&2
exit 1
}

# convert to hex
GIT_HASH=$( printf "%07x" "${DECIMALIZED_GIT_HASH}" )

echo "${GIT_HASH}"

You can call this from the command line with a bundle version:

$ hash-from-bundleversion.sh 2.168.9999
14af68f

Tying it all together

Now we need to hook our new scripts into our Xcode build process.

In Xcode, go to [Target] > Build Phases > + > New Run Script Phase.

You can rename the new phase to something more descriptive like “Override Build Version”. Add in your scripts in the script window and adjust the options like so:

Next, in order for the script to run properly, we have to turn off script sandboxing. Go to [Target] > Build Settings > ENABLE_USER_SCRIPT_SANDBOXING and set it to “NO” for your target.

That should do it on the Xcode side. Now, every time you build your project, the build version will be set to something that is linked to the git hash for your current commit. And every time you update your Marketing Version (via the usual method, under [Target] > General > Version), a git tag will be created for you at that point.

One final touch. Let’s set up a git alias to automatically run our script so we can check out commits by their build version.

Run this command from your git repository:

$ git config alias.cob '!git co $( Scripts/hash-from-bundleversion.sh $1 ); :'

This creates a git alias named “cob” for “check out build”. Now, when you get a crash report from build 2.168.9999 for example, you can simply do this:

$ git cob 2.168.9999

and it will check out the commit that corresponds to that build version.

This is the ideal setup for me. All the versioning is handled automatically, and I can instantly sync with whatever build version I want. All I need to remember is to commit my source before archiving, and updating the marketing version when appropriate.

  1. Since I develop apps for iPhone, everything here is relevant to iOS. There are slightly different rules for MacOS. 

Tagged with

Comments and Webmentions


You can respond to this post using Webmentions. If you published a response to this elsewhere,

This post is licensed under CC BY 4.0