Continuous Integration and Delivery of Enterprise iOS applications using CircleCI, fastlane and AWS S3

Axel Möller
HackerNoon.com
Published in
5 min readMar 13, 2017

--

Background: Budbee is a last-mile logistics service offering home deliveries for e-commerce shoppers. We’re a tech startup based in Stockholm, Sweden.

At Budbee we have several iOS applications that are used by multiple stake holders, e.g. Drivers. Since we have to move in a fast pace in our development, we distribute these applications using Apples Enterprise distribution (There’s no way we can wait 1–4 weeks for a deploy).

I personally always thought that managing code signing assets and performing deploys of iOS applications is a hassle, so we decided to automate as much as possible of the process so we can focus on more important stuff.

We use CircleCI together with fastlane to continuously build and distribute our iOS applications to an S3 bucket. This bucket has a JSON file describing the latest version so we can prompt our users to upgrade if a new version is available. We use sentry for crash analytics, and our dSYMs are automatically uploaded when we deploy a new version.

Use match to manage code signing assets

Every developer needs to have his own set of private and public keys for development, and whoever does the distribution must have the distribution certificate on their machine. When setting up a new developer or migrating to a new machine you always manage to mess something up. Bleh, we just want to get our app out there — so we use match to manage our code signing assets for us. It keeps everything synced on git.

  1. Create a new private github repo in which you will store the profiles
  2. Create a new Apple ID to use for code-signing that will be shared between your team (e.g ios-dev@company.com)
  3. Run match to generate new certificates and provisioning profiles
$ MATCH_FORCE_ENTERPRISE=”1" match enterprise

Match don’t allow Enterprise distribution by default, due to it’s dangerous nature (if your keys are out and about, anyone can misuse your company name) hence the MATCH_FORCE_ENTERPRISE variable. We made sure our github repo is private, only the iOS dev team has access to it, and they have 2-factor authentication on their accounts.

4. Follow the instructions in match, use your newly created Apple ID for authentication.

Setup CircleCI

In the root of your iOS project, create a circle.yml file and populate it with the template below:

machine:
environment:
MATCH_FORCE_ENTERPRISE: "1"
xcode:
version: 8.2
dependencies:
pre:
- gem update fastlane
- brew install getsentry/tools/sentry-cli
cache_directories:
- 'vendor/bundle'
- 'budbee-driver/Pods'
- '~/.cocoapods'
compile:
override:
- fastlane build
test:
override:
- fastlane test
deployment:
staging:
branch: develop
commands:
- fastlane staging
release:
tag: /v?[0-9]+(\.[0-9]+)*/
commands:
- fastlane deploy
- fastlane dsym

In our circle.yml we set the environment flag MATCH_FORCE_ENTERPRISEto allow enterprise distribution, set the Xcode version to 8.2 (Use whatever you need here), update to the latest version of fastlane and install sentry-cli (for uploading dSYMs)

You will need an OS X plan on CircleCI to build iOS/OSX projects. Follow your github repo and make sure starts a build using the circle.yml settings.

You need to populate environment variables in the CircleCI project, namely MATCH_PASSWORD (Your password to unlock the code signing certificate, which you chose when setting up match), S3_ACCESS_KEY, S3_SECRET_ACCESS_KEY (your AWS IAM keys that has access to the S3 bucket) and SENTRY_AUTH_TOKEN (an auth token generated by sentry.io)

We have separated our Fastfile with different lanes for compiling, testing and distributing. When we push code to any branch, the project is built and tested. When we push to the develop branch a staging version of the application is deployed to S3 and we push a new version tag (v1.2.3) a production build is deployed to S3 and the dSYM is uploaded to Sentry.

Setup fastlane

fastlane/Fastfile:

fastlane_version "2.17.1"default_platform :iosplatform :ios dolane :build do
match
gym
end
desc "Runs all the tests"
lane :test do
scan(
workspace: 'budbee-app/myapp.xcworkspace',
scheme: 'myapp',
devices: ['iPhone 5s']
)
end
desc "Deploy new version to S3 bucket"
lane :deploy do
aws_s3(
access_key: ENV['S3_ACCESS_KEY'],
secret_access_key: ENV['S3_SECRET_ACCESS_KEY'],
bucket: 'budbee-apps',
region: 'eu-west-1',
ipa: 'myapp.ipa',
dsym: 'myapp.app.dSYM.zip',
app_directory: 'com.budbee.myapp',
upload_metadata: true,
html_template_path: 'fastlane/s3_ios_html_template.erb',
version_template_path: 'fastlane/s3_ios_version_template.erb',
version_file_name: 'app_version.json'
)
end
desc "Upload dSYM to Sentry"
lane :dsym do
sentry_upload_dsym(
auth_token: ENV['SENTRY_AUTH_TOKEN'],
org_slug: 'budbee-ab',
project_slug: 'budbee-app',
dsym_path: './myapp.app.dSYM.zip'
)
end
desc "Deploy new staging version to S3 Bucket (staging)"
lane :staging do
aws_s3(
access_key: ENV['S3_ACCESS_KEY'],
secret_access_key: ENV['S3_SECRET_ACCESS_KEY'],
bucket: 'budbee-apps',
region: 'eu-west-1',
ipa: 'myapp.ipa',
dsym: 'myapp.app.dSYM.zip',
app_directory: 'com.budbee.myapp.staging',
upload_metadata: true,
html_template_path: 'fastlane/s3_ios_html_template.erb',
version_template_path: 'fastlane/s3_ios_version_template.erb',
version_file_name: 'app_version.json'
)
end
end

fastlane/Appfile:

app_identifier "com.budbee.myapp" # The bundle identifier of your app
apple_id "ios-dev@company.com" # Your Apple email address
team_id "[[DEV_PORTAL_TEAM_ID]]" # Developer Portal Team ID

fastlane/Gymfile:

workspace "budbee-app/myapp.xcworkspace"
scheme "myapp"
export_method "enterprise"
output_directory "./"

fastlane/Matchfile:

git_url "git@github.com:budbee/ios-certificates.git"type "enterprise"readonly trueapp_identifier ["com.budbee.myapp"]
username "ios-dev@company.com" # Your Apple Developer Portal username

Install the needed fastlane plugins

$ fastlane add_plugin aws_s3
$ fastlane add_plugin sentry

fastlane/s3_ios_html_template.erb:

<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Install <%= title %></title>
</head>
<body>
<style type="text/css">
* {
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
text-align: center;
background-color: #ffffff;
}
.oneRow {
width: 100%;
overflow: auto;
overflow-y: hidden;
white-space: nowrap;
text-align: center;
}
.download {
margin: 30px;
font-size: 130%;
}
a {
text-decoration: none;
color: blue;
}
a:hover {
text-decoration: underline;
}
#finished { display: none; }
#tutorial {
text-align: center;
max-width: 300px;
}
#logo {
text-align: center;
max-width: 150px;
margin-top: 10px;
}
</style>
<h1 style="text-align: center;"><%= title %></h1><div class="oneRow">
<span class="download" id="ios">
<a href="itms-services://?action=download-manifest&url=<%= plist_url %>" id="text" class="btn btn-lg btn-default" onclick="document.getElementById('finished').id = '';">
Install <%= title %> <%= bundle_version %>
</a>
</span>
</div>
<h3 id="desktop">Please open this page on your iPhone!</h3><div id="finished">
<p>App is being installed. You may close Safari.</p>
</div>
<img src="https://company.com/logo.png" id="logo" />
</body>
<script type='text/javascript'>
if (/iPhone|iPad|iPod/i.test(navigator.userAgent))
{
document.getElementById("desktop").remove()
}
else
{
document.getElementById("ios").remove()
}
</script>
</html>

fastlane/s3_ios_version_template.erb:

{
"latestVersion": "<%= bundle_version %>",
"updateUrl": "itms-services://?action=download-manifest&url=<%= plist_url %>"
}

Keeping the app up-to-date

Upon start of our iOS application, it checks the S3 bucket if there is a new version available, and if so prompts the user to update.

$ pod install Alamofire
$ pod install Version

UpdateManager.swift:

import Foundation
import Alamofire
import Version
@objc class UpdateManager: NSObject {

func checkForUpdate() {

let baseUrl = "https://budbee-apps.s3-eu-west-1.amazonaws.com"
let bundleIdentifier = Bundle.main.bundleIdentifier!
let url = "\(baseUrl)/\(bundleIdentifier)/app_version.json"

let currentVersion: Version = Version(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String)

Alamofire.request(url).responseJSON { response in
if let result = response.result.value {
let JSON = result as! NSDictionary

if let version = JSON.value(forKey: "latestVersion") {
let latestVersion: Version = Version(version as! String)
let updateUrl = JSON.value(forKey: "updateUrl") as! String
if (currentVersion < latestVersion) {
self.displayUpdateAlert(url: updateUrl)
}
}

}
}
}

func displayUpdateAlert(url: String) {
let alertController: UIAlertController = UIAlertController(title: "New Version", message: "There is a new App version available. You must update to continue", preferredStyle: .alert)

let button: UIAlertAction = UIAlertAction(title: "Update App", style: .default) { (action) in
UIApplication.shared.openURL(URL(string: url)!)
}

alertController.addAction(button)

UIApplication.shared.keyWindow?.rootViewController?.present(alertController, animated: true, completion: nil)
}
}

That’s it! You should be able to visit the url to your S3 bucket on any iOS device and install the enterprise application. When you deploy a new version, you will be prompted to update right from within the application.

Hacker Noon is how hackers start their afternoons. We’re a part of the @AMIfamily. We are now accepting submissions and happy to discuss advertising & sponsorship opportunities.

To learn more, read our about page, like/message us on Facebook, or simply, tweet/DM @HackerNoon.

If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories. Until next time, don’t take the realities of the world for granted!

--

--