Continuous delivery with Travis and fastlane

Continuous delivery is a key to a healthy communication with your customer. However, it is not a common practice for iOS developers to set up a continuous delivery pipeline. It is likely caused by the inferior tooling provided by the Apple itself.

This tutorial aims to present a painless way to set up the continuous delivery for your project. It also describes common pitfalls encountered during the process and how to avoid them.

The tutorial is based on a continuous delivery implementation for EL Debate open source project. In case anything goes wrong, you can always check out the sources and see for yourself. 😊

The goals

The aim of this tutorial is to have a new version of the application uploaded to TestFlight beta every time pull request is merged into a master branch of the git repository.

Since we always merge pull requests through Github, the tutorial takes a significant shortcut. The merges are detected by Merge pull request #{no.} from #{branch} pattern lookup in the last commit message.

Prerequisites

Following preliminary steps are required to follow the tutorial:

  1. Github repository containing project's sources.
  2. Travis CI integration configured to build the project on every branch push. The simplest .travis.yml will suffice.
  3. Bundler should be installed on your machine. Just run gem install bundler command.

Example .travis.yml configuration:

osx_image: xcode8.3
language: objective-c
podfile: Podfile
before_install:
- pod repo update
- pod install
script:
- set -o pipefail && xcodebuild test -workspace TheWorkspace.xcworkspace -scheme TheScheme -sdk iphonesimulator10.3 -destination 'name=iPhone SE,OS=10.3' ONLY_ACTIVE_ARCH=NO | xcpretty

Auto deployment setup

Installing fastlane & travis

The first step is to create an empty file called Gemfile in your project directory. It should contain following dependencies:

source 'https://rubygems.org'

gem 'fastlane'
gem 'travis'

(Probably, you would want to include gem 'cocoapods' too). To install the dependencies, just run bundle install command in the project root directory.

Configuring match

The tutorial uses match to handle the code signing. Match is a superior way of managing Xcode signing certificates. It is much more convenient than built-in approach:

  • You need only one set of certificates for a single project. Normally, you would need one set of certificates per team member per project.
  • Portable code signing configuration for the app. 🎉

In order to set up match:

  1. Follow the steps presented in match Usage readme.
  2. Configure the Xcode project to use match-generated certificates.

While following the instructions, please keep in mind that you need to meet additional requirements for match to seamlessly work with Travis:

  • The match repository URL should use SSH protocol rather than HTTPS (it would look like [email protected]:organization/certs.git).
  • Do NOT include username entry in the Matchfile. As it is shown later in the tutorial, you will need a dedicated Apple Developer account for Travis CI builds.

Creating dedicated Travis accounts

Although technically possible, it is not advised to use your personal credentials for continuous deployment. Therefore you would need to create additional:

  1. Github account with read-only access to certificates repository. This account is needed for match to work properly.
  2. Apple Developer account. The account needs following permissions under your organization:
  • Member access to certificates panel,

  • Developer access to iTunes Connect.

    Never use the Admin privileges to reduce an impact of hypothetical credentials leak.

Apart from granting read-only access to match certificates repository, following actions are needed for the Github account:

  1. Enable two-factor authentication for security purposes.

  2. Generate a new SSH key on your local machine:

    • Name your key travis_ci_rsa.
    • Use blank password 😢 to be able to use the key without the password prompt.
    • Be extremely careful not to override your existing SSH key.
  3. Add generated SSH key to the account.

Unfortunately, at the time of writing the article you cannot enable two-factor authentication on the Apple Developer's account. There is an unresolved issue that prevents using it on Travis CI specifically. If it was fixed, you would have to set two additional environment variables to usie two-factor authentication:

Autodeployment secrets

Having configured continuous delivery accounts, the next step is to create a script file that sets up all the secrets. The script will be executed before any Travis build takes place.

Since the setup script contains the sensitive data, it needs a special treatment to stay secure:

  1. Add .autodeployment.sh to repository's .gitignore file. Commit and push the change first. This will prevent from accidentally commiting the unencrypted script file later on.

  2. Create an empty .autodeployment.sh file. Populate it with following contents:

     #!/bin/bash
     echo -e "Host *\n\tStrictHostKeyChecking no\n" >> ~/.ssh/config
     echo -e "{{travis_ci_rsa.pub file content}}" > ~/.ssh/id_rsa.pub
     echo -e "{{travis_ci_rsa file content}}" > ~/.ssh/id_rsa
     chmod 600 ~/.ssh/id_rsa*
     eval `ssh-agent -s`
     ssh-add ~/.ssh/id_rsa
     export FASTLANE_USER='{{Apple Developer account username}}'
     export FASTLANE_PASSWORD='{{Apple Developer account password}}'
     export MATCH_PASSWORD='{{match repository password}}'
     echo "Environment variables set"
    

    You should never ever commit that file to git repository!

  3. Authenticate with Travis CLI by executing travis login command.

  4. Run travis encrypt-file .autodeployment.sh --add command. It:

    • Generates a random key used to encrypt/decrypt files and stores it as environment variables.
    • Creates encoded .autodeployment.sh.enc file that you can safely commit to the repository.
    • Adds a new before_install step to your .travis.yml that decrypts the file contents before running actual build.
  5. Define two additional before_install steps in .travis.yml:

     - chmod +x .autodeployment.sh
     - . ./.autodeployment.sh
    

    The steps must be preceded by decrypting the script file.

    The script execution command (./.autodeployment.sh) is prefixed with additional .. It sources the script, executing all the instructions in the same shell as the command itself. Otherwise, the script would be executed in a child process and the export results would not be visible to the Travis shell.

Let's break down what .autodeployment.sh script does:

  1. It modifies SSH configuration by appending two lines to ~/.ssh/config:

     Host *
         StrictHostKeyChecking no
    

    This disables an interactive prompt that asks to add server fingerprint to known_hosts. If you have ever used SSH, you have probably seen this prompt:

     The authenticity of host '192.168.0.169 (192.168.0.169)' can't be established.
     ECDSA key fingerprint is 74:39:3b:09:43:57:ea:fb:12:18:45:0e:c6:55:bf:58.
     Are you sure you want to continue connecting (yes/no)?
    

    It needs to be disabled in order to perform a SSH git clone in a non-interactive environment such as Travis CI. This is exactly what StrictHostKeyChecking no takes care of.

  2. It copies private (id_rsa) and public (id_rsa.pub) keys used to authenticate to match repository. You need to paste in the actual key contents that you have generated when setting up the Github account earlier.

  3. It adds the key to SSH agent (ssh-add ~/.ssh/id_rsa).

  4. It sets up the credentials used by fastlane to access Apple Developer Center. Those are exported FASTLANE_USER and FASTLANE_PASSWORD environment variables.

  5. It sets up the credentials to download certificates from match repository (MATCH_PASSWORD variable).

Why not provide environment variables directly?

Instead of exporting environment variables in a script file I could simply define them in Travis build settings. I like this approach better. However, Travis has a length limit of 128 bytes per variable and also requires variables to be bash-escaped for double quote input. After encountering those issues on a couple of occasions, I decided to go with the script approach instead.

Creating auto deployment lane

Having all the secrets in place, we can proceed to creating a fastlane script for auto deployment. Let's start by invoking fastlane init command in the project root directory. After answering a couple of simple questions, the script will generate the Fastfile located under fastlane directory.

As described in the goals section, we want the script that is executed only upon merging a pull request. Fastlane does not provide such a check out of the box. Fortunately, it is really easy to implement this action by hand. Let's create a new action by calling fastlane new_action and call it merges_pull_request. Now, open fastlane/actions/merges_pull_request.rb file and paste in following contents:

module Fastlane
  module Actions
    class MergesPullRequestAction < Action
      def self.run(params)
        branch = GitBranchAction.run(params)

        unless branch == "master"
          return false
        end

        last_git_commit = LastGitCommitAction.run(params)[:message]

        if last_git_commit.match(/Merge pull request #\d+ from .+/)
          true
        else
          false
        end
      end

      #####################################################
      # @!group Documentation
      #####################################################

      def self.description
        "Checks whether last commit merges a PR"
      end

      def self.return_value
        "True if merges a PR, false otherwise"
      end

      def self.is_supported?(platform)
        true
      end
    end
  end
end

Now we can implement the auto deployment lane. Open fastlane/Fastfile and add following code:

desc "Auto deploy to TestFlight if merges pull request"
lane :autodeploy do
  if merges_pull_request
    # increment build number
    build_number = latest_testflight_build_number + 1
    increment_build_number(build_number: "#{build_number}")
    # create a new keychain
    password = SecureRandom.base64
    keychain_name = "fastlane"
    ENV["MATCH_KEYCHAIN_NAME"] = keychain_name
    ENV["MATCH_KEYCHAIN_PASSWORD"] = password

    create_keychain(
      name: keychain_name,
      default_keychain: true,
      unlock: true,
      timeout: 3600,
      lock_when_sleeps: true,
      password: password
    )

    # fetch provisioning profile
    match(
      type: "appstore",
      keychain_name: keychain_name,
      keychain_password: password,
      readonly: true
    )

    # build the app
    gym(scheme: "SchemeName", clean: true)

    # upload to TestFlight
    pilot
  end
end

The script performs following actions:

  1. Fetches the latest build number from TestFlight and updates the project with its incremented value.

  2. Creates a new keychain. Otherwise, MacOS would display an interactive prompt during the build phase which freezes the build indefinitely.

    Source: Xcode 8.1 support #6791.

  3. Invokes match to fetch a certificate into a newly created keychain. The match is invoked in a read-only mode so that it can't mess up the certificates repo.

  4. Does a clean build of the project and uploads the resulting .ipa file to TestFlight.

Attaching auto deploy lane to Travis build

The last step is to make Travis invoke auto deployment lane for every build. It requires two changes to .travis.yml file:

  1. Append - bundle install to before_install phases.
  2. Add - fastlane ios autodeploy to after_success phases.

The resulting .travis.yml file should look similar to:


osx_image: xcode8.3
language: objective-c
rvm:
- 2.2
podfile: Podfile
before_install:
- openssl aes-256-cbc -K $encrypted_key -iv $encrypted__iv -in .autodeploy.sh.enc -out .autodeploy.sh -d
- chmod +x .autodeploy.sh
- . ./autodeploy.sh
- bundle install
- pod repo update
- pod install
script:
- set -o pipefail && xcodebuild test -workspace TheWorkspace.xcworkspace -scheme TheScheme -sdk iphonesimulator10.3 -destination 'name=iPhone SE,OS=10.3' ONLY_ACTIVE_ARCH=NO | xcpretty
after_success:
- fastlane ios autodeploy

The .travis.yml file presented above sets the Ruby interpreter to version 2.2. By default, Travis uses Ruby v1.9 which is unsupported by the latest Cocoapods release.

Congratulations!

From now on your pull requests are automatically delivered to TestFlight! 🎉

It is high time to roll some awesome apps.

Show Comments