Basil Shkara

Automated OTA iOS App Distribution

comments [ ios appstore ota ] 27 October 2010

I’ve been spending some time setting up Hudson on a Mac Pro for continuous integration of Xcode projects. It’s worked out better than I expected. I started with a goal to allow over-the-air (OTA) distributions of iOS ad hoc builds and am pleased to say that this goal was accomplished.

OTA distribution is a new feature particular to iOS4, so users on versions prior to this will not be able to take advantage of the feature, however they are still catered for as I’ll share further down. The primary advantage of having your build server take care of distributing your builds to your beta testers is that you do not have to be concerned with creating correctly signed .ipa files, zipping them up together with the right provisioning profile and then emailing the result to your testers. It streamlines your development process by getting rid of one of the most annoying processes of iOS development: the beta release cycle.

For OTA distribution, you cannot simply create an ad hoc signed build, zip it up manually and give it the .ipa extension. If the user attempts to install this build and s/he doesn’t have the same provisioning profile you signed the build with, the installation will fail. Until recently, I thought the only way of creating a valid OTA build was via Xcode’s ‘Build and Archive’ feature which performs some trickery and embeds the provisioning profile somehow into the resulting .ipa. This allows users to install the app and during this installation process, the provisioning profile also gets installed onto the device automatically.

So what if you want this process automated? Turns out there is a command-line utility which allows you to do the exact same thing as the ‘Build and Archive’ tool. In fact, it’s what gets run when you hit ‘Build and Archive’.

The Setup

All our code is kept on a locally hosted Gitorious instance on a virtual machine somewhere. Hudson is installed on a Mac Pro running Snow Leopard. The rest is just configuration.

I created a parameterized build in Hudson which just means that you get to supply a lot of environment variables to your running script if you want. I hosted my build scripts in my VCS and I recommend anyone to do the same. Then it’s just a matter of having the following execute in Hudson:

bash -c "$(curl -fsS http://github.com/baz/ios-build-scripts/raw/master/build_ipa.sh)"

The script in its entirety is below:

#!/bin/bash
# Below are required environment variables with some example content:
# SDK='iphoneos4.1'
# FORMATTED_TARGET_LIST='-alltargets'
# CONFIGURATION='Ad Hoc'
# DISTRIBUTION_CERTIFICATE='iPhone Distribution: Your Company Pty Ltd'
# PROVISIONING_PROFILE_PATH='/Users/tomcat/Library/MobileDevice/Provisioning Profiles/Your_Company_Ad_Hoc.mobileprovision'
# GIT_BINARY='/usr/local/git/bin/git'
# REMOTE_HOST='your.remote.host.com'
# REMOTE_PARENT_PATH='/www/docs/ios_builds'
# MANIFEST_SCRIPT_LOCATION='http://github.com/baz/ios-build-scripts/raw/master/generate_manifest.py'
# ROOT_DEPLOYMENT_ADDRESS='http://your.remote.host.com/ios_builds'
# ARCHIVE_FILENAME='beta_archive.zip'
# KEYCHAIN_LOCATION='/Users/tomcat/Library/Keychains/Your Company.keychain'
# KEYCHAIN_PASSWORD='Password'

# Build project
security default-keychain -s "$KEYCHAIN_LOCATION"
security unlock-keychain -p $KEYCHAIN_PASSWORD "$KEYCHAIN_LOCATION"
xcodebuild -sdk "$SDK" $FORMATTED_TARGET_LIST -configuration "$CONFIGURATION" clean build

GIT_HASH="$($GIT_BINARY log --pretty=format:'' | wc -l)-$($GIT_BINARY rev-parse --short HEAD)"
GIT_HASH=${GIT_HASH//[[:space:]]}
BUILD_DIRECTORY="$(pwd)/build/${CONFIGURATION}-iphoneos"
cd "$BUILD_DIRECTORY" || die "Build directory does not exist."
MANIFEST_SCRIPT=$(curl -fsS $MANIFEST_SCRIPT_LOCATION)
MANIFEST_OUTPUT_HTML_FILENAME='index.html'
MANIFEST_OUTPUT_MANIFEST_FILENAME='manifest.plist'
for APP_FILENAME in *.app; do
	APP_NAME=$(echo "$APP_FILENAME" | sed -e 's/.app//')
	IPA_FILENAME="$APP_NAME.ipa"
	DSYM_FILEPATH="$APP_FILENAME.dSYM"

	/usr/bin/xcrun -sdk iphoneos PackageApplication -v "$APP_FILENAME" -o "$BUILD_DIRECTORY/$IPA_FILENAME" --sign "$DISTRIBUTION_CERTIFICATE" --embed "$PROVISIONING_PROFILE_PATH"

	# Create legacy archive for pre iOS4.0 users
	cp "$PROVISIONING_PROFILE_PATH" .
	PROVISIONING_PROFILE_FILENAME=$(basename "$PROVISIONING_PROFILE_PATH")
	zip "$ARCHIVE_FILENAME" "$IPA_FILENAME" "$PROVISIONING_PROFILE_FILENAME"
	rm "$PROVISIONING_PROFILE_FILENAME"

	# Output of this is index.html and manifest.plist
	python -c "$MANIFEST_SCRIPT" -f "$APP_FILENAME" -d "$ROOT_DEPLOYMENT_ADDRESS/$APP_NAME/$GIT_HASH/$MANIFEST_OUTPUT_MANIFEST_FILENAME" -a "$ARCHIVE_FILENAME"

	# Create tarball with .ipa, dSYM directory, legacy build and generated manifest files and scp them all across
	PAYLOAD_FILENAME='payload.tar'
	tar -cf $PAYLOAD_FILENAME "$IPA_FILENAME" "$DSYM_FILEPATH" "$ARCHIVE_FILENAME" "$MANIFEST_OUTPUT_HTML_FILENAME" "$MANIFEST_OUTPUT_MANIFEST_FILENAME"

	QUOTE='"'
	ssh $REMOTE_HOST "cd $REMOTE_PARENT_PATH; rm -rf ${QUOTE}$APP_NAME${QUOTE}/$GIT_HASH; mkdir -p ${QUOTE}$APP_NAME${QUOTE}/$GIT_HASH;"
	scp "$PAYLOAD_FILENAME" "$REMOTE_HOST:$REMOTE_PARENT_PATH/${QUOTE}$APP_NAME${QUOTE}/$GIT_HASH"
	ssh $REMOTE_HOST "cd $REMOTE_PARENT_PATH/${QUOTE}$APP_NAME${QUOTE}/$GIT_HASH; tar -xf $PAYLOAD_FILENAME; rm $PAYLOAD_FILENAME"

	# Clean up
	rm "$IPA_FILENAME"
	rm "$ARCHIVE_FILENAME"
	rm "$MANIFEST_OUTPUT_HTML_FILENAME"
	rm "$MANIFEST_OUTPUT_MANIFEST_FILENAME"
	rm "$PAYLOAD_FILENAME"
done

Feel free to use and modify the above script. Both scripts I use are located here. The script above expects quite a lot of environment variables to be set so it knows what to do. The expectation is that this script is run in an automated fashion, so you just need to set it up the one time. The environment variables listed at the top of the file are all parameters set in Hudson. This way you can easily change any of the settings should you decide to switch provisioning profiles or server locations.

The most interesting line is possibly this one:

/usr/bin/xcrun -sdk iphoneos PackageApplication -v "$APP_FILENAME" -o "$BUILD_DIRECTORY/$IPA_FILENAME" --sign "$DISTRIBUTION_CERTIFICATE" --embed "$PROVISIONING_PROFILE_PATH"

This will create the .ipa for you, codesign it correctly and embed the provisioning profile into the .ipa so that when your beta testers install the build OTA - the profile will be installed automatically as part of the installation. How did I discover this command? This lonely thread on the Apple developer forums. Turns out if you watch the Console whilst you run ‘Build and Archive’ from within Xcode, you’ll see the command that it runs.

There is also a Python script which gets executed:

python -c "$MANIFEST_SCRIPT" -f "$APP_FILENAME" -d "$ROOT_DEPLOYMENT_ADDRESS/$APP_NAME/$GIT_HASH/$MANIFEST_OUTPUT_MANIFEST_FILENAME" -a "$ARCHIVE_FILENAME"

This generates the required manifest file (which is just a plist describing your app) and the web page presented to your users. The template for the web page was taken from the iOS Beta Builder project.

The end result is this:

  1. Hudson polls the VCS and ensures it has the latest copy of your codebase.
  2. If a change is detected in the codebase, it initiates a build using the above bash script.
  3. An .ipa is then created from the built app and the provisioning profile is embedded.
  4. A .zip file is created with the .ipa and the provisioning profile for legacy iOS3 users.
  5. The plist manifest file and the index.html web page is generated.
  6. All of these files plus the .dSYM directory are then copied across to your web server and versioned by the current Git hash.