Monday, November 11, 2013

Integrating an automated iOS build with a Windows Team Foundation Server build environment

My current job is a "Microsoft shop" - our source control is Microsoft's Team Foundation Server, we primarily write applications for windows using C++ and C#, and all the developers use windows workstation PC's.

We'd like to develop an iOS app, but we'd like to set it up with a repeatable, automatable build process. Simply asking the guy with the mac on his desk to compile a new build and hope it works doesn't cut it.
All our existing build processes are using Team Foundation Server's build agents and build scripts, which have some very nice properties, and we'd like to be able to queue and publish iOS builds in the same way.

The problem is that we must build iOS apps on a Mac using Xcode, but the Team Foundation Server build agent only runs on Windows

How do we bridge this gap? Using SSH from a windows build agent to a remote mac.

  • The TFS build script uses putty (specifically the command line plink application) to tell the mac to remotely do things.
  • We queue a TFS build onto one of our windows TFS build agent PC's. It basically just runs plink, captures the output and waits for it to exit.
  • We get the files from the TFS repository onto the mac via SCP - the windows TFS build agent checks everything out, then runs pscp to copy it all over to the mac
  • The mac then uses xcodebuild and xcrun to compile and package the iOS app for ad-hoc distribution
  • The windows build agent then uses pscp to copy the packaged iOS app off the mac back onto the windows build agent, which then copies it into the appropriate drop folder alongside all the other windows builds.
  • We host a basic web server to serve the iOS application's .ipa package over HTTP, and everyone installs/upgrades our app by accessing this webpage using iOS' over the air beta testing facilities.

There are a lot of pieces to this, particularly given that we do not want to put secure data such as user account passwords in source control or have them hardcoded into build scripts. Here are the details, in the order that (for me) makes the most sense.

Setting up the Mac


Install Xcode

Obviously we need to install Xcode on the mac. The easiest way is just via the Mac app store, but you can also download a DMG from the apple developer centre for offline installs.

Install Team Explorer Everywhere

I did this by unzipping microsoft's zip file into /usr/local/bin. There is likely a better/more modular way to manage this than putting everything directly in /usr/local/bin, but this works fine on my isolated build machine.

User account

In the interests of keeping the build environment clean, and repeatable (so we can set up other mac build machines easily in the future), we want all of the builds to be performed by a dedicated user account. We do not ever want to use this account to do normal day to day development. Ideally we should never log into it interactively at all, only via SSH. This helps ensure that our builds really are clean and repeatable, and that we don't end up with accidental dependencies on random files/data in a normal person's user account.

Enabling passwordless SSH logins

We need the windows build agent PC to SSH into the mac, but we don't want the TFS build scripts to have the password of the mac's build account, we want to enable passwordless logins. To do this, I followed the instructions here: http://www.tonido.com/blog/index.php/2009/02/20/ssh-without-password-using-putty/

Once this has been set up, on the mac the /Users/buildaccount/.ssh/authorized_keys file will contain the details of the ssh key, and on the windows PC there will be the matching .ppk file. We can use plink to get the mac to run various scripts, as follows

plink -ssh -batch -l buildaccount -i buildaccountkey.ppk mac01.domain.local /Users/buildaccount/build_script.sh

Enabling our non-admin account to login using SSH

Apple's default SSH security settings are such that only users with Admin rights can login via SSH (At least this is the case on OSX 10.9, which I am using). We want our build account to be a non-admin user, so we'll have to edit the access control list that controls who can access ssh, and add our build user to it.

sudo dseditgroup -o edit -n . -a buildaccount -t user com.apple.access_ssh

Setting up the build process


Certificates and Provisioning Profiles

As we're setting up for Ad-hoc deployment of our app, we need an iOS Distribution Certificate, and an Ad-hoc provisioning profile. You generate both of these via the Apple Developer website's Member Centre. When building through the Xcode UI, it handles most of this for you, but as we want to be building via the command line, and via SSH into a clean user account, we have a few more hoops to jump through.

Putting the certificates in a custom keychain

By default Xcode and it's command line tools will look for your distribution certificate in the user default keychain (the login keychain). As we want our build account to be "clean", we don't want to have to put them in the build account's login keychain. To resolve this, we can create a custom keychain, and put the certificates/provisioning profile keys in there.

We can then decide to either check this custom keychain file into source control, or put it in some other special location.

I'm assuming that you've already created and loaded/installed a distribution certificate, and an ad-hoc provisioning profile.

  1. From your normal user account, run Keychain Access
  2. Right-click on the list of keychains in the top-left, and create a new keychain. Give it a meaningful filename and store it somewhere you can find later.
  3. From your login keychain, hold the option key (to copy) and drag your iPhone Distribution certificate, and the Ad-hoc distribution private key.

Using this custom keychain in the build script

To get the Xcode command line tools to use this custom keychain file, add the following before you invoke xcodebuild

keychain_file="$SCRIPTPATH/iOSCustomBuild.keychain"
keychain_pass=secretpassword
security list-keychains -s "$keychain_file" ~/Library/Keychains/login.keychain  # put the iOS keychain in ahead of the login keychain
security unlock-keychain -p "$keychain_pass" "$keychain_file"

The security list-keychains -s command sets the search order, telling the system first to look in our custom keychain, followed by the default login keychain. This command is persistent - I haven't worried about resetting it after our script completes, but you may want to

The security unlock-keychain command is required because keychains are locked by default when code is run in a remote SSH session. We must issue security unlock-keychain or else xcodebuild will not be able to read the keychain. This is another reason to use a custom keychain file - if we were using the user's login keychain, we would be required to hard code the user account's password into our build scripts.

Compiling and packaging the app

To build our iOS app, we'll need to run xcodebuild, which is xcode's command line build tool. This requires a number of flags, which we will create variables for and then re-use. Here's mine:

# hack to get the full path to the current directory
pushd `dirname $0` > /dev/null
SCRIPTPATH=`pwd`
popd > /dev/null

application_name=MyiOSApp # our app name - should correspond to MyiOSApp.xcodeproj
sdk="iphoneos7.0" # which version of iOS are we targeting

# Name of the distribution certificate. Get this by downloading the certificate from 
# the apple developer center, installing it, and then viewing it's name in keychain
codesign="iPhone Distribution: My Name (AAA12345ZZ)"

# build the project (must sign as xcode requires signing for all non-simulator bulds)
# CONFIGURATION_BUILD_DIR is only required for cordova/phonegap projects.
xcodebuild -project "$SCRIPTPATH/$application_name.xcodeproj" -target $application_name -configuration Release -sdk $sdk clean build CODE_SIGN_IDENTITY="$codesign" CONFIGURATION_BUILD_DIR="$SCRIPTPATH/build"

This only gets us halfway - xcodebuild will produce a .app package (a directory). This doesn't have the provisioning profile embedded in it, and we also need to package it up as a .ipa file to distribute it via the ad-hoc mechanisms. To do this step, we'll need to run xcrun. Here's my part of the script to do that:

# Get this by downloading it from the apple developer center
provisioning_profile_file="$SCRIPTPATH/My_Ad_Hoc_Provisioning_Profile.mobileprovision"
provisioning_profile_name="My Ad Hoc Provisioning Profile" # get this from the apple developer center when you download the file

# install the provisioning profile so xcode can use it 
provisioning_profile_uuid=`grep UUID -A1 -a "$provisioning_profile_file" | grep -o "[-A-Z0-9]\{36\}"`
cp "$provisioning_profile_file" ~/Library/MobileDevice/Provisioning\ Profiles/$provisioning_profile_uuid.mobileprovision
 
# this is where xcode will drop it's output, as a .app package
build_dir="$SCRIPTPATH/build"

# this is where we want the created .ipa file to be put
drop_dir="$SCRIPTPATH"

# package it and embed the provisioning profile (must re-sign as packaging alters the app)
/usr/bin/xcrun -sdk $sdk PackageApplication -v "${build_dir}/${application_name}.app" -o "${drop_dir}/${application_name}.ipa" --sign "$codesign" --embed "$provisioning_profile_file"

I chose to simply check my provisioning profile file into source control.

Also note that we have the extra step of "installing" the provisioning profile. This is because we're trying to run under a clean account. If we were running as a normal user account where someone had manually run Xcode, Xcode would have put the provisioning profile into the ~/Library/MobileDevice/Provisioning Profiles directory already for us and we wouldn't need to do this step.

The full build script


keychain_file="$SCRIPTPATH/iOSCustomBuild.keychain"
keychain_pass=secretpassword
security list-keychains -s "$keychain_file" ~/Library/Keychains/login.keychain  # put the iOS keychain in ahead of the login keychain
security unlock-keychain -p "$keychain_pass" "$keychain_file"

# hack to get the full path to the current directory
pushd `dirname $0` > /dev/null
SCRIPTPATH=`pwd`
popd > /dev/null

application_name=MyiOSApp # our app name - should correspond to MyiOSApp.xcodeproj
sdk="iphoneos7.0" # which version of iOS are we targeting

# Name of the distribution certificate. Get this by downloading the certificate from 
# the apple developer center, installing it, and then viewing it's name in keychain
codesign="iPhone Distribution: My Name (AAA12345ZZ)"

# build the project (must sign as xcode requires signing for all non-simulator bulds)
# CONFIGURATION_BUILD_DIR is only required for cordova/phonegap projects.
xcodebuild -project "$SCRIPTPATH/$application_name.xcodeproj" -target $application_name -configuration Release -sdk $sdk clean build CODE_SIGN_IDENTITY="$codesign" CONFIGURATION_BUILD_DIR="$SCRIPTPATH/build"

# Get this by downloading it from the apple developer center
provisioning_profile_file="$SCRIPTPATH/My_Ad_Hoc_Provisioning_Profile.mobileprovision"
provisioning_profile_name="My Ad Hoc Provisioning Profile" # get this from the apple developer center when you download the file

# install the provisioning profile so xcode can use it 
provisioning_profile_uuid=`grep UUID -A1 -a "$provisioning_profile_file" | grep -o "[-A-Z0-9]\{36\}"`
cp "$provisioning_profile_file" ~/Library/MobileDevice/Provisioning\ Profiles/$provisioning_profile_uuid.mobileprovision
 
# this is where xcode will drop it's output, as a .app package
build_dir="$SCRIPTPATH/build"

# this is where we want the created .ipa file to be put
drop_dir="$SCRIPTPATH"

# package it and embed the provisioning profile (must re-sign as packaging alters the app)
/usr/bin/xcrun -sdk $sdk PackageApplication -v "${build_dir}/${application_name}.app" -o "${drop_dir}/${application_name}.ipa" --sign "$codesign" --embed "$provisioning_profile_file"

Putting it all together

All we need to do now is create TFS build script which Exec's plink and pscp and asks the mac to run the various .sh scripts which do all the work. Here's some snippets which you could use


<PropertyGroup>
    <!-- general -->
    <IpaFileName>MyiOSApp.ipa</IpaFileName>
    
    <!--File locations on the windows build machine-->
    <Plink>pathtoPLINK.EXE</Plink>
    <Pscp>pathtoPSCP.EXE</Pscp>
    
    <!--SSH connection info-->
    <SshHost>mac.domain.local</SshHost>
    <SshKey>buildaccountkey.ppk</SshKey>
    <SshUser>buildaccount</SshUser>

    <!--File locations on the mac-->
    <RemoteiOSBuildPath>/Users/buildaccount/iOSBuild</RemoteiOSBuildPath>
    <RemoteBuildScriptPath>$(RemoteiOSBuildPath)/build.sh</RemoteBuildScriptPath>
    <RemoteIpaFilePath>$(RemoteiOSBuildPath)/$(IpaFileName)</RemoteIpaFilePath>
</PropertyGroup>

...

<!--this "echo y | exit" causes plink/putty to cache the remote host key in the registry for subsequent operations.-->
<Exec command="echo y | &quot;$(Plink)&quot; -ssh -l $(SshUser) -i $(SshKey) $(SshHost) exit"/>

<!--Wipe any old files-->
<Exec command="&quot;$(Plink)&quot; -ssh -batch -l $(SshUser) -i $(SshKey) $(SshHost) rm -rf $(RemoteiOSBuildPath)" />
<Exec command="&quot;$(Plink)&quot; -ssh -batch -l $(SshUser) -i $(SshKey) $(SshHost) mkdir -p $(RemoteiOSBuildPath)" />

<!-- copy the source over to the mac -->
<Exec command="&quot;$(Pscp)&quot; -sftp -batch -r -i $(SshKey) -l $(SshUser) &quot;$(SolutionRoot)*&quot; $(SshHost):$(RemoteiOSBuildPath)" />

<!--run the build script-->
<Exec command="&quot;$(Plink)&quot; -ssh -batch -l $(SshUser) -i $(SshKey) $(SshHost) chmod +x $(RemoteBuildScriptPath)"/>
<Exec command="&quot;$(Plink)&quot; -ssh -batch -l $(SshUser) -i $(SshKey) $(SshHost) $(RemoteBuildScriptPath)"/>

<!-- copy the packaged ipa file back -->
<Exec command="&quot;$(Pscp)&quot; -sftp -batch -i $(SshKey) -l $(SshUser) $(SshHost):$(RemoteIpaFilePath) ." />