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:
- Archive
- Post archive scripts
- ???
- Generate IPA file
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:
- A plist for launchd
- Plists for each project that explain where to send the build
- 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.