React Native Expo automated deployment using fastlane and CircleCI
Tags: reactnative, circleci, mobile, devops
Published: 2022-11-02 06:36:48

# Intro

As hybrid mobile application frameworks mature, [React Native](https://reactnative.dev/) 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](https://reactnative.dev/docs/environment-setup) between using [Expo Go](https://expo.dev/client) 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](https://expo.dev/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](https://fastlane.tools/) and [CircleCI](https://circleci.com/). 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](https://developer.apple.com/xcode/)
- [Android Studio](https://developer.android.com/studio)
- [NodeJS](https://nodejs.org/en/)
- [Google Play Console Account](https://play.google.com/console/about/) and knowledge on how to setup a Play Store app listing.
- [Apple Developer Account](https://developer.apple.com/) 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.

```bash
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](https://docs.expo.dev/development/introduction/#from-expo-go-to-development-builds), which will require referencing the `expo-dev-client` package, which you can so by running the following command.

```bash
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.

```bash
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.

```bash
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](https://bundler.io/) 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](https://docs.fastlane.tools/) 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.

```bash
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.

```bash
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.

```bash
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`.

```bash
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.

```ruby
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.

```ruby
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.

```bash
#!/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.

```ruby
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](http://docs.fastlane.tools/actions/get_certificates/#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. 

```ruby
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.

```bash
#!/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](https://circleci.com/docs/github-integration/) 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.

```yml
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](https://circleci.com/docs/set-environment-variable/#set-an-environment-variable-in-a-project) 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](https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api) 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](https://medium.com/@gobi.ios.dev/ios-creating-a-distribution-certificate-and-p12-2f4d3b63ea9b) 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](https://docs.fastlane.tools/getting-started/android/setup/#collect-your-google-credentials) 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](https://developer.android.com/studio/publish/app-signing#generate-key) then [enrol into Play App Signing](https://developer.android.com/studio/publish/app-signing#enroll) 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:

```bash
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](https://docs.fastlane.tools/actions/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](https://docs.fastlane.tools/).  

You can see an example of this approach in this [repo](https://github.com/gabrielsadaka/expo-fastlane).

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.