iOS Deployment and Provisioning - Part III - Tools in Detail - Fastlane
Fastlane
Fastlane is a collection of about a dozen different tools for communicating with iTunes Connect, the Apple Developer Portal and 3rd party services in a scriptable manner.
It has a concept of 'lanes' these are separate pipelines of tasks that can be run in sequence. In our configuration we have just three lanes, test
, alpha
and release
. It may seem odd to jump all the way from an alpha to release, with no apparent beta, but in fact there is no real distinction between a beta, a release candidate and a production release. When a release
build is uploaded to TestFlight it is marked as 'beta' meaning it is available for testing via TestFlight. But any one of those builds could be submitted to Apple for review for the App Store.
The test lane just builds the app for testing and runs the unit tests over the app. Both the alpha and release lanes also run the full test suite before building the app for being uploaded. We could keep the test and build lanes separate, but as we'd want to run the tests before any build, I thought I might as well include them in the lane.
So we have two convenience methods, do_build
and run_tests
that are called by the respective lanes:
def run_tests
for scheme in ['Nutrition', 'NutritionAppTest']
xctest(
scheme: scheme,
workspace: 'Nutrition/Nutrition.xcworkspace',
destination: 'platform=iOS Simulator,name=iPhone 6',
reports: [
{
report: "junit",
output: "build/reports/#{scheme}-tests.xml"
}
],
clean: nil,
derivedDataPath: './build',
)
end
end
We have multiple test suits that we want to run in isolation of each other, hence we loop over the two test suites: Nutrition
and NutritionAppTest
. It calls xctest
which fires up an iOS simulator on the Mac Mini and runs the tests on it. If you VNC into the Mac Mini you can see the simulator start up and the tests run. We output the tests in junit
format for Jenkins to be able to parse later. I've set clean: nil
as Jenkins is set to wipe the workspace between each build anyway, so no need to clean. The derivedDataPath
argument means all the build artefacts end up in the workspace, rather than put in a shared space in the user's home directory by default. This means they too are cleared when the workspace is wiped, and multiple Jenkins jobs don't taint each other with build artefacts.
def do_build(scheme, configuration)
increment_build_number(
build_number: ENV['BUILD_ID']
)
profile = 'build/nutrition.mobileprovision'
if configuration == 'Release'
app_identifier = 'com.enquos.nutrition'
display_name = 'enquos nutrition'
set_istest_false
sigh(
app_identifier: app_identifier,
filename: profile,
)
else
app_identifier = 'com.enquos.Nutrition.alpha.' + GIT_BRANCH_ID
display_name = 'enquos nutrition ' + GIT_BRANCH
sigh(
app_identifier: '*',
adhoc: '1',
filename: profile,
)
end
update_info_plist(
app_identifier: app_identifier,
display_name: display_name,
plist_path: 'Nutrition/Info.plist',
xcodeproj: 'Nutrition/Nutrition.xcodeproj',
)
update_project_provisioning(
xcodeproj: "Nutrition/Nutrition.xcodeproj",
build_configuration_filter: ".*Nutrition.*",
profile: profile,
)
ipa(
scheme: scheme,
configuration: configuration,
embed: profile,
xcargs: '-derivedDataPath ./build',
)
end
The do_build
function does a number of steps:
- Work out the build number, in this case we take it from an environment variable supplied by Jenkins.
- Set the app identifier and the display name depending on whether this is a release build or alpha. It is is an alpha build then we create the app id and display name based on the Git branch. This allows us to have multiple distinct apps on a device at one time, each from a different branch of the code.
- Download the correct provisioning profile from the Apple Developer Portal (
sigh
). If this is a release, then use our main app store distribution profile. If it is an alpha, then use our wildcard adhoc profile. This profile contains the UDIDs of our developers and anyone we want to test the app. By downloading this each time, it means we always build with the latest list of devices. - Update the info.plist and provisioning files in the app to contain the information calculated above. This basically overrides any settings that might be already contained in the code from Xcode.
- Finally, build the ipa file using the specified configuration and scheme. It should be possible to infer the configuration from the scheme, as that is where you set it in Xcode, but due to a bug in xcodebuild it picks the wrong configuration, so we specify it manually here.
Before we run any of the lanes, we set a few variables based on variables supplied by Git and we run the tests. We set the text to use for the testing notes shown by TestFlight / Hockey to be the git commit log. We explicitly choose which certificate ID to use as we have two registered in the Apple Developer Portal. And we munge the Git branch into something that looks like an id.
before_all do
ENV['DELIVER_WHAT_TO_TEST'] = git_commit_log
ENV['SIGH_CERTIFICATE_ID'] = 'XXXXXXXXXX'
GIT_BRANCH = ENV['GIT_BRANCH'] || 'unknown'
GIT_BRANCH_ID = GIT_BRANCH.dup
GIT_BRANCH_ID.gsub! '/', '.'
GIT_BRANCH_ID.gsub! /[^A-Za-z0-9\-.]/, "-"
run_tests
end
Now we have the actual lanes themselves. The test lane is short and sweet. It doesn't actually do anything as the tests themselves are run in the before_all
block above:
lane :test do
# Tests run in before_all
end
The alpha lane is the first lane that does any real work:
lane :alpha do
do_build('Nutrition', 'Beta')
hockey(
api_token: 'deadbeefdeadbeefdeadbeef',
notes: git_commit_log,
notify: '0', # Means do not notify
status: '2',
release_type: '2',
)
if ENV["SLACK_URL"]
slack(
message: "New alpha build available for download",
success: true,
payload: {
'Build number' => ENV['BUILD_ID'],
'Git branch' => ENV['GIT_BRANCH'],
'Download URL' => Actions.lane_context[ Actions::SharedValues::HOCKEY_DOWNLOAD_LINK ],
'What\'s new' => git_commit_log,
},
default_payloads: [],
)
end
end
First we call the do_build
routine with the schema Nutrition
and the configuration Beta
. Xcode projects start off by default with two configurations: beta
and release
. These control whether things like profiling and debugging symbols are left in or stripped. So we just use the default name of 'Beta' here.
Then we upload the build to Hockey. We pass in the release notes and tell Hockey what sort of build this is (2: alpha). Then, if we have a Slack URL environment variable defined, we post a message to Slack announcing the successful build and with a link to the download URL on Hockey.
The release
lane is very similar:
lane :release do
do_build('Nutrition Release', 'Release')
hockey(
api_token: 'deadbeefdeadbeefdeadbeef',
notes: git_commit_log,
notify: '0', # Means do not notify
status: '1', # Means do not make available for download
release_type: '1',
public_identifier: 'deadbeefdeadbeefdeadbeef',
)
deliver(
beta: true,
)
if ENV["SLACK_URL"]
slack({
message: "Nutrition Release build #{ENV['BUILD_ID']} uploaded to Testflight"
})
end
end
We do the build with the Release
configuration. This strips out debug symbols and the likes. We upload it to Hockey, and set the release type to be a proper release (1) and we set the status so that it is not downloadable from Hockey. This is due to it being uploaded to TestFlight instead. As it will have been signed with an App Store provisioning profile, it can't be uploaded to Hockey for download anyway.
The deliver
command is the one that actually uploads to Testflight.
We then send a message to Slack. There is no point putting a public URL here as TestFlight will automatically notify testers on their device and by email that a new build is available.
After all this is done, we reset the Git repo. This is not necessary for the Jenkins run builds, as the workspace is wiped anyway, but is nice for when running manually as it means you are back to a clean slate:
after_all do |lane|
reset_git_repo(
force: true,
files: [
"Nutrition/Nutrition.xcodeproj/project.pbxproj"
]
)
end
In the next post I'll go into detail about Jenkins and how we automatically run Fastlane when changes are committed.