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"