Automate React Native App deployments

Introduction

App deployment is a fundamental part of the native development workflow, and automating these deployments can make the process more efficient and controllable. This post aims to educate on the various deployment options available for a React Native app, the appropriate strategy, and the ways to automate them. Combining these strategies has enabled us to deliver App updates as fast as back-end updates.

Before diving into deployment, it is helpful to understand that there are two core parts of any React Native App:

  • The Native App container (different for iOS and Android)
  • The JavaScript codebase that talks to the native container using a bridge

The React Native documentation explains this well.

Manual steps to deploy a React Native app

For iOS, these are the steps to archive and upload a new build to TestFlight via the App Store Connect:

  1. Open XCode and select your project in the Project Navigator.
  2. Update the Version or Build number.
  3. On the Top Bar, click on Product > Archive (after the app is archived, the Archives Manager will open).
  4. Select the new Archive and click on the Distribute App button.
  5. Select method of distribution: App Store Connect and choose Destination: Upload
  6. On App Store Connect distribution options, ensure all boxes are checked.
  7. Select the Automatic profile, review the content and click the Upload button.
  8. Once the upload is finished go to App Store Connect > My Apps, select your app, and the build should be available on the dashboard.
  9. After the build is processed, it is ready to be submitted to internal testers that are part of your Apple Development team or external users invited directly via a public link.

The process for Android deployment involves a series of steps such as generating an upload key using keytool, executing commands to generate release builds, and uploading the App to the Play store.

Based on this, We have several steps for both platforms, and some of the steps are prone to developer errors.

Metrics for deployment strategy

For a React Native app, here are a few valuable metrics that can be used to decide if a deployment strategy will be successful:

  1. Time to deploy - The time taken from the start of the deployment process till an App update is available on the stores. This also includes the time spent on the unpredictable App review process.
  2. Time to upgrade - We have not deployed to 100% of the users if they don’t upgrade immediately. Not all users upgrade their apps when there is an App update, and it might take a few weeks to even a month to ensure a large percentage of the users have upgraded. Sometimes it’s worth measuring if a higher percentage of active users have upgraded.
  3. Frequency of deployments - The number of App updates shipped in a given time. If this number is small, the above two factors might not matter. But if our team ships many updates and the above factors start impacting delivery, it might be worth changing the strategy or adding a new approach to supplement the existing one.
  4. Automatic/Manual - While deploying mobile app updates, developers do not have an “Undo” button, and updates are permanent once shipped. Imagine shipping an App update to thousands of users and realising that a developer wrongly used the staging environment instead of production during the deployment process. This gets worse when a fix addressing this takes two days to be available due to it being stuck on App review. Any deployment strategy must be automatic to an extent that it at least eliminates developer errors.
  5. Implementation complexity - It’s great to have a one-click deployment in place, but it’s not optimal if it takes a few weeks to implement. We optimistically expect developers to set up the App, CI tools (TypeScript, Linter, Test-suite) and App deployments (Android and iOS) in a week. It might be okay if the process is not fully automatic as long as the rest of the metrics are addressed by our strategy.

Deployment of Expo vs React Native CLI app

It is relatively easy to deploy an App using Expo compared to the React Native CLI.

The difference between Expo and RN CLI is that Expo provides great defaults and aids the development of the different aspects (App permissions, Push notifications, Dark mode, etc. ) of a mobile application such that developers can focus on building good user experiences instead of handling third-party library conflicts, integration problems, upgrades and maintenance. With the CLI, developers have greater flexibility in deciding the correct third-party libraries that solve the problem and sometimes this could be a good thing as Expo can limit developers with what can be achieved in a Mobile app. But this choice comes with a cost in the form of developer hours spent on integrating the third-party library with the native platforms. Hence the Native container of an Expo App is constrained for a reason yet stable for each release.

The JavaScript codebase can change dynamically based on the App we want to build. Additionally, Expo uses Expo Application Services, which makes it a breeze to update the JavaScript bundle over the air. Running one command on your terminal or the CI can deliver instant updates to all the App users without submitting an App update to the iOS App Store or the Google Playstore, which is fantastic from a continuous deployment perspective.

But there are a few cons to using Expo, such as

  • Native Modules, which is available only on React Native CLI, enables the React Native JavaScript code to talk with Native platform code.
  • Latest Platform (iOS/Android) updates are not available immediately, and we need to wait for the Expo team to implement them on a future release.

Apps built using RN CLI have near-unlimited capabilities. Hence, We will only focus on the deployment strategies of RN CLI-based apps in this post.

FastLane

When starting a new project, there is a higher possibility of adding third-party libraries and associated dependencies to the project, which means the Native app container and the JavaScript codebase change continuously. This means teams must also frequently submit App updates to the iOS/Android app stores. Automating app builds keeps the team productive and focused on developing the app instead of deploying it.

Fastlane is a commonly used tool for managing App deployments. It is built using Ruby, has been around for some time, and is nicely documented. It also makes deploying apps to multiple environments (dev, staging, production) easier as we can create separate lanes for each environment and platform.

Fastfile contains all such lane commands, and the README.md is auto-generated so that every team member is empowered to deploy the Apps across environments and platforms. There’s also a plugin ecosystem with a lot of third-party integration for services such as Microsoft AppCenter, Version number increment, Upload symbols to Sentry, Bugsnag, etc.

App deployment on local machine

The Fastlane docs contain all the information required to set up an individual deployment workflow. This section shows how a deployment workflow looks like highlighting the ones which have proven to be successful for our projects.

iOS

For iOS, these are the typical steps of the app deployment workflow that Fastlane handles for us:

  • Ensure the git repository is clean, as we don’t want to build and submit apps with uncommitted code.
  • Auto-increment the iOS build numbers, as every iOS app submitted to TestFlight needs a higher build number.
  • Commit these changes, create a git tag and push the changes to the repository. These git tags serve as checkpoints to quickly identify the commit that caused a particular App release.
  • Build the app using the correct scheme based on the selected lane. Example: For lane development, build the Development version of the app.
  • Submit the generated App build to iOS TestFlight.

lane :development do
  ensure_git_status_clean
  increment_build_number(xcodeproj: 'ios/MyApp.xcodeproj')
  commit_version_bump(xcodeproj: 'ios/MyApp.xcodeproj')
  add_git_tag
  push_to_git_remote
  scheme = options.fetch(:scheme, 'Development')
  gym(
    scheme: scheme,
    include_bitcode: false,
    export_xcargs: '-allowProvisioningUpdates',
  )
  testflight(skip_waiting_for_build_processing: false)
end

Android

For Android, these are the steps managed by Fastlane:

  • Ensure the git repository is clean.
  • Build the app for the correct environment and Google play track.
  • Supply (Submit) the generated App build to Google Playstore.
lane :build do |options|
  type = options.fetch(:type, 'Release')
  env = options.fetch(:env, 'development')
  track = options.fetch(:track, 'beta')
  ENV['ENVFILE'] = '.env.#{env}'
  Dir.chdir('..') do
    sh 'bin/bundle-android'
  end
  gradle(task: 'clean')
  gradle(
    task: 'assemble',
    flavor: track,
    build_type: type,
  )
end

lane :development do
  ensure_git_status_clean
  build(type: 'Release', env: 'development', track: 'development')
  supply(
    track: 'internal',
    apk: 'development',
    package_name: 'com.myapp.development'
  )
end

We do not have automated version number increments because, on Android, we use product flavours which FastLane does not support to be incremented automatically. Unfortunately, this must be done manually before deploying an Android app.

Continuous Deployment workflow on the CI

The entire deployment process is difficult to automate on the CI fully, so we perform semi-automatic deployments. The build process is fully automated on Circle CI, whereas the deployment to the Stores is manually done on the local machine. This ensures that there are no developer errors with respect to the environment variables and other environment-specific Configurations (Firebase, Play store config, etc.) during App deployments as these data are stored as Circle CI secrets.

A Circle CI workflow is shown below, which helps developers build both iOS and Android apps on the CI in three steps:

  • build - Basic code quality checks (lint, types, tests, etc.)
  • hold_build - An approval process to conserve CI resources and not build apps for every commit.
  • build_ios or build_android - Actual build process which generates the App builds as artefacts.

Circle CI

iOS build process

The Apple AppStore connect session sometimes requires a one-time password, which can be entered on the local machine but not on the CI. The workarounds available were unreliable, which made us split the workflow into three like this:

  1. On the local machine, run bundle exec fastlane ios adhoc_pre_build. Irrespective of “Development”, “Staging”, or “Production”, this must be executed for each deployment to Testflight as it increments the build number of the iOS App.
  2. bundle exec fastlane ios adhoc_build will be executed in the CI, based on the selected environment, eventually generating an App build (IPA file).
  3. bundle exec fastlane ios adhoc_deploy scheme:{SCHEME_NAME} must be executed by the developer after downloading the artefact (IPA) produced by the CI. SCHEME_NAME must be provided depending on the chosen environment in the build process above.
lane :adhoc_pre_build do
  increment_build_number(xcodeproj: 'ios/MyApp.xcodeproj')
  commit_version_bump(xcodeproj: 'ios/MyApp.xcodeproj')
  add_git_tag
  push_to_git_remote
end

lane :adhoc_build do |options|
  scheme = options.fetch(:scheme, "Development")
  match(type: "appstore", readonly: true)
  gym(
    scheme: scheme,
    include_bitcode: false,
    export_xcargs: "-allowProvisioningUpdates",
  )
end

lane :adhoc_deploy do |options|
  scheme = options.fetch(:scheme, "Development")
  match(type: "appstore", readonly: true)
  testflight(
    ipa: "MyApp.ipa",
    skip_waiting_for_build_processing: false
  )
end

Android build process

Android product flavours do not allow automating the version numbers increment by flavour. To get around that and also keep a three-step process similar to iOS, the Android workflow is as follows:

  1. Ensure that the build number of the Android App is incremented in build.gradle file for the corresponding productFlavors - “development”, “staging” or “production”.
  2. bundle exec fastlane build will be executed in the CI, generating an App build (APK file).
  3. bundle exec fastlane android adhoc_deploy env:{ENV_NAME} must be executed by the developer after downloading the artefact (APK) produced above. ENV_NAME must be specified based on the downloaded artefact.
lane :build
# Same as described previously
end

lane :adhoc_deploy do |options|
  env = options.fetch(:env, "development")
  package_name = "com.myapp.development"
  apk = "myapp-development-release.apk"

  case env
    when "development"
      package_name = "com.myapp.development"
      apk = "myapp-development-release.apk"
    when "staging"
      package_name = "com.myapp.staging"
      apk = "myapp-staging-release.apk"
    when "production"
      package_name = "com.myapp"
      apk = "myapp-production-release.apk"
  end

  supply(
    track: "internal",
    apk: apk,
    package_name: package_name
  )
end

Conclusion

We covered App deployment strategies of React Native CLI based mobile apps in this post. Remember how there are two core parts of a React Native app. The process described in this post mainly applies to automating the deployment of the Native App container in which the JavaScript code gets bundled during the build process. React Native Code Push is a module that enables the JavaScript code to be separately updated over the air, similar to Expo Application Services, and automating deployments using this technology can hopefully be a separate blog post.