Intro
As hybrid mobile application frameworks mature, React Native has become a popular choice for building iOS and Android mobile apps using a shared codebase. When kicking off a React Native mobile app, developers are presented with a choice between using Expo Go or the React Native CLI. While React Native provides you with an engine that can be used to build a hybrid mobile app, Expo Go provides you with the complete car ready to drive away from the dealer. It provides you with a streamlined developer experience, with all the tooling required to build, deploy and test your mobile apps. The Expo SDK provides access to device and system functionality using shared code, enabling developers to focus on building their app without worrying about platform specific native code.
Expo also provides a cloud based build and deployment service, EAS, that makes it easy to deploy an app to the store and keep it up to date. While this may be suitable for some organisations, there may be a need to build and deploy the app using an organisation's own infrastructure or existing CI/CD platform. This post will walk through an approach to building, testing and deploying an app using fastlane and CircleCI. While CircleCI is used for this example, this could be substituted with a CI/CD platform of choice that has support for Linux and MacOS build agents. CircleCI was chosen because its free tier is sufficient for building, testing and deploying Android and iOS apps with little configuration.
This post assumes prior knowledge in deploying apps to the Google Play Store and Apple App Store and will not cover the details of setting up the app listings. Please refer to the many resources already available online on how to do this, if you have not done this before.
Project setup
Prerequisites
- Xcode
- Android Studio
- NodeJS
- Google Play Console Account and knowledge on how to setup a Play Store app listing.
- Apple Developer Account and knowledge on how to setup an App Store app listing.
Initialise Expo app
Lets start off by creating a new Expo app using the create-expo-app
npx package, by running the following command.
npx create-expo-app --template
I chose the Navigation (Typescript)
template, but this example should work with any template. Then provide the name of the application and hit enter.
While the Expo SDK provides access to many native capabilities it is likely you will encounter the need to reference third party React Native packages that have their own custom native code or you might want to customise the build. Doing so will require an Expo development build, which will require referencing the expo-dev-client
package, which you can so by running the following command.
npx expo install expo-dev-client
Now that we have an Expo app with the development client package referenced, we are ready to start the app in an emulator or an a device. To do this we can run either of the following commands depending on the platform we want to run it on. While the Expo CLI can automatically start the iOS Simulator, if you would like to run it in an Android Emulator you will need to start one up before running the command. Note that while the initial build can be slow, the subsequent builds will be much quicker.
npx expo run:android #run on Android Emulator
npx expo run:ios #run on iOS Simulator
npx expo run:android -d #run on Android Device
npx expo run:ios -d #run on iOS Device
Before we setup fastlane and CircleCI, we will need to run the Expo prebuild
command, which will generate the iOS and Android native code and project files. This will enable us to use standard build tools for the respective platforms to build and deploy the apps to the stores, thus avoiding the need to rely on EAS, by running the following command.
npx expo prebuild
Fastlane
The next step is setting up the fastlane workflow, which will take care of building, signing and deploying the Expo React Native mobile app. fastlane is being used as it automates many tedious tasks that can be tricky to get right using the platform provided CLI tooling. Since fastlane is a Ruby package Bundler will be used to define the dependency, to make it easy for other developers to run it and to enable consistency with the CI pipeline. Refer to the fastlane documentation on steps to get Ruby installed on your machine. Once Ruby is installed run the following commands to install fastlane in the ios
and android
directories.
gem install bundler
cd ios
bundler init
bundler add fastlane
bundle install
cd android
bundler init
bundler add fastlane
bundle install
Now that fastlane is installed, we can initialise fastlane in the ios
and android
directories, using the following command. When prompted, choose the Manual Setup
option.
cd ios
fastlane init
At the time of writing the fastline init
command failed when running within the Android folder, so if you also receive an error similar to no iOS project in the current directory
, then use the following commands to copy the iOS generated config files. We will be replacing the contents of the Appfile
and Fastfile
next, so need to worry about iOS specific commands in there.
cd android
mkdir fastlane
cp ../ios/fastlane/Appfile fastlane/Appfile
cp ../ios/fastlane/Fastfile fastlane/Fastfile
Android fastlane workflow
Now that the base fastlane configuration is ready to go, we can define the workflow to build, sign and deploy the Android app.
To enable automatic incrementing of the build version number, a third party package will need to be installed using the following command. When prompted to update the gemfile, enter y
.
cd android
fastlane add_plugin increment_version_code
To begin with, update android/fastlane/Appfile
to define the package name that will be used. This will need to match the identifier that you specified when setting up Expo.
package_name("org.sample.mobileapp")
Next, we will need to configure a fastlane lane that will increment the build number, build the app and then upload it to the Play Store, by updating android/fastlane/Fastfile
with the following contents.
default_platform(:android)
platform :android do
desc "Deploy a new version to the Google Play Beta track"
lane :beta do
# Grab the latest build number from the Play Store
previous_build_number = google_play_track_version_codes(
track: "internal",
json_key: "#{Dir.pwd}/play-store-credentials.json"
)[0]
# Increment the build number
increment_version_code(
gradle_file_path: "app/build.gradle",
version_code: previous_build_number + 1
)
# Build a release version of the app
gradle(
task: "clean assembleRelease",
print_command: false,
properties: {
"android.injected.signing.store.file" => "#{Dir.pwd}/example-app.keystore",
"android.injected.signing.store.password" => ENV["ANDROID_SIGNING_KEY_PASSWORD"],
# replace alias with your signing key alias
"android.injected.signing.key.alias" => "[example-app]",
"android.injected.signing.key.password" => ENV["ANDROID_SIGNING_KEY_PASSWORD"],
}
)
# Upload the app to the Play Store
upload_to_play_store(
track: "internal",
json_key: "#{Dir.pwd}/play-store-credentials.json"
)
end
end
With the fastlane configuration complete, the next step is to create a shell script which will retrieve the Android Signing Key and the Play Store Credentials from the CI environment variables and store them as files that can be accessed by fastlane, then run the fastlane beta lane. Create a shell script android/fastlane/deploy.sh
, with the following contents. Then run chmod +x deploy.sh
to give it execution permission.
#!/bin/sh
echo ${ANDROID_SIGNING_KEY} | base64 -d > fastlane/example-app.keystore
echo ${ANDROID_PLAY_STORE_CREDENTIALS} | base64 -d > fastlane/play-store-credentials.json
fastlane beta
iOS fastlane Workflow
Configuring the iOS fastlane workflow is a similar process to the Android one.
To start with update ios/fastlane/Appfile
to define the app_identifier
, which will need to match the bundle identifier you chose during the Expo setup. It will also define the itc_team_id
and team_id
configuration variables.
app_identifier("org.sample.mobileapp") # The bundle identifier of your app
itc_team_id("111111") # App Store Connect Team ID
team_id("111111") # Developer Portal Team ID
Next, we will need to configure a fastlane lane that will increment the build number, build the app and then upload it to the App Store. The following workflow relies on a certificate that has already been created and is pulled into the build machine via an environment variable. There is an alternative approach using the get_certificates, which will pull the certificate from Apple or create one if it doesn't exist already. Update ios/fastlane/Fastfile
with the following contents.
default_platform(:ios)
platform :ios do
before_all do
setup_circle_ci
end
desc "Push a new beta build to TestFlight"
lane :beta do
# Retrieve the api key from key file
api_key = app_store_connect_api_key(
# Replace key_id and issuer_id with your api key key and issuer IDs
key_id: "[key_id]",
issuer_id: "[issuer_id]",
key_filepath: "AppStoreConnectApiKey.p8",
duration: 1000,
in_house: false
)
# Retrieve signing certificate from file
import_certificate(
certificate_path: "PrivateKey.p12",
keychain_name: ENV["MATCH_KEYCHAIN_NAME"]
)
# Import mobile provisioning profile from app store
get_provisioning_profile(
filename: "distribution.mobileprovision",
# Replace provisioning_name with your provisioning profile name
provisioning_name: "[provisioning_name]",
ignore_profiles_with_different_name: true,
readonly: true,
api_key: api_key
)
# Get latest build number
previous_build_number = latest_testflight_build_number(
api_key: api_key
)
# Increment build number
increment_build_number(
# Replace xcodeproj
xcodeproj: "AppName.xcodeproj",
build_number: previous_build_number + 1
)
# Disable automatic code signing, so the signing certificate on the filesystem can be used
update_code_signing_settings(
use_automatic_signing: false,
# Replace xcodeproj, bundle_identifier and profile_name
path: "AppName.xcodeproj",
bundle_identifier: "org.sample.mobileapp",
profile_name: "AppName",
)
# Build the iOS app
build_app(
# replace workspace, scheme provisioningProfiles[0] and codesigning_identity
workspace: "AppName.xcworkspace",
scheme: "AppName",
skip_profile_detection: true,
export_method: "app-store",
export_options: {
provisioningProfiles: {
"org.sample.mobileapp" => "AppName",
}
},
codesigning_identity: "iPhone Distribution: Org (Team ID)"
)
# Upload app to TestFlight, skipping waiting for build processing to reduce CI credit waste
upload_to_testflight(
skip_waiting_for_build_processing: true,
api_key: api_key
)
end
end
With the fastlane configuration complete, the next step is to create a shell script which will retrieve the Signing Certificate Private Key and the App Store Connect Api Key from the CI environment variables and store them as files that can be accessed by fastlane, then run the fastlane beta lane. Create a shell script ios/fastlane/deploy.sh
with the following contents. Then run chmod +x deploy.sh
to give it execution permission.
#!/bin/sh
echo ${IOS_PRIVATE_KEY} | base64 -d > PrivateKey.p12
echo ${APP_STORE_CONNECT_API_KEY} | base64 -d > AppStoreConnectApiKey.p8
fastlane beta
CircleCI Pipeline
Finally we can configure the CircleCI pipeline to build, test and deploy the latest changes to the Play Store and App Store when a new commit is pushed. To connect CircleCI to your repository, so that it can be triggered when a new commit is pushed and access the code, go to the CircleCI documentation relevant to your VCS.
Create a CircleCI config.yml
fine in a .circleci
folder in your repository, with the following contents, which will checkout the code, restore packages while storing them in a cache to speed up subsequent builds, run tests, compile TypeScript, build, sign and deploy your app. The app store deployment steps require manual approval because they take a long time and can quickly consume all available build credits, so only run them when you want a new build published to the store.
version: 2.1
jobs:
react-native-test:
docker:
- image: cimg/node:18.8
steps:
- checkout
- restore_cache:
key: yarn-v1-{{ checksum "yarn.lock" }}-{{ arch }}
- restore_cache:
key: node-v1-{{ checksum "package.json" }}-{{ arch }}
- run: yarn install
- run: yarn test
- run: yarn tsc
- save_cache:
key: yarn-v1-{{ checksum "yarn.lock" }}-{{ arch }}
paths:
- ~/.cache/yarn
- save_cache:
key: node-v1-{{ checksum "package.json" }}-{{ arch }}
paths:
- node_modules
macos-build-and-deploy:
macos:
xcode: 13.4.1
environment:
FL_OUTPUT_DIR: output
steps:
- checkout
- restore_cache:
key: yarn-v1-{{ checksum "yarn.lock" }}-{{ arch }}
- restore_cache:
key: node-v1-{{ checksum "package.json" }}-{{ arch }}
- run: yarn install
- save_cache:
key: yarn-v1-{{ checksum "yarn.lock" }}-{{ arch }}
paths:
- ~/.cache/yarn
- save_cache:
key: node-v1-{{ checksum "package.json" }}-{{ arch }}
paths:
- node_modules
- restore_cache:
key: bundle-v1-{{ checksum "ios/Gemfile.lock" }}-{{ arch }}
- restore_cache:
key: pods-v1-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
- run:
command: pod install
working_directory: ios
- run:
command: bundle install
working_directory: ios
- save_cache:
key: bundle-v1-{{ checksum "ios/Gemfile.lock" }}-{{ arch }}
paths:
- vendor/bundle
- save_cache:
key: pods-v1-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
paths:
- ios/Pods
- run:
name: Fastlane deploy TestFlight
command: ./fastlane/deploy.sh
working_directory: ios
# replace example-app with repository name
android-build-and-deploy:
working_directory: ~/example-app
docker:
- image: cimg/android:2022.08-node
steps:
- checkout:
path: ~/example-app
- attach_workspace:
at: ~/example-app
- restore_cache:
key: yarn-v1-{{ checksum "yarn.lock" }}-{{ arch }}
- restore_cache:
key: node-v1-{{ checksum "package.json" }}-{{ arch }}
- run: yarn install
- save_cache:
key: yarn-v1-{{ checksum "yarn.lock" }}-{{ arch }}
paths:
- ~/.cache/yarn
- save_cache:
key: node-v1-{{ checksum "package.json" }}-{{ arch }}
paths:
- node_modules
- restore_cache:
key: bundle-v1-{{ checksum "android/Gemfile.lock" }}-{{ arch }}
- run:
command: bundle install
working_directory: android
- save_cache:
key: bundle-v1-{{ checksum "android/Gemfile.lock" }}-{{ arch }}
paths:
- vendor/bundle
- run:
name: Fastlane deploy beta
working_directory: android
command: ./fastlane/deploy.sh
workflows:
react-native-android-ios:
jobs:
- react-native-test
- approve_deploy_to_apple_app_store:
type: approval
requires:
- react-native-test
- approve_deploy_to_google_play_store:
type: approval
requires:
- react-native-test
- android-build-and-deploy:
requires:
- approve_deploy_to_google_play_store
- macos-build-and-deploy:
requires:
- approve_deploy_to_apple_app_store
To configure the environment variables that are referenced by the iOS & Android deploy.sh
bash scripts, follow the CircleCI guide to set them up for your project.
The following environment variables will need to be configured:
-
APP_STORE_CONNECT_API_KEY
: API Key to authenticate with the Apple App Store. Follow the Apple documentation to generate an API Key, then base64 encode the file and set it as the environment variable value. -
IOS_PRIVATE_KEY
: Private key used to sign the iOS builds. Follow this post to generate a distribution certificate and export the private key from it, then base64 encode the file and set it as the environment variable value. -
ANDROID_PLAY_STORE_CREDENTIALS
: Google Play Store Service Account credentials to authenticate with the Play Store. Follow the fastlane setup documentation to create a service account and generate credentials for it, then base64 encode the file and set it as the environment variable value. -
ANDROID_SIGNING_KEY
: Signing key used to sign the Android builds. Follow the Google documentation to Generate an upload key then enrol into Play App Signing using that key, then base64 encode the file and set it as the environment variable value. -
ANDROID_SIGNING_KEY_PASSWORD
: Password for signing key used to sign the Android builds.
For those following this guide on MacOS you can base64 encode a file using the included terminal application with the following command:
base64 -i file.ext
Once all of the changes are pushed to your repository, CircleCI should test & build every commit, while providing the option to either deploy your app to the Google Play Store or Apple App Store.
Wrap up
An alternative approach to storing the app signing secrets as environment variables in the pipeline is to use fastlane match which encrypts them and stores them in a shared git repository. It will make it easier to share the secrets with other developers on the team and help avoid storing secrets in the pipeline environment variables, which might not be ideal for all teams.
This guide only demonstrates how to build and deploy apps to Apple TestFlight and Google Play Store Internal track. It does not demonstrate promoting builds to the release tracks, nor does it show how to automatically generate screenshots and release notes. To further expand the pipeline to cover those steps, please refer to the excellent fastlane documentation.
You can see an example of this approach in this repo.
Please let me know what you think and any questions/feedback you may have.
Contributions
Thanks to George Ostrobrod, Acer Zhou & Luke Kugelman for their excellent work on the project that we leveraged Expo & fastlane to build a React Native mobile app that was automatically deployed and built using CircleCI.
Top comments (0)