How to build and deploy React Native applications with Fastlane
A detailed look at our internal Fastlane setup, operating on cross-platform React Native projects as a team, integrating the CI/CD environment and CLI operations.
Introduction
React Native is a powerful framework for building mobile, desktop and even web applications, and with its CLI you can run a development version of a mobile app in an emulator or connected physical device almost with zero conf. Unfortunately, when you want to build and deploy for production, deal with code signing, certificates and keys, and share the development process across your team, it doesn’t offer a solution out of the box, and you still need to use platform-specific IDE like Xcode and Android Studio.
But there’s an open source tool that fills this gap and provides a complete automation for the whole process, starting from the development build to the signed production bundle: Fastlane.
In this post I will show you our internal Fastlane setup that lets us operate on cross-platform React Native projects as a team, integrate the CI/CD with GitHub Actions, and do almost everything from the CLI.
Note: this setup is tested on React Native projects, but should be easily adaptable to any mobile framework or native projects.
Let’s start.
Installation
So first of all if you want to be cross-platform and build for iOS and Android, you’re gonna need a Mac so this post assumes that you are on a macOS and that you have everything set-up for a manual build with Xcode and Android Studio.
The key tool here is fastlane
so my advice is to read the docs for a complete setup guide (https://docs.fastlane.tools/) but in short you need to install Xcode command-line tools and the fastlane tool.
> xcode-select --install
> brew install fastlane
And then cd to your project root and run:
> fastlane init
This will create a fastlane
folder with an initial Fastfile
, which is the config file for the tool.
Environment setup
Since you want to build across different local machines and CI environments, you need to setup a .env
file that fastlane
is able to read (https://docs.fastlane.tools/advanced/other/)
Here’s the template for a .env
file that you should completely fill with your project’s specs.
KEYCHAIN_NAME=***.keychain
KEYCHAIN_PASSWORD=
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=
EXPORT_METHOD=
IOS_PROJECT_PATH=
WORKSPACE_PATH=
APP_ID=
APPSTORE_USERNAME=
TEAM_ID=
TEAM_NAME=
OUTPUT_PATH=
SCHEME=
BUILD_PODS=true
PODS_PATH=ios/Podfile
UPDATE_TARGETS=
MATCH_PASSWORD=
MATCH_GIT_URL=
MATCH_GIT_BASIC_AUTHORIZATION=
MATCH_BUILD_TYPE=development
ANDROID_PROJECT_PATH=android
KEYSTORE_PATH=
KEYSTORE_PASSWORD=
KEYSTORE_ALIAS=
Each variable should be pretty self-explanatory, for some of them we’ll see more details later.
Don’t commit the .env file! You can commit the template, but this file contains secrets that should be handled with your CI secrets management.
iOS
Storing and sharing certificates
In order to create a signed build for iOS you need to use your Apple certificates, but you don’t want to create them manually and store them only in your local machine, nor you want to use Xcode’s automatic signing because this would mean that you cannot share them with your team or in CI, so you’ll use a powerful tool provided by fastlane that creates and sync your Apple certificates across builds: match
.
If you read the docs you can see that match
supports Git repo, Google Cloud or Amazon S3 for storing the certificates and identities. For this post you will use Git.
Initialize match with:
> fastlane match init
This will create a Matchfile
under your fastlane
folder, but you can overwrite it with this one:
git_url(ENV["MATCH_GIT_URL"])
storage_mode("git")
type(ENV["MATCH_BUILD_TYPE"]) # The default type, can be: appstore, adhoc, enterprise or development
app_identifier(ENV["APP_ID"])
username(ENV["APPSTORE_USERNAME"]) # Your Apple Developer Portal username
keychain_name(ENV["KEYCHAIN_NAME"])
keychain_password(ENV["KEYCHAIN_PASSWORD"])
team_id(ENV["TEAM_ID"])
# For all available options run `fastlane match --help`
# Remove the # in the beginning of the line to enable the other options
# The docs are available on https://docs.fastlane.tools/actions/match
Follow these steps:
- Create a new keychain and fill the
.env
file with keychain name and password - Create a private remote git repo (i.e. in GitHub)
- Create a private token and save base64 encrypted (
echo -n user:token | base64
) username:token in env file asMATCH_GIT_BASIC_AUTHORIZATION
- Configure env file with the URL of the git repo in
MATCH_GIT_URL
- Ensure the env file is filled in every variable regarding app name, apple account, team id, passwords
- Note:
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
is required only if you want to push to the AppStore, follow this guide to create it https://support.apple.com/en-us/HT204397
- Note:
- From your project root, run
fastlane match
Now you should have new certificates and provisioning profiles created in your Apple developer account and stored in your private git repo by match
. You are now ready to build your app with fastlane
.
Setup the Fastfile
The Fastfile
is a ruby file containing fastlane
tasks (called lanes
) that you can run individually.
Your first task should be a development build that produces a .ipa
file that you can share with your team, or upload to a service like Browserstack App Live.
Something like this:
default_platform(:ios)
platform :ios do
before_all do
unlock_keychain(
path: ENV['KEYCHAIN_NAME'],
password: ENV['KEYCHAIN_PASSWORD']
)
if ENV["BUILD_PODS"] == 'true'
cocoapods(
podfile: ENV["PODS_PATH"],
use_bundle_exec: false
)
end
end
desc "Export ipa"
lane :export_ipa do
use_workspace = !ENV['WORKSPACE_PATH'].empty?
match(type: "development")
build_app(
workspace: use_workspace ? ENV['WORKSPACE_PATH'] : nil,
project: !use_workspace ? ENV['IOS_PROJECT_PATH'] : nil,
configuration: ENV['CONFIGURATION'],
scheme: ENV['SCHEME'],
output_directory: File.dirname(ENV['OUTPUT_PATH']),
output_name: File.basename(ENV['OUTPUT_PATH']),
clean: true,
export_method: ENV['EXPORT_METHOD'],
export_team_id: ENV['TEAM_ID'],
)
end
end
You see that we are using a lot of our env variables here, so this is totally dependent on the environment. Let’s break it down to steps.
- First we configure the default platform and the lane platform
- In a
before_all
block, so before for each run, we unlock the keychain and if our env tells us toBUILD_PODS
, then we use the fastlane commandcocoapods
to basically runpod install
- We create the lane
export_ipa
. use_workspace
is a block variable that will be used later- Then we tell
match
to set the build type todevelopment
, so it will use our development profile - The last step is the actual build command
Building locally with fastlane
You are ready for your first build. Run:
> fastlane export_ipa
The build process now starts and you will see tons of logs in your command-line. Hopefully no errors 🙂
And finally…
...
*fastlane.tools finished successfully* 🎉
You should find your .ipa
file in your project root!
Uploading a signed production build to TestFlight
So now that you have a working development build command, you can also runa production, signed build that you can upload to the appstore, test with TestFlight and then submit for review and publishing, all without even opening Xcode and from whatever local machine.
In order to do this you must simply fill the FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
with the one generated from your Apple account page as we said in a note before.
Then, you need the appropriate lane in your Fastfile
, so add this one to the platform :ios
section:
desc "Upload to TestFlight"
lane :upload_testflight do
use_workspace = !ENV['WORKSPACE_PATH'].empty?
match(type: "appstore")
increment_build_number(
xcodeproj: ENV['IOS_PROJECT_PATH'],
build_number: (latest_testflight_build_number(
app_identifier: ENV["APP_ID"],
username: ENV["APPSTORE_USERNAME"],
team_name: ENV["TEAM_NAME"],
) + 1).to_s,
)
build_app(
workspace: use_workspace ? ENV['WORKSPACE_PATH'] : nil,
project: !use_workspace ? ENV['IOS_PROJECT_PATH'] : nil,
configuration: "Release",
scheme: ENV['SCHEME'],
output_directory: File.dirname(ENV['OUTPUT_PATH']),
output_name: File.basename(ENV['OUTPUT_PATH']),
clean: true,
include_bitcode: true,
export_method: "app-store",
export_team_id: ENV['TEAM_ID'],
)
upload_to_testflight(
ipa: File.join(File.dirname(ENV['OUTPUT_PATH']), File.basename(ENV['OUTPUT_PATH'])),
username: ENV['APPSTORE_USERNAME'],
app_identifier: ENV['APP_ID'],
team_name: ENV['TEAM_NAME'],
skip_waiting_for_build_processing: true,
)
end
You can spot some differences with the development build.
- The
type
inmatch
is nowappstore
, so Match will use the production profile and certs - We added
increment_build_number
that basically fetches the latest build number from TestFlight and then increments it by 1- Note: if
fastlane
complains that it cannot auto-increment the build number, follow this guide to setup Apple Generic versioning system: https://developer.apple.com/library/archive/qa/qa1827/_index.html
- Note: if
- We then build and sign using
build_app
as before but with aRelease
configuration andapp-store
export method - Finally we upload the build using
upload_to_testflight
- Note:
skip_waiting_for_build_processing
means thatfastlane
will end right after the upload, if you don’t set it or set it tofalse
the process will wait until the build is completely processed on TestFlight
- Note:
And that’s it, you can simply run
> fastlane upload_testflight
and your app should be uploaded to the App Store!
Creating a new release on the App Store
Fastlane is a really powerful tool. It even allows you to completely control the review and publish process of your app completely from the command line.
This is not the purpose of this blog post, but I want to share a lane
that creates a new release for your app on App Store, that you can use as a starting point to craft your own:
desc "Release ipa"
lane :release do
deliver(
app_identifier: ENV["APP_ID"],
username: ENV["APPSTORE_USERNAME"],
team_name: ENV["TEAM_NAME"],
build_number: (latest_testflight_build_number(
app_identifier: ENV["APP_ID"],
username: ENV["APPSTORE_USERNAME"],
team_name: ENV["TEAM_NAME"],
)).to_s,
precheck_include_in_app_purchases: false,
submit_for_review: false,
force: true, # Skip HTMl report verification
skip_screenshots: true,
skip_binary_upload: true,
submit_for_review: true,
# Comes from https://github.com/fastlane/fastlane/issues/5542#issuecomment-254201994
submission_information: {
add_id_info_serves_ads: false,
add_id_info_tracks_action: false,
add_id_info_tracks_install: false,
add_id_info_uses_idfa: false,
content_rights_has_rights: true,
content_rights_contains_third_party_content: true,
export_compliance_platform: 'ios',
export_compliance_compliance_required: false,
export_compliance_encryption_updated: false,
export_compliance_app_type: nil,
export_compliance_uses_encryption: false,
export_compliance_is_exempt: false,
export_compliance_contains_third_party_cryptography: false,
export_compliance_contains_proprietary_cryptography: false,
export_compliance_available_on_french_store: false
},
)
end
Then running
> fastlane release
should create a new release from your latest uploaded build and submit it for review.
As you can see, you can configure pretty much any aspect of the submission process. You can even add screenshots and descriptions!
Building remotely in GitHub CI
So now you have a working local build, and since you have everything configured as environment variables and command-line tools you should be able to implement the same build using your preferred CI/CD system.
But if you are using GitHub, you can use an Action we created that let’s you easily and securely build remotely. It even uploads your build to Browserstack App Live if you want!
Here you can find our action: https://github.com/sparkfabrik/ios-build-action
So you can simply create a new action template and use
this, but this needs a couple more steps because you need to download the Match certificates, decrypt them and upload them as secrets to your GitHub project.
Follow these steps:
-
Add
sparkfabrik/ios-build-action
to your GitHub Action, example:- name: Build and deploy app uses: sparkfabrik/ios-build-action@v1.2.0 with: project-path: ${{ secrets.PROJECT_PATH }} p12-key-base64: ${{ secrets.P12_KEY_BASE64 }} p12-cer-base64: ${{ secrets.P12_CER_BASE64 }} mobileprovision-base64: ${{ secrets.MOBILEPROVISION_BASE64 }} team-id: ${{ secrets.TEAM_ID }} workspace-path: ${{ secrets.WORKSPACE_PATH }} export-method: ${{ secrets.EXPORT_METHOD }} output-path: ${{ github.sha }}.ipa configuration: Debug update-targets: | MyApp YourApp disable-targets: App scheme: MyApp build-pods: true pods-path: 'ios/Podfile' browserstack-upload: true browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }} browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
-
Add the following secrets to GitHub (just copy / paste from your env file)
PROJECT_PATH
(in env file it’sIOS_PROJECT_PATH
)TEAM_ID
WORKSPACE_PATH
EXPORT_METHOD
CERTIFICATE_PASSWORD
(in env file it’sMATCH_PASSWORD
)CODE_SIGNING_IDENTITY
(should beiPhone Developer
)- Optional:
BROWSERSTACK_USERNAME
andBROWSERSTACK_ACCESS_KEY
(only if you want to upload to BrowserStack)
-
Clone locally the private git repo where you stored the Match certificates
-
Decrypt the
.cer
and.p12
files in/certs/(development|appstore)/
and.mobileprovision
in/profiles/(development|appstore)/
with the following command:openssl aes-256-cbc -k $MATCH_PASSWORD -in $YOURCERT.cer -out ios-build.cer -a -d
openssl aes-256-cbc -k $MATCH_PASSWORD -in $YOURCERT.p12 -out ios-build.p12 -a -d
openssl aes-256-cbc -k $MATCH_PASSWORD -in $YOURCERT.mobileprovision -out ios-build.mobileprovision -a -d
- $MATCH_PASSWORD is the same password you configured in env file
-
Copy and paste the .p12, .cer and .mobileprovision files in base64 in github in the corresponding GitHub secrets (examples using
pbcopy
):P12_KEY_BASE64
:base64 ios-build.p12 | pbcopy
P12_CER_BASE64
:base64 ios-build.cer | pbcopy
MOBILEPROVISION_BASE64
:base64 ios-build.mobileprovision | pbcopy
That’s it for the iOS build, let’s move to the Android one which is waaaay easier 😀
Android
Android build is easier because gradle
is already a powerful tool for making command-line builds, so fastlane
can basically act as a wrapper. React Native comes with a pre-configured gradle file that just works and produces development and production builds.
So you only need to wrap gradle
tasks in fastlane
lanes, providing the correct configurations, and sign the app with the new app signing system that Android offers.
Build unsigned production APK
For a development, unsigned build, you just need to fill the env variable ANDROID_PROJECT_PATH
(in React Native this is android
) and add this lane to your Fastfile
:
platform :android do
desc "Assemble release APK"
lane :assemble do
gradle(
task: "clean assembleRelease",
project_dir: ENV["ANDROID_PROJECT_PATH"],
flags: "--no-daemon",
)
APK_LOCATION = "#{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]}"
puts "APK location: #{APK_LOCATION}"
end
end
We are using the gradle
command, telling gradle to execute the clean
task (so we have a clean build folder) and then the assembleRelease
task, which produces an unsigned production build as an APK file. Then we print (using puts
) in console the location of that file.
Of course you need to have gradle
executable in your command-line environment, as long ad Java
and everything Android needs, but if you build with React Native or other frameworks at this point you should already have everything set-up.
Now, you can just launch:
> fastlane android assemble
And have your unsigned APK (the file location is printed before the final fastlane summary
table) ready to be tested on a local device or uploaded to BrowserStack.
Note that here we assume that ios
is the default platform, so in order to run assemble
you must first specify the correct platform, which is android
.
Build signed production bundle
In order to produce the signed bundle (.aab
), first you need to prepare your app for signing, following the official guide here: https://developer.android.com/studio/publish/app-signing
When you have every file, save the keystore
file, pepk
key, pem
certificate and credentials (alias, passwords) to a local secure folder, and upload it to a safe location (eg. private git repo, gcloud bucket, …). Unfortunately, Fastlane doesn’t provide a tool like Match for managing Android keys so you need to store and share them on your own.
Then you need to fill the following vars in your .env
file:
KEYSTORE_PATH= (path to the .jks file)
KEYSTORE_PASSWORD= (the keystore password)
KEYSTORE_ALIAS= (the keystore alias)
The lane
for your production build is this:
lane :bundle_production do
increment_version_code()
gradle(
task: "clean bundleRelease",
project_dir: ENV["ANDROID_PROJECT_PATH"],
flags: "--no-daemon",
properties: {
"android.injected.signing.store.file" => File.join(Dir.pwd, "..", ENV["KEYSTORE_PATH"]),
"android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["KEYSTORE_ALIAS"],
"android.injected.signing.key.password" => ENV["KEYSTORE_PASSWORD"],
}
)
AAB_LOCATION = "#{lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]}"
puts "AAB location: #{AAB_LOCATION}"
end
First, we are incrementing the build version code using increment_version_code
. This is not a command provided by Fastlane, it’s a plugin that you need to add to your fastlane installation, and that’s as simple as running:
> fastlane add_plugin increment_version_code
This command will create a Pluginfile
in your fastlane
folder with the added plugin. You can commit this file as Fastlane will check and install plugins from this file at every run.
Then, the lane calls gradle
with tasks clean
and bundleRelease
, and this time we also want to sign the build so we pass properties
using our environment variables pointing to the keystore.
Then, run the command
> fastlane android bundle_production
And the resulting AAB file path is printed before the final fastlane summary
table.
This is the signed production buld, so you can upload this file to the Play Store and publish it. We do this manually at the moment, but there’s a fastlane action to upload directly to the store: upload_to_play_store
. You can find more informations in the docs https://docs.fastlane.tools/actions/upload_to_play_store/
This post doesn’t cover it now, but maybe it will in the future, tell us if you are interested 😃
Building remotely in GitHub CI
Good news: we have a GitHub action for Android too! Unfortunately it’s a lot simpler than our iOS action, because, again, we followed our needs, but you can contribute to it or open an issue and discuss it! Here’s the repo https://github.com/sparkfabrik/android-build-action
Using it in your action is just:
- uses: sparkfabrik/android-build-action@v1.0.0
with:
project-path: android
output-path: my-app.apk
browserstack-upload: true
browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
Again browserstack
is optional here, the produced APK is unsigned and you can then download it as an Artifact.
Conclusions
Fastlane is a fantastic tool that lets you manage an entire build process without ever having to open an IDE, including signing and uploading to the app stores. Integrating all this to your local build is straightforward, and even in your CI/CD environment.
And there’s even more: for example there are actions in Fastlane to manage the git repo (like git_commit
, add_git_tag
or push_to_git_remote
) or run tests, and many more, you can take full control over the build and release process of your app from a command-line, automate everything, share across the team and do real GitOps 😃
I hope this was a clear and exhaustive post, configuring Fastlane may seem confusing at the beginning but once you start to master some of its features you realize how much power you have at your fingers!
Uh, I almost forgot, here’s the complete Fastfile
:
default_platform(:ios)
platform :ios do
before_all do
unlock_keychain(
path: ENV['KEYCHAIN_NAME'],
password: ENV['KEYCHAIN_PASSWORD']
)
if ENV["BUILD_PODS"] == 'true'
cocoapods(
podfile: ENV["PODS_PATH"],
use_bundle_exec: false
)
end
end
desc "Export ipa"
lane :export_ipa do
use_workspace = !ENV['WORKSPACE_PATH'].empty?
match(type: "development")
build_app(
workspace: use_workspace ? ENV['WORKSPACE_PATH'] : nil,
project: !use_workspace ? ENV['IOS_PROJECT_PATH'] : nil,
configuration: ENV['CONFIGURATION'],
scheme: ENV['SCHEME'],
output_directory: File.dirname(ENV['OUTPUT_PATH']),
output_name: File.basename(ENV['OUTPUT_PATH']),
clean: true,
export_method: ENV['EXPORT_METHOD'],
export_team_id: ENV['TEAM_ID'],
)
end
desc "Upload to TestFlight"
lane :upload_testflight do
use_workspace = !ENV['WORKSPACE_PATH'].empty?
match(type: "appstore")
increment_build_number(
xcodeproj: ENV['IOS_PROJECT_PATH'],
build_number: (latest_testflight_build_number(
app_identifier: ENV["APP_ID"],
username: ENV["APPSTORE_USERNAME"],
team_name: ENV["TEAM_NAME"],
) + 1).to_s,
)
build_app(
workspace: use_workspace ? ENV['WORKSPACE_PATH'] : nil,
project: !use_workspace ? ENV['IOS_PROJECT_PATH'] : nil,
configuration: "Release",
scheme: ENV['SCHEME'],
output_directory: File.dirname(ENV['OUTPUT_PATH']),
output_name: File.basename(ENV['OUTPUT_PATH']),
clean: true,
include_bitcode: true,
export_method: "app-store",
export_team_id: ENV['TEAM_ID'],
)
upload_to_testflight(
ipa: File.join(File.dirname(ENV['OUTPUT_PATH']), File.basename(ENV['OUTPUT_PATH'])),
username: ENV['APPSTORE_USERNAME'],
app_identifier: ENV['APP_ID'],
team_name: ENV['TEAM_NAME'],
skip_waiting_for_build_processing: true,
)
end
desc "Release ipa"
lane :release do
deliver(
app_identifier: ENV["APP_ID"],
username: ENV["APPSTORE_USERNAME"],
team_name: ENV["TEAM_NAME"],
build_number: (latest_testflight_build_number(
app_identifier: ENV["APP_ID"],
username: ENV["APPSTORE_USERNAME"],
team_name: ENV["TEAM_NAME"],
)).to_s,
precheck_include_in_app_purchases: false,
submit_for_review: false,
force: true, # Skip HTMl report verification
skip_screenshots: true,
skip_binary_upload: true,
submit_for_review: true,
# Comes from https://github.com/fastlane/fastlane/issues/5542#issuecomment-254201994
submission_information: {
add_id_info_serves_ads: false,
add_id_info_tracks_action: false,
add_id_info_tracks_install: false,
add_id_info_uses_idfa: false,
content_rights_has_rights: true,
content_rights_contains_third_party_content: true,
export_compliance_platform: 'ios',
export_compliance_compliance_required: false,
export_compliance_encryption_updated: false,
export_compliance_app_type: nil,
export_compliance_uses_encryption: false,
export_compliance_is_exempt: false,
export_compliance_contains_third_party_cryptography: false,
export_compliance_contains_proprietary_cryptography: false,
export_compliance_available_on_french_store: false
},
)
end
end
platform :android do
desc "Assemble release APK"
lane :assemble do
gradle(
task: "clean assembleRelease",
project_dir: ENV["ANDROID_PROJECT_PATH"],
flags: "--no-daemon",
)
APK_LOCATION = "#{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]}"
puts "APK location: #{APK_LOCATION}"
end
lane :bundle_production do
increment_version_code()
gradle(
task: "clean bundleRelease",
project_dir: ENV["ANDROID_PROJECT_PATH"],
flags: "--no-daemon",
properties: {
"android.injected.signing.store.file" => File.join(Dir.pwd, "..", ENV["KEYSTORE_PATH"]),
"android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["KEYSTORE_ALIAS"],
"android.injected.signing.key.password" => ENV["KEYSTORE_PASSWORD"],
}
)
AAB_LOCATION = "#{lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]}"
puts "AAB location: #{AAB_LOCATION}"
end
end