OVERVIEW
The main concepts of CI/CD are continuous integration, continuous delivery and continuous deployment.
CI/CD introduces automation to the lifecycle of a mobile application from development and testing through to delivery to an App Store. This automation process can be called a CI/CD pipeline.
The following is a platform-agnostic template for deploying an iOS and Android app to the relevant stores. The iOS app will be deployed to iOS TestFlight. The Android app will be deployed to Android Internal Test Track.
Promoting to production would be the final step after successful testing.
Repo Branch Structure
The branch structure should allow for a formal and structured approach to support
- development, develop
- quality assurance, qa
- deployment to production, production
Code push and merge to a repository
Pipeline yaml file
The following is an example of a generic ‘.yml’ file for a CI/CD pipeline configuration. There are 2 workflows shown:
test- triggered every time a pull request (PR) is created.
- performs ci test
build-ios-android-on-merge-into-qa- triggered on push into /qa branch
definitions:
# instance types
instance_mac_pro:
instance_type: mac_pro
max_build_duration: 90
instance_linux:
instance_type: linux
max_build_duration: 60
.
.
.
scripts:
-
# bundle install will use the gem file to install fastlane. See
Setup section below.
script: |
cd ios
bundle install
- &fastlane_ios_beta
# Get secrets (keys) from AWS SecretsManager. This is not covered
as part of this template.
name: Build Sign Upload - iOS beta
script: |
# Build iOS and Sign Android app
bundle exec fastlane ios beta --env $2 key_id:$3 issuer_id:$4
key_content:$5 s3_region:$6 s3_access_key:$7 s3_secret_access_key:$8
s3_bucket:$9 slack_url:${10}
.
.
.
workflows:
test:
# Test
# - triggered via a pull request into any branch
name: Test
<<:
environment:
<<:
<<:
triggering:
events:
- pull_request
branch_patterns:
- pattern: '*'
include: true
source: true
cancel_previous_builds: false
scripts:
- echo 'test-npm-ci'
-
-
publishing:
<<:
build-ios-android-on-merge-into-qa:
# Build iOS and Sign Android app
# - this will put the build (.ipa) into TestFlight for client
# - this will put the build (.aab) into the beta track in the
Android Console for client
name: Build iOS and Android on merge into QA
<<:
working_directory: app
environment:
<<:
<<:
triggering:
events:
- push
branch_patterns:
- pattern: qa
include: true
source: true
cancel_previous_builds: false
scripts:
-
-
-
-
-
artifacts:
-
-
publishing:
<<: From the Yaml file, we can see that a PR merged into the qa branch will trigger build-ios-android-on-merge-into-qa. This will run the install, test and deployment scripts eg fastlane_ios_beta.
fastlane_ios_beta will call a Fastlane lane called beta. See Using Fastlane to build and sign the apps.
Using Fastlane to build and sign the apps
“fastlane is the easiest way to automate beta deployments and releases for your iOS and Android apps. It handles all tedious tasks, like generating screenshots, dealing with code signing, and releasing your application.” - https://docs.fastlane.tools/
Setup
In the root of ios and android folders, create a Gemfile with the following content:
source "https://rubygems.org"
gem "fastlane"
The AppFile
Fastlane uses this file to grab env variables it needs for the lanes. A lane can be described as a workflow or task.
# For more information about the Appfile, see:
# https://docs.fastlane.tools/advanced/#appfile
app_identifier(ENV['FLAVOUR_PACKAGE_NAME']) # The bundle identifier of
your app
itc_team_id(ENV['ITC_TEAM_ID']) # App Store Connect Team IDThe .env file: .env.Test
A sample .env file to use. You could have a different .env file for a different app flavour
FLAVOUR_PACKAGE_NAME = "com.company.app1"
SCHEME_NAME = "Production"
ITC_TEAM_ID = "TEAM_ID_HERE"Template for iOS
Use the following template to complete the tasks of
- clean
- build
- sign
- upload to Apple
This template will use an AWS S3 bucket to store the signing keys.
Uses env variables to allow the template to be used as part of a CICD solution and is not dependent on the type of secret manager used in your pipeline
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
default_platform(:ios)
before_all do
######### delete build folders. Used by build_app #########
sh "rm -rf ./build"
######### clear derived data #########
clear_derived_data
end
######### beta #########
platform :ios do
desc "Push a new beta build to TestFlight"
lane :beta do |options|
begin
puts "Apple App Store connect API "
app_store_connect_api_key(
key_id: options[:key_id],
issuer_id: options[:issuer_id],
key_content: options[:key_content],
duration: 1200 # optional (maximum 1200)
)
######### download and install certs and prov profiles #########
match(
type: "appstore",
readonly: false,
verbose: false,
api_key: lane_context[SharedValues::APP_STORE_CONNECT_API_KEY],
storage_mode: 's3',
s3_region: options[:s3_region],
s3_access_key: options[:s3_access_key],
s3_secret_access_key: options[:s3_secret_access_key],
s3_bucket: options[:s3_bucket],
team_id: ENV['ITC_TEAM_NAME'],
keychain_password: options[:keychain_password]
)
puts "certificates are ok"
######### build and sign .ipa file #########
ipaName = "#{ENV['SCHEME_NAME']}.ipa"
build_app(
workspace: "App.xcworkspace",
scheme: ENV['SCHEME_NAME'],
configuration: "Release",
clean: true,
silent: true,
suppress_xcode_output: true,
include_bitcode: true,
include_symbols: true,
output_directory: "./fastlane/build",
output_name: ipaName
)
puts "app built successfully"
######### verify build #########
verify_build(
provisioning_type: "distribution",
bundle_identifier: CredentialsManager::AppfileConfig.
try_fetch_value(:app_identifier),
)
puts "app verified successfully"
######### upload to TestFlight #########
puts "uploading to TestFlight"
upload_to_testflight(
api_key: lane_context[SharedValues::APP_STORE_CONNECT_API_KEY],
notify_external_testers: ENV['DISTRIBUTE_EXTERNAL']
)
puts "uploaded to TestFlight"
clean_build_artifacts
puts '--success--'
rescue => exception
puts '************ --exception-- *************'
puts exception.to_s
# tidy up
clean_build_artifacts
end
end
end
Template for Android
Uses env variables to allow the template to be used as part of a CICD solution and is not dependent on the type of secret manager used in your pipeline.
fastlane_require 'dotenv'
default_platform(:android)
@track = 'internal'
@latest_app_version_in_track = 'N/A'
before_all do
######### check for any fastlane upates. Minor fixes only #########
# update_fastlane
######### delete build folders, create a new build folder. Used by
build_app #########
sh "rm -rf build"
sh "mkdir build"
sh "rm -rf app/build/*"
end
######### closed beta #########
platform :android do
desc "Build signed Android app bundle by variant"
lane :beta do |options|
begin
######### check service account with play store #########
validate_play_store_json_key(
json_key_data: options[:service_account_json_key_data]
)
puts "build bundle"
######### build bundle #########
build_bundle(keystore_upload_password: options[:
keystore_upload_password])
######### upload to play track #########
upload_to_play_store(
json_key_data: options[:service_account_json_key_data],
track: @track,
skip_upload_apk: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
skip_upload_aab: false,
skip_upload_changelogs: false
)
puts 'success'
get_app_version(@track, options[:service_account_json_key_data])
puts @latest_app_version_in_track
rescue => exception
puts '--exception---'
puts exception
writeErrorFileToBuildFolder(exception)
end
end
end
######### private lanes #########
platform :android do
desc "Build signed app bundle by variant"
private_lane :build_bundle do |values|
begin
puts "let's begin " + ENV['FLAVOUR_NAME']
######### clean #########
build_android_app(
tasks: ["clean"],
print_command_output: true
)
puts "building app"
######### build the app #########
build_android_app(
task: "bundle",
build_type: "Release",
flavor: ENV['FLAVOUR_NAME'],
system_properties: {
"ENVFILE": ENV['CONFIG_ENVFILE']
},
properties: {
"android.injected.signing.store.file" => ENV
['KEY_STORE_FILE'],
"android.injected.signing.store.password" => values[:
keystore_upload_password],
"android.injected.signing.key.alias" => ENV
['KEY_STORE_ALIAS'],
"android.injected.signing.key.password" => values[:
keystore_upload_password]
},
print_command_output: true
)
puts "build complete"
puts lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]
puts "extract .apk from .aab"
######### build the app #########
bundletool(
ks_path: ENV['KEY_STORE_FILE'],
ks_password: values[:keystore_upload_password],
ks_key_alias: ENV['KEY_STORE_ALIAS'],
ks_key_alias_password: values[:keystore_upload_password],
bundletool_version: '0.11.0',
aab_path: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
apk_output_path: "fastlane/build/" + ENV['FLAVOUR_NAME'] + ".
apk",
verbose: true
)
######### tidy up #########
puts "--success--"
rescue => exception
puts exception
# bubble up
raise exception
end
end
end
# get the latest app version from Play Store by track
def get_app_version(track, service_account_json_key_data)
nameArray = google_play_track_release_names(
track: track,
json_key_data: service_account_json_key_data
)
@latest_app_version_in_track = nameArray[0]
end

