This is part 3 of my Creating a Build-Once iOS Deployment Pipeline series.

In part 2 we looked what our deployment looked like at a high level, as well as what we were trying to achieve. And now we finally start to put things together. For those that made it through the first two posts, I thank you. To those that skipped them, I don’t blame you. Let’s get to it.

The build and test phases both require the application code repository and are actually relatively simple so we’ll include them both in this part.


Build Server Environment

LaunchAgents vs LaunchDaemons

If you are configuring a headless build agent, you’ll probably want to configure it as either a LaunchAgent or LanuchDaemon. If you need to access the Simulator for your automated tests, LaunchAgent is your only choice since LaunchDaemons can’t access the UI. Since LaunchAgents only run when the user is logged in, you’ll probably want to configure the user to auto-login in case the server is rebooted.

Relevant Apple documentation:

XCode Versions

Whether you have numerous applications in varying states of legacy or a new app that you’d like to test against newer tooling on a branch, you’ll eventually want to make use of multiple versions of XCode. Apple provides downloads for every version of XCode and the Command Line Tools at the Downloads for Apple Developers on the Developer Portal.

You can set the DEVELOPER_DIR directory before your build and it will use the appropriate XCode/Tooling installation.

Dependency Management

Both Ruby Gems and Homebrow both install packages under /usr/local, which your build user won’t necessarily be able to write to.

For Gems, you can set the GEM_HOME variable to something like ~/.gems in your .bashrc. If you use Ruby Version Manager (rvm), it will do this for you. If you’re setting this manually, just don’t forget to append it to $PATH. Stick to using the bundle install and bundle exec fastlane syntax, as that will ensure that the same versions are always used.

Homebrew dependencies, like ImageMagick (required by fastlane’s badge), aren’t so easy to isolate. I would still recommend using a bundler like Brew Bundle, but how you deal with being able to write to /usr/local is up to you. Your choice is essentially between chowning the /usr/local directory or configuring Brew to install to a profile-local directory. I went with the former, but you can read this Stack Exchange question and decide for yourself.


I’m not going to walk through how to setup fastlane, since this is not a fastlane tutorial. We don’t need any of the default lanes, so you can delete them if you don’t need then. We also won’t need any if the other config files (Matchfile, Deliverfile) though, again, you’re free to keep them if they work for you. You could even skip initializing fastlane and start writing your “lanes” (tasks) yourself.

One recommendation I will make is to use a Gemfile and the “bundle exec” syntax for both fastlane and any other build tools you might be using (ie. Cocoapods). These tools often break compatibility and a Gemfile allows you to lock the version local to your app, rather than relying on the globally installed version.

You may need to add some variables to your .bashrc to set the locale to en_US.UTF-8 to avoid any “invalid byte sequence” errors. See fastlane/#488 for more details.

Also keep in mind that, for all fastlane actions, almost all of their options can be specified as arguments, environment variables, or in a separate configuration file. Don’t be afraid to look at the source code and use GitHub’s “search in repository” feature. Here’s a direct link to all the actions we’re using:



To build, we use Gym, which will build an xcarchive and export it as an IPA. The IPA should be unsigned, since we won’t know which signing identity to use until deployment, and it also avoid having to access the signing identity repository during this phase.

Gotcha #5: Gym doesn’t officially support a way of creating an unsigned IPA, and doesn’t make it easy to do due to the way it’s designed. Gym tests both builds and exports. If it’s signing identity option is applied as a blank string, the build will succeed but the export will attempt to sign it with a blank signing identity, which falls. There’s also no way to just build and not export. As a workaround, we use the xcargs option to supply additional command arguments to xcodebuild but not xcrun. See fastlane/#5617

Gotcha #6: Xcode 7 introduced a new API for exporting archives. The new system is triggered by using the -exportArchive parameter with a manifest specified by -exportOptionsPlist , and supports newer iOS features such as app thinning and bitcode. However, it doesn’t support generating unsigned IPAs based on my research. Still, we’ll look at how we can take advantage of those features in a future post.

desc "Build an unsigned IPA artifact" 
lane :build do 
    output_directory: "./fastlane/build", 
    scheme: "MyApp", 
    configuration: "Release", 
    use_legacy_build_api: true, 


To test, we use Scan, selecting the simulator (or device) to run on and the scheme to use when building the tests.

Gotcha #7: Scan always outputs test results using the selected format as a file extension, in our case report.junit. Some CI servers might not pick this up, so we’ll rename the file to .xml.

desc "Run unit tests"
lane :test do
      scheme: "MyApp",
      device: "iPhone 5 (9.1)",
      output_types: "junit"
    #rename it so CI can find it
    sh "mv test_output/report.junit test_output/report.xml"

Putting it all together

Here’s a gist of the fully assembled Fastfile:

Build artifacts

We now have two sets of artifacts that can be captured:

fastlane/test_output/report.xml - unit test results for your CI server.

fastlane/build/DeploymentPipeline.ipa and fastlane/build/ - application archive and symbols archive. Well need to keep both for our deployment phase: the IPA contains our application and the is useful for getting meaningful crash reports in services like HockeyApp, TestFlight, and Sentry. How you capture these artifacts will depend on your CI server, but most should support a glob-style pattern.

In Part 4 we’ll setup our deployment fastfile in the configuration repository.