When developing for the iPhone it’s often necessary to build the application in a form that can be easily installed by collaborators and testers. For ease of installation, the app should be distributed as a .ipa (iPhone Application) file. Unfortunately, XCode does not include a way to do this, so I wrote a bash script to automate the process.
Additionally, in any build that leaves the developer’s desk it is especially important to include version information so that bug reports can be traced back to the correct version of the source, and to ensure that any distributed version can be located in the version control system. I use git for source control (installed with MacPorts), though the script should be easily adaptible to CVS or SVN.
The goals for this script were:
- Automatically tag the project with a unique build number before each build.
- Build all supported configurations with a single command. Sometimes compiler errors or warnings are only revealed with certain preprocessor flags or optimization settings.
- Ensure any build created for distribution is cleanly committed to version control and tagged.
- Name tags so that they can be easily correlated to user-visible version information.
Preliminaries
Before we get into the script, a bit of groundwork is needed in the XCode project.
Build Configurations
By default, XCode includes two configurations in a new project: Debug and Release. I add a third configuration, Distribution, created by copying the Release configuration, but signed with my distributions certificate instead of the development certificate. This allows me to test using the same build settings that will be used for the submitted app.
I also set a couple of preprocessor flags based on the build configuration. The Debug configuration sets DEVELOPMENT_BUILD=1 and DEBUG_BUILD=1, while the Release build just sets DEVELOPMENT_BUILD=1. These flags are used for several purposes, including conditional logging, but in the context of this article they are used just for version string generation.
The versioning tool discussed in the next session also requires a build variable. Search for the CURRENT_PROJECT_VERSION key and set its value to 1.
Apple’s Versioning Tool
First, we need to take a little side trip into Apple’s built-in versioning support. XCode includes agvtool, which with a bit of setup can be used to satisfy our versioning requirements. The first step is to separate the “marketing version” from the build version. Both components are stored in the Info.plist file, though a new XCode project only includes the build version key.
| Field | Key | agvtool use | Description |
|---|---|---|---|
| Bundle Version | CFBundleVersion |
(Build) Version | Monotonically increasing integer |
| Bundle versions string, short | CFBundleShortVersionString |
Marketing Version | Published version number (e.g. 1.1) |
I set the marketing version to the version I plan to specify in iTunesConnect when the app is submitted. The build version is simply set to 1 initially and incremented with each build thereafter (this should match the CURRENT_PROJECT_VERSION build variable).
Displaying the Version
In the About view controller’s viewDidLoad method I include the following:
- (void)viewDidLoad { NSString *versionString = [NSString stringWithFormat: NSLocalizedString(@"Version: %@%@ (%@)", @"Version string format"), [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"], #if DEBUG_BUILD @" Debug", #elif DEVELOPMENT_BUILD @" Beta", #else @"", #endif [[NSBundle mainBundle] objectForInfoDictionaryKey:(NSString*)kCFBundleVersionKey]]; [versionLabel setText:versionString]; // Additional initialization }
This provides enough information to determine the build and configuration from any copy of the app. Actually, the marketing version isn’t even needed, but it allows the user to compare with the information in iTunes.
The remainder of this article breaks down the script, section by section. You can download the script (updated July 14, 2009) and use or adapt it to your own projects. The script is under a BSD license.
The description below details the 1.0 version of the script.
Helper Functions
The first section of the script defines a few helper functions.
The die function simply prints its arguments to stderr and exits.
die() { echo "$*" >&2 exit 1 }
The usage function prints its args (if any) followed by a usage message and exits.
usage() { if [ -n "$1" ] ; then echo "$@" >&2 printf "\n" fi echo "usage: build [-n] [config...]" >&2 echo " -n : do not update build number or commit to git">&2 if [ -n "$xcodeconfigs" ] ; then printf "\n Known configs:" >&2 printf " %s" $xcodeconfigs >&2 printf "\n\nDefault action is to build all configs\n" >&2 else echo "This does not appear to be a valid project directory!" >&2 fi exit 3 }
The usage message is:
usage: build [-n] [config...]
-n : do not update build number or commit to git
Known configs: Debug Release Distribute
Default action is to build all configs
Defaults and Known Configurations
By default, the script will run agvtool and the git operations, and build all configs in the project.
nocommit=0 configs=
The list of known configurations is determined by filtering the output of xcodebuild -list with sed:
# all known configurations xcodeconfigs=$(xcodebuild -list | sed ' /Build Configurations:/,/^[[:space:]]*$/ !d /Build Configurations/ d /^[[:space:]]*$/ d s/[[:space:]]*\([^[:space:]]*\).*/\1/ ')
The output of xcodebuild -list looks like:
$ xcodebuild -list
Information about project "SpinSlide":
Targets:
SpinSlide (Active)
Build Configurations:
Debug (Active)
Release
Distribute
If no build configuration is specified "Release" is used.
The four-line sed script first deletes all lines except those between Build Configurations: and the first blank line. The second and third lines delete the Bould Configurations line and the blank line, respectively. Finally, each line is replaced by just the first word on that line.
Next the script checks for an empty list of configurations, which can occur if there is no project bundle in the current directory.
if [ -z "$xcodeconfigs" ] ; then # no project bundle? usage; fi
Argument Processing
Command line arguments are processed in a loop. The only supported flag is ‘-n‘, which suppresses avgtool and git operations. All non-flag arguments are assumed to be build configuration names, and are checked against the list of known configurations generated in the last section before being added to the list of configurations to build.
while [ -n "$*" ]; do case "$1" in -n) nocommit=1 ; shift ;; -*) usage ;; *) if echo $xcodeconfigs | grep -wq "$1" ; then configs="$configs $1"; shift else usage "Invalid config '$1'" fi ;; esac done
If no configurations were specified on the command line, all known configurations are built.
# default to building all configs if [ -z "$configs" ] ; then configs=$xcodeconfigs fi
Check for Modified Files
The next step is to check for modified files. If modified files exist and -n was not specified, the script will exit with an error. If -n was specified the script continues after setting the isdirty flag.
if ! git status | grep -q 'nothing to commit (working directory clean)' ; then if [ "$nocommit" -eq "0" ] ; then # if committing the directory must be clean at first git status die "directory is dirty" else # development build, just remember that it was dirty isdirty=1 fi else isdirty=0 fi
This step is git-specific, and must be adapted if you are using a different VCS.
Versioning
The next step handles all of the versioning and source control operations.
The steps required depend on the command line options used.
Tagged Builds
First, we handle the case where the -n flag was not specified. The first step is to use agvtool to determine the marketing version. This will not be changed when the build number is incremented. Checking the marketing version first lets us verify that the project is set up properly before making changes.
if [ "$nocommit" -eq "0" ] ; then # read out the marketing version # do this separate from the cut so we can check the exit code mvers=$(agvtool mvers -terse) if [ $? -ne 0 -o -z "$mvers" ] ; then die "No marketing version found" fi if echo "$mvers" | grep -q = ; then mvers=$(echo "$mvers" | cut -f 2 -d =) fi
The output of agvtool looks like:
"SpinSlide.xcodeproj/../Info.plist"=1.0
The cut command is used to extract the field after the = sign.
After this point, the environment has been fully verified, so we can go ahead and increment the build version.
# going to commit, bump the version number agvtool bump -all
Now we extract the (newly incremented) build version…
bvers=$(agvtool vers -terse) if [ $? -ne 0 -o -z "$bvers" ] ; then die "No build version found" fi
… and combine the marketing version and build numbers into a human-readable string and a tag identifier, which has the spaces replace by underscores.
# read out the build version, must exist if the marketing version does fullvers="$mvers build $bvers" tag=$(echo "$fullvers" | tr ' ' _)
Finally, we commit the changed .plist and tag the new version.
# commit the changed version and tag it echo "Committing "$fullvers" with tag $tag" git ci -a -m "Set build version '$fullvers'" -n || die 'commit failed' git tag "$tag" -m "$fullvers"
Untagged Builds
Untagged builds are much simpler. The SHA1 of the current version is used as the tag. If the source directory is dirty, the current date and time is added to make a unique identifier.
else # not committing, use the SHA1 as the version fullvers=$(git rev-parse HEAD 2>/dev/null) # if it's dirty, append the date, time, and timezone if [ "$isdirty" -ne 0 ] ; then fullvers="$fullvers+ $(date +%F\ %T\ %Z)" fi fi
Build Prep
To ensure the build matches the source, we delete the build directory and the Payload directory (the former is used by XCode, the latter by this script).
# clean up old builds so that everything is built from scratch rm -rf build rm -rf Payload all=
Build and Package
Next we loop over the requested configurations, building and packaging each one.
# build and package each requested config for config in $configs ; do
The build is performed with the xcodebuild utility, supplied with XCode. A failed build stops the script.
xcodebuild -alltargets -parallelizeTargets -configuration $config build || die "Build failed"
Build output is stored in a tree named for the build type (tagged or untagged), version, and XCode build configuration:
# packaged output goes in Releases if tagged, Development otherwise if [ "$nocommit" -eq "0" ] ; then basedir=Releases else basedir=Development fi releasedir=$basedir/$config/"$fullvers" mkdir -p "$releasedir"
Each build target is then packaged, one at a time.
# Package each app for app in build/$config-iphoneos/*.app ; do basename="$(basename "$app" .app)"
The application bundle is copied into a directory called Payload/Payload. The first directory name isn’t significant, but the second is important to make a working .ipa file.
mkdir -p Payload/Payload cp -Rp "$app" Payload/Payload
To have an icon in iTunes, the package must include a 512×512 PNG or JPEG named iTunesArtwork at the root. The following code supports per-project default artwork as well as app-specific artwork (if there are multiple apps in a single project).
# Get app-specific iTunes artwork or project-specific artwork # if available if [ -f "$basename".iTunesArtwork ] ; then cp -f "$basename".iTunesArtwork Payload/iTunesArtwork elif [ -f iTunesArtwork ] ; then cp -f iTunesArtwork Payload/iTunesArtwork fi
A .ipa file is just a .zip file with a particular content format. When building for distribution through the AppStore, the .zip extension is required. Otherwise the .ipa extension is used.
# Distribution builds have a .zip extension, development # builds have a .ipa extension if [ "$config" -eq "Distribute" ] ; then ext=zip else ext=ipa fi
Next we use ditto to zip the contents, then delete the temp directory.
# zip the Payload directory then delete it output="$releasedir/$basename.$ext" ditto -c -k Payload "$output" || die "Failed to compress" rm -rf Payload
We remember the name of the generated file to display at the end of the script.
# add to the list of output files all="${all:+$(printf "%s\n" "$all")}$(printf "\t%s" "$output")"
XCode supports splitting the debug symbols from the app. If this option is enabled (as it should be for Release and Distribution builds) we save the debug symbols with the app:
# save debug symbols (if available) with the app if [ -d "$app.dSYM" ] ; then output="$releasedir/$basename.dSYM.zip" ditto -c -k "$app.dSYM" "$output" || die "Failed to compress debug info" all="$(printf "%s\n" "$all")$(printf "\t%s" "$output")" fi
Finally, we update a symlink to point to the latest version.
# update a symlink to the latest version (cd $basedir/$config ; ln -sf "$fullvers/$basename.$ext") done done
Report Results
Once all the builds have completed and been packaged, a build summary is displayed to the user.
# report the generated files printf "Created: $all\n"
#1 by Mike Heinz on May 25th, 2009
| Quote
The last time I used XCode for an app, I used an auto-incrementing build number to act as a suffix to the version number – that let me uniquely identify the build. Since one of the files that “/Developer/Tools/agvtool next-version -all” modified was a file under CVS control, I could always identify exactly what code was used when that build was done.
#2 by Frank on July 14th, 2009
| Quote
Updated the script to my latest working version. Main change is better handling of spaces in target names. See the changelog in the script header for details.
#3 by sujal on September 16th, 2009
| Quote
I just wanted to let you know that I’ve put a (modified) copy of your script up in a github repo. I was wondering if you’d want to do that with your original. I’m happy to then make my changes available.
The modifications I’ve made include some support for Build configurations with spaces, SVN support.
#4 by Frank on September 17th, 2009
| Quote
Thanks for the motivation sujal. I’ve been meaning to put this up on github but just haven’t gotten around to it. The versions posted here are at http://github.com/fszczerba/scripts/tree/master/iPhone/build.sh. I’ll be adding more to this repo (http://github.com/fszczerba/scripts/), as I have lots of stuff I’ve accumulated over the years.