Skip to main content
mbehan

Uploading Xcode Bot Builds to Testflight, with launchd

Continuous integration with Xcode is super easy to set up and does the basics of continuous integration really well. With almost no effort you'll have nightly builds, test suites doing there thing, email alerts to committers, lovely graphs and even a cool dashboard thing for your big screen. I won't go through setting that all up here, the Apple docs are excellent and there are plenty of other people who've already explained it better than I will.

Where things are less than straightforward is when you want to use the IPA file produced–to send it to your testers via TestFlights, or to your remote teammates, your client or whoever.

The server executes an Xcode scheme, which defines your targets, build configuration, and tests. In the scheme there's an opportunity to include custom scripts that run at various points, pre and post each of the schemes actions, so you can run a script pre-build or post-archive etc.

This post-archive step is the last place we can do some work, so it's the obvious place to go upload our build to TestFlight, right? Well it would be except the IPA file never exists at this point. The IPA file is generated some time after this. The process is:

So if you want to upload to TestFlight what can you do? Well the solution offered by anyone I've seen blogging about it is to go make your own IPA using xcrun. That doesn't sound so bad until you end up with code signing and keychain issues and it's all to do something that is about to happen as soon as you're done anyway.

My solution was to just wait until the IPA file was made. My initial naive attempts were to schedule the upload from the post-archive script using at or simply adding a delay for some amount of time while the IPA file didn't exist. What I should have realised though is that the Bot will wait as long as I'm waiting and only when my script finishes will it continue and make the IPA file.

launchd to the rescue.

What I've ended up with, and which is working nicely for us, is a scheduled job on the build server which will notice any IPA files built by an Xcode bot, and upload them. I wasn't familiar with launchd prior to this and was excepting to use cron, but it turns out this is the modern OSX way for scheduling jobs. There's a great site showing you how to use launchd but I'll show you what I have anyway.

What I have:

  1. A plist for launchd
  2. Plists for each project that explain where to send the build
  3. A shell script that looks for IPA files and sends them to TestFlight or FTP using the information from 2.

1. The launchd plist

This is placed in /Library/LaunchDaemons and simply tells launchd that we want to run our script every 600 seconds. You could schedule it to run once a day or any other interval, I left it at 10 minutes so any bots that are run on commit or are started manually will have their builds uploaded right away rather than at the end of the day.

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
	<key>Label</key>
	<string>com.mbehan.upload-builds</string>
	<key>ProgramArguments</key>
	<array>
		<string>/ci_scripts/build-uploader.sh
			<key>StartInterval</key>
			<integer>600</integer>
			<key>StandardOutPath</key>
			<string>/tmp/build-uploads.log</string>
			<key>StandardErrorPath</key>
			<string>/tmp/build-uploads.log</string>
		</dict>
	</plist>
</key>

2. Per project plist

If we want the build to be uploaded automatically, it needs a plist telling it where to go. We share builds with one of our clients via FTP so there is a method key for that and a different set of keys are required if it's value is to FTP rather than TestFlight. I keep these plists in the same directory as the script.

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
	<key>Method
		<string>TestFlight</string>
		<key>ProductName</key>
		<string>Some App.ipa
			<key>APIToken</key>
			<string>GET THIS FROM TESTFLIGHT
				<key>TeamToken</key>
				<string>AND THIS</string>
			</dict>
		</plist>
	</key>
</key>

3. Checking for IPA files, uploading

We're using find with the -mtime option here to find recently created files with the name specified in the plist. If we find a file we then either use curl to upload to TestFlight or we send it via FTP depending on the method indicated in the plist.

You can remove the stuff for FTP if you only care about TestFlight, and you might want to add extra detail to the plist such as distribution lists.


#!/bin/bash

files=/ci_scripts/*.plist

for f in $files
do
	echo Processing $f "..."
	productName="$(/usr/libexec/plistbuddy -c Print:ProductName: "$f")"
	
	echo $productName
	ipaPath=$(find /Library/Server/Xcode/Data/BotRuns/*/output/"$productName" -mtime -15m | head -1)
	
	if [ ${#ipaPath} -gt 0 ]; then
		echo "Have IPA FILE: " $ipaPath
		
		method="$(/usr/libexec/plistbuddy -c Print:Method: "$f")"
		
		if [ $method == "FTP" ]; then
			echo "Attempting FTP ..."
			
			host="$(/usr/libexec/plistbuddy -c Print:FTPHost: "$f")"
			user="$(/usr/libexec/plistbuddy -c Print:UserName: "$f")"
			pass="$(/usr/libexec/plistbuddy -c Print:Password: "$f")"
			filename="$(/usr/libexec/plistbuddy -c Print:FileNameOnServer: "$f")"
			hostDir="$(/usr/libexec/plistbuddy -c Print:DirectoryOnServer: "$f")"

			date=`date +%y-%m-%d`

			ftp -inv $host <<-ENDFTP
			user $user $pass
			cd $hostDir
			mkdir $date
			cd $date
			binary
			put "$ipaPath" "$filename"
			bye
			ENDFTP
			
		elif [ $method == "TestFlight" ]; then
			echo "Attempting TestFlight ..."
			
			apiToken="$(/usr/libexec/plistbuddy -c Print:APIToken: "$f")"
			teamToken="$(/usr/libexec/plistbuddy -c Print:TeamToken: "$f")"
			
			/usr/bin/curl "http://testflightapp.com/api/builds.json" \
			  -F file=@"$ipaPath" \
			  -F api_token="$apiToken" \
			  -F team_token="$teamToken" \
			  -F notes="Automated Build"
		fi
	fi
done

This all assumes you've set your provisioning profile and code signing identity up correctly for the build configuration used by your Xcode scheme. Make sure the configuration used in the archive step (Release by default) will make a build the people you want to share builds with will be able to install.