/dev/trouble
Eric Roller's Development Blog

Xcode build numbering update

- Posted in Xcode by

Up until today, I have been using a Build Phase script to update the CFBundleVersion in the Info.plist file using a script like this:

#!/bin/bash

if [[ ! -d .git ]]; then
  echo "Error: Git setup not found: ./.git"
  exit 1
fi

# The number of commits in the master branch.
rev=$(expr $(git rev-list master --count) - $(git rev-list HEAD..master --count))

echo "Updating build number to $rev:"

for plist in 
    "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}" 
    ; do
  if [ -f "$plist" ]; then
    echo " -> $(file "$plist")"
    /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $rev" "$plist"
    plutil -lint -s "$plist"
  else
    echo " ?? $plist"
  fi
done

Sadly, with Xcode 15, this stopped working. Whether or not ENABLE_USER_SCRIPT_SANDBOXING was enabled or not, Xcode no longer allowed access to the plist file (or complained about a circular dependency).

So, now I am using a build configuration file to set the build number; the config file needs to be added to the Xcode project, and selected in the project "Configurations". My file contains just:

// proj.xcconfig
CURRENT_PROJECT_VERSION = 0

Also, the Info.plist file needs to be updated to use:

<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>

My new methodology uses a "Build pre-actions" script (added in the target's scheme editor and using the build settings from the target) to update the .xcconfig file before every build:

#!/bin/bash
cd "$SOURCE_ROOT"

if [[ ! -d .git ]]; then
  echo "Error: Git setup not found: ./.git"
  exit 1
fi

# The number of commits in the master branch.
rev=$(expr $(git rev-list master --count) - $(git rev-list HEAD..master --count))

echo "Updating build number to $rev:"

sed -i '' "/CURRENT_PROJECT_VERSION/ { s/=.*/= $rev/; }" ./*.xcconfig

But since the .xcconfig file is also a revision-controlled file, I reset the value again in a "Build post-actions" script. Similar to using git restore to revert the changes:

#!/bin/bash
cd "$SOURCE_ROOT"
sed -i '' '/CURRENT_PROJECT_VERSION/ { s/=.*/= 0/; }' ./*.xcconfig

Explanation of the sed code:

  • For any file matching *.xcconfig,
  • modify it "in place" (not creating a backup file),
  • all lines containing "CURRENT_PROJECT_VERSION",
  • substitute text beginnig with "=" to "= $rev" or "= 0".

Update 2024-01-11

Finally, I added a build configuration script to make sure that the $(CURRENT_PROJECT_VERSION) variable contains a non-zero value:

#!/bin/sh
ok=0
if [ ${CURRENT_PROJECT_VERSION:-0} -eq 0 ]; then
  echo 'Error: CURRENT_PROJECT_VERSION has not been set.'
  ok=1
fi
exit $ok

Update 2025-01-01

The script has evolved further. I am no longer re-setting the values in the .xcconfig file since Xcode is very sensitive to any changes to the file, particularly when working with SwiftUI previews. The new approach is as follows; this is one scheme pre-build script, shown here in sections:

To begin with, some setup and checks that a git repo is present and that Xcode is calling the script:

cd "$PROJECT_DIR"

# The default branch, if not supplied as an argument, is 'master'.
branch=${1:-'master'}
year=$(date +%Y)

if [ ! -d ./.git ]; then
  echo "Error: Git setup not found: $(pwd)/.git" >&2
  exit 1
fi

if [ -z "${MARKETING_VERSION:-}" ]; then
  echo 'Error: MARKETING_VERSION has not been set.' >&2
  exit 1
fi

The build number is worked out as the number of commits:

# The number of commits in the branch, less the number of commits
# behind in that branch.
rev=$(expr $(git rev-list $branch --count) - $(git rev-list HEAD..$branch --count))

if [ -z "$rev" ]; then
  echo 'Error: Unable to work out commit count.' >&2
  exit 1
fi

echo "Updating build number to $rev using branch '$branch':"

To avoid changing the local file, we create a copy in this temporary directory, creating it if missing:

[ -d "$CONFIGURATION_TEMP_DIR" ] || mkdir -p "$CONFIGURATION_TEMP_DIR"

I have moved the input .xcconfig template files in a ./Config directory, which will be modified and a copy is created in the local directory. Also, I have renamed my variables to TREDJE_BUILD_NUMBER and TREDJE_YEAR.

Two important points:

  • The script creates a copy in a temporary directory and only overwrites the local file if they are different. Keeping the local file untouched in this way avoids repeated re-builds for SwiftUI previews.

  • While I have checked in a basic version of the .xcconfig file, I never want to check in the modified version. This is achieved by instructing git to ignore the local file change.

This is done here:

for file in ./Config/*.xcconfig ; do
  fout=${file#*Config/}
  name=${file##*/}
  [ ! -f "$file" ] && echo " ?? $file" && continue
  [ ! -f "$fout" ] && echo " ?? $fout" && continue

  # Put the up-to-date data in a temporary file.
  sed "/TREDJE_BUILD_NUMBER/ s/=.*/= $rev/; /TREDJE_YEAR/ s/=.*/= $year/;" "$file" > "$CONFIGURATION_TEMP_DIR/$name"

  # Skip changing the file if identical to the temp file.
  $(cmp -s "$fout" "$CONFIGURATION_TEMP_DIR/$name") && continue

  echo " -> $fout"
  mv "$CONFIGURATION_TEMP_DIR/$name" "$fout"
  git update-index --assume-unchanged "$fout"
done

Finally, I do a similar change for a settings bundle Plist file:

echo "Updating YEAR to $year and VERSION_STRING to $MARKETING_VERSION ($rev):"

for file in 
        ./Config/iOS/Settings.bundle/Root.plist 
    ; do
  fout=${file#*Config/}
  name=${file##*/}
  [ ! -f "$file" ] && echo " ?? $file" && continue
  [ ! -f "$fout" ] && echo " ?? $fout" && continue

  sed "s/TREDJE_VERSION_STRING/$MARKETING_VERSION ($rev)/; s/TREDJE_YEAR/$year/;" "$file" > "$CONFIGURATION_TEMP_DIR/$name"

  # Skip changing the file if identical to the temp file.
  $(cmp -s "$fout" "$CONFIGURATION_TEMP_DIR/$name") && continue

  echo " -> $(file "$fout")"
  mv "$CONFIGURATION_TEMP_DIR/$name" "$fout"
  plutil -lint -s "$fout"
  git update-index --assume-unchanged "$fout"
done