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:
- Github repository containing project's sources.
- Travis CI integration configured to build the project on every branch push. The simplest
.travis.yml
will suffice. - 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:
- Follow the steps presented in match Usage readme.
- 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:
- Github account with read-only access to certificates repository. This account is needed for match to work properly.
- 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:
-
Enable two-factor authentication for security purposes.
-
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.
- Name your key
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:
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
generated as described by Apple,FASTLANE_SESSION
generated by invokingfastlane spaceauth -u [email protected]
command.
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:
-
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. -
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!
-
Authenticate with Travis CLI by executing
travis login
command. -
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.
-
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 theexport
results would not be visible to the Travis shell.
Let's break down what .autodeployment.sh
script does:
-
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 whatStrictHostKeyChecking no
takes care of. -
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. -
It adds the key to SSH agent (
ssh-add ~/.ssh/id_rsa
). -
It sets up the credentials used by fastlane to access Apple Developer Center. Those are exported
FASTLANE_USER
andFASTLANE_PASSWORD
environment variables. -
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:
-
Fetches the latest build number from TestFlight and updates the project with its incremented value.
-
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.
-
Invokes
match
to fetch a certificate into a newly created keychain. Thematch
is invoked in a read-only mode so that it can't mess up the certificates repo. -
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:
- Append
- bundle install
tobefore_install
phases. - Add
- fastlane ios autodeploy
toafter_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.