DEV Community

Gabriel Sadaka
Gabriel Sadaka

Posted on

React Native Expo automated deployment using fastlane and CircleCI

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Latest comments (0)