iOS Continuous Delivery with Fastlane

Continuous Delivery (CD) and  DevOps practices accelerate the delivery of new features to end users. The fast-paced world of iOS app development can benefit from these to release an app quickly and easily to production.

In the iOS world, releasing is difficult. It involves complicated steps like code signing and dealing with Apple development and distribution certificates. It can be an error-prone and time consuming process. A manual release is done by building, testing, and archiving an iOS app from Xcode locally. The archived .ipa file then needs to be uploaded to iTunes Connect. Ideally we would want to automate this process using Continuous Integration (CI) which pushes every commit or merge to the main branch to Apple’s beta testing service TestFlight without any manual intervention. In this post, we’ll explain how we achieved Continuous Deployment using the build automation tool Fastlane.

Fastlane offers the most common business benefits of CD, but also offers benefits to other engineering teams including:

  • Eliminates DIY for DevOps and focus on building native iOS features.
  • New builds are uploaded automatically to iTunes Connect after every merge.
  • Automates repetitive tasks of upgrading or setting up new CI server machines.
  • Manages all the Infrastructure from source code.
  • Developer computers and CI server have the same configuration.

The code snippets we’ve shared in this post are for reference purposes only.

Challenges

Previously, releasing our app was a mission; deployment was a day long marathon. We followed traditional, time consuming, manual releases from local machines. It was time consuming to build, test, archive and upload an iOS app from Xcode. The most painful part of release involved downloading correct provisioning profiles associated with distribution certificates. It was even more painful to repeat the process if we found an issue in the build uploaded to iTunes Connect or in production.

In order to streamline our release process, we had to overcome the following challenges:

  • Automate the process of analysing, building, testing, archiving and uploading the iOS app to iTunes Connect
  • Setup a Continuous Integration Server to use automated builds
  • Automate the process of resetting the CI server setup whenever needed (for example, when the Xcode version changes, or when any of the Apple APIs change)
  • Drive the iOS CI infrastructure from code a.k.a Infrastructure as Code
  • Make sure the software configuration on an iOS developer machine and CI server are the same. Say No to It Works on My Machine

We solved these problems using a combination of Fastlane, TeamCity and Ansible tools. Let’s explore this in detail.

Build Automation with Fastlane

Apple command line developer tools like xcodebuild are a powerful way to script anything we want to automate. However, commands we were writing could be very lengthy and cumbersome. Fastlane is a wrapper around Apple command line tools to make the build automation easier. There is a collection of Fastlane tools available to automate various iOS development tasks, e.g Scan is used for running tests, Gym is used for building an app, Pilot is used to upload an app to TestFlight.

 

Automating Swift Version Checking

The first step in our automated build process is to make sure that we start the CI build in a clean state using correct software versions e.g. Swift and Ruby. Fastlane provides a before_all  step which runs before all other lanes. You can read more about advance Fastlane to configure this step. We wrote custom Fastlane actions to check the version of Swift check_swift_version and to check Swift Toolchain check_swift_toolchain. It’s pretty straightforward to write a custom Fastlane action. Our before_all  step looked like this:

before_all do |lane,options|
    ensure_git_status_clean
    fastlane_version "2.20.0"
    check_swift_version(version: "Apple Swift version 3.1")
    check_swift_toolchain
    clear_derived_data
end

This ensures that every build starts with a clean state and a correct version of tools; there are no leftovers from the previous build.

Automating XCTest

We use the  XCTest Framework by Apple to write unit and UI tests. Fastlane made it easy to configure the XCTest by wrapping all the options from xcodebuild in Fastlane’s Scan tool.  Xcode has new features like build-for-testing  and test-without-building which means we build once and test multiple times using the .xctestrun file. We have a Scanfile  where all the common configurations like scheme, workspace and build configuration are stored. Our example lane looks like this:

lane :build_app_for_testing do
  scan(
    build_for_testing: true,
  )
end

desc "Run Unit Tests without building and generate code coverage"
lane :run_test_without_building do
  scan(
    test_without_building: true,
  )
  xcov(
    scheme: "our_test_scheme",
    derived_data_path: "./build",
    output_directory: "./build/reports/coverage/",
    skip_slack: true
  )
end

Automating Fabric Deployments

We use Fabric for testing debug or adhoc builds internally within the team. We don’t always need to test using Fabric or Crashlytics so we only make a build when needed. The build will be pushed to Crashlytics when a developer puts  #fabric  in the commit message. There is a fastlane action to push builds to Crashlytics, we just need to provide our API_TOKEN and GROUP. Our lane looks like this:

lane :distribute_to_crashlytics do
  commit_message = last_git_commit[:message]
  if commit_message.include? "#fabric"
    increment_build_number(build_number: build_number) if is_ci?
    gym(
      export_method: "ad-hoc",
      configuration: "AdHoc",
    )
    crashlytics(groups: "photobox-pr-reviewers", notes: commit_message)
  else
    puts "=== Skipping the Crashlytics build for now. ====="
  end
end

Automating TestFlight Deployments

Apple’s TestFlight is great way of beta testing an iOS application. We use TestFlight to test release candidates to be shipped to the App Store.  The process of automating the TestFlight build involves various things:

  • Ensuring Xcode Automated Signing is disabled
  • Getting the current version and build number from the Info.plist file
  • Incrementing the build number for the specific version
  • Uploading the build to TestFlight with the correct scheme, provisioning profiles and certificates
  • Committing back the build version bump to the main source repository
  • Creating and Push Tagging on Github
  • Creating Github Upload assets (.ipa) to Github Release
  • Sending a Slack notification that a new build is uploaded to TestFlight with release notes

It’s a lot to do!  Fortunately, Fastlane provides actions or plugins for each of these tasks. We get the current version of the app by using the get_version_number_from_plist plugin and current build number by using the Fastlane action latest_testflight_build_number. We use the increment_build_number action to increment the build number for the version and build our app with a ‘release’ configuration.  We then upload it to TestFlight using Pilot. One thing to note is that the increment_build_number action changes the Info.plist file which we need to commit back to the source code for future build creation. In order to commit this file, we use the commit_version_bump action.  Gym can now be used to build the app. Once the build is successfully uploaded we generate a Slack message to the team.

Our sample TestFlight lane looks like this :

lane :distribute_to_testflight do |options|
    scheme = "our_app"
    version = get_version_number_from_plist(xcodeproj: "our_project.xcodeproj", target: 'our_target', build_configuration_name: 'Release')
    current_build_number = latest_testflight_build_number(version: version)
    increment_build_number(
      build_number: current_build_number + 1
    )
    testflight_build = current_build_number + 1
    gym(
      scheme: scheme,
      export_method: "app-store",
      configuration: "Release",
    )
  
    pilot(
      skip_submission: false,
      distribute_external: false,
      skip_waiting_for_build_processing: true
    )

    slack_message(message: ":airplane: The new build is uploaded to testflight: version #{version} & Build number : #{testflight_build}", success: true, payload: {"Build Date" => Time.new.to_s,
              "Built by" => "Photobox iOS CI Server",})

    add_git_tag(
       tag: "#{version}-#{testflight_build}"
    )

    push_to_git_remote(
      remote: "origin",
      local_branch: "develop",
      remote_branch: "develop",
      tags: true
    )
end

The sample Slack message looks like this:

Automating Releases on Github

As part of our code review process, an engineer has to create a feature branch on GitHub and create a pull request against the ‘develop’ branch (main branch) once the feature is built. The pull request has to go though our SwiftLint rules and code review process. Once the pull request is merged to the main branch it will trigger our automated tests.  If all the tests pass the build will be uploaded to TestFlight with the incremented build number.

This means that every merge to the main branch goes to TestFlight, and any build can then be promoted for release. Once we select a release candidate build, assets such as the .ipa or .dSYM are uploaded to Github, storing artifacts from previous releases and change logs for reference. We use the Fastlane action set_github_releases to automate this process.

With this process we have almost achieved a fully automated CD process for iOS apps.

Next Challenge

Continuous Delivery isn’t possible without Continuous Integration.  All the above build automation scripts are automatically triggered from our TeamCity CI server.

In the next post, we will discuss what challenges we faced while maintaining the CI servers, and how we used Ansible to solve configuration management issues and speedy setup of iOS CI Server.

5 Responses

  1. With your automated deployment to TestFlight, how do you keep it from recursively triggering builds? You are watching the ‘develop’ branch for changes, to start the build, then in your build process you are committing to ‘develop’ which would trigger another build, right?

  2. Normally, yes it would do that. To get around this we added a filter on the VCS trigger in TeamCity that ignores commits made by our build user. In this case we add a trigger rule that excludes commits made by the ‘TeamCity Build Agent’ user.

  3. Great read, very insightful.

    Have you stumbled across tests failing and restarting, thus impacting code coverage at all?
    Our current tests seem to crash and restart from that point but without any guidance of what may have crashed. All that is visible is the Simulator starting log. Any thoughts?

    1. Hi Chris, thanks for your response! The honest answer is that we face the same issues. We had an issue not long ago whereby we were getting seemingly random crashes when running XCUI tests on CI. Alas, the build logs only reported a test time out. It was the ~/Library/Logs/DiagnosticReports crash logs that helped point us in the right direction. In terms of restarting the tests, we always consider one failure (whether be from XCTest, fbsnapshot or XCUI) to be an overall failure and therefore run the suite again. If you and your team have any further suggestions or ways forward (especially with iOS 11/Xcode 9) please let us know! 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *