Bitrise and React Native
I've been intending to write this article for about a year and just haven't made time for it until now. I'm going to discuss how our team uses Bitrise workflows to build our React Native mobile app for development and production. This is not intended as a tutorial and may gloss over some details, but it will share the broad strokes that could help someone take a similar approach. The article is not sponsored by Bitrise, and in fact you may find that there are other solutions which better fit your budget (see end of article).
When establishing a build pipeline, it helps to first consider what we are trying to accomplish. At a bare minimum, we want the service to automatically build and deploy iOS and Android apps when we add commits to specific branches of our React Native project repository. There are two different build configurations to consider. One, we would like to be able to build a development version of our app which speaks to our development API services environment. This version of the app should be installable on our devices for manual testing but never ship to external users. Two, we also want to be able to build a production candidate version of our app which will go to Apple's TestFlight and the Google Play Store internal testing track. These builds will connect to our production API, and after manual verification, they may be promoted to live store releases for end users. As an additional wrinkle, we would like these development and production versions to have distinct application identifiers so that they may be installed side-by-side. It would also be rather nice if we could store the app's version number in a single place (say,
package.json) to use for both platforms.
When it comes to providing multiple configuration environments for your React Native app, you may find various tools and opinions. Xcode supports the concepts of build schemes and configurations. Android uses build variants and product flavors. Third-party libraries will let you dynamically set environment variables for your project.
Perhaps adding to the cognitive load, React Native inherently includes debug and release build modes, although these are orthogonal to our goals. The debug build connects over a bridge to the bundler which is running dynamically so that you can develop your code and reload it on your device or simulator on the fly, whereas the release build is the fully packaged version that you might ship to a device or store. For our purposes, we might want to create a debug build using our production configuration when locally investigating a problem with our API services; and we would certainly want to create a release build with our development configuration when shipping out a version for internal testing.
If you're comfortable with the Xcode and Android mechanisms, by all means use them. However, some of us do not come from a native development background and feel a bit out of our depth when delving into the minutiae. To complicate matters, my team's first attempts at this infrastructure were in the days of React Native 0.55, back when we had to manually link many of our third party dependencies. Veering from the out-of-the-box configuration seemed to create problems with the standard react-native scripts for us. As well, it was often tricky to follow instructions written by others who were using simpler or different patterns.
We were able to get the multiple Xcode build configurations and Android product flavors working with our deployment system, but it caused some ongoing hassles and workarounds for local development. When we performed a fresh rebuild of our project from scratch with React Native 0.60, we also tossed out our existing pipeline and moved to a new approach.
We thought to ourselves: what if neither the React Native app logic nor the platform build configurations needed to know anything about the existence of multiple environments? Could we use the stock iOS and Android specs provided by React Native and have our different workflows surgically alter them during the pre-build steps? Experimentally we determined that yes we could, or pretty close to it.
Our React Native project, written in TypeScript, has a file named
env.ts which contains the environment-specific values. There are distinct
env.prod.ts files in the repository which are not used by the app directly; rather, the appropriate file is copied into the
env.ts position before building and packaging. In addition to avoiding an extra library dependency, this allows our config to be strongly typed and contain values more complex than strings. This file is where we configure the API endpoint that the app should use, as well as public API keys and other parameters specific to an environment.
What about the distinct application identifiers for the different builds? We determined that we could use our development identifier in the repo and have pre-build steps replace the identifier in the appropriate files when running through the production workflow. Defaulting to the development identifier means that local development builds will conveniently use the same identifier as CI development builds (with a minor caveat about Android build versions, discussed below), although it would be possible to use a third distinct identifier to keep those separate too.
App signing with Xcode's provisioning profiles and certificates can also be a bit tricky with multiple environments. Generally, we want to use an
adHoc profile for CI development builds, an
appStore profile for CI production builds, and a
development profile when working locally. Again, we left the default configuration focused on local development and used pre-build steps to apply the correct profiles when building.
And then there are the build and version numbers to consider. Clearly a unique build number should be provided automatically by a build system, but version numbers have a semantic meaning and should be applied manually at some point. As alluded to above, we wanted to store our app's version number in our root
package.json file and have it automatically propagate into the platform builds. Once again, we used pre-build steps to inject both of these values into each build. When compiling locally, our default build number and version number in the platform-specific files are 1 and 1.0.0, respectively. This creates a small wrinkle with Android because any CI development build already on a test device will have a higher build number and prevent the local build from being installed. When this happens, we simply delete the app from the device and try again.
Finally, getting into the weeds a bit, some projects have environment-specific values which reside outside of the app logic. Specifically for us, we need the production and development versions of our app to handle slightly different deeplinking URL schemes. With our implementation, these schemes must be specified in Xcode's
Info.plist and Android's
build.gradle. Again here we use our pre-build steps to replace placeholders with values from environment variables.
In our project we have a handful of git branches with event triggers for various builds. Essentially there are two workflows named
prod-deploy, both very similar in structure. With our current process, we manually merge to corresponding branches named
prod; but it's easy to imagine having a single branch on which pull requests trigger
dev-deploy builds and successful merges trigger
prod-deploy builds. Bitrise supports this and provides integrated GitHub status checks.
Each Bitrise workflow instance runs in a clean VM which comes with standard preinstalled tools such as Xcode and Java. Any additional needs specific to your build process must be installed and configured by steps in the workflow. Our
dev-deploy workflow consists of 24 steps:
- Activate SSH key: prepares SSH, required for Bitrise workflow
- Git Clone Repository: downloads the project code
- Prepare environment config: copies the appropriate
env.ts, as described above
- Bitrise.io Cache:Pull: downloads a build cache stored by Bitrise
- Get npm package version: exports the version number from
package.jsonfor use in later steps
- Create npmrc: prepares auth token for installing private libraries from npm
- Run yarn (install dependencies): installs project's yarn dependencies, which may be cached
- Install missing Android SDK components: does what it says, based on project's gradlew file
- Jetify: migrates any old native dependencies to AndroidX
- Configure Android project environment: replaces application id and deeplinking URL scheme values in
build.gradle, using environment-specific values
- Set Android version: replaces
build.gradle, using the npm package version (from step 5) and the Bitrise build number, respectively
- Configure iOS project environment: replaces bundle version, bundle short version, bundle identifier, distribution type, and deeplink URL scheme values in
project.pbxproj; using the Bitrise build number, npm package version (from step 5), and environment-specific values, respectively
- Stamp iOS AppIcon with version number: creates a distinct icon with the version and build number printed, to more easily distinguish our development builds from production builds. (This Bitrise step was created by a third party, and unfortunately we have not found an Android equivalent.)
- Android Build: builds an Android APK or AAB, depending on environment
- Android Sign: signs the APK or AAB generated by step 14, using our keystore
- AppCenter App Release (Android): ships the Android app to testers via AppCenter
- Certificate and profile installer: fetches the Xcode provisioning profiles and signing certificate from Bitrise storage
- Run CocoaPods install: installs project's CocoaPods dependencies, which may be cached
- Xcode Archive & Export for iOS: builds an iOS IPA
- AppCenter App Release (iOS): ships the iOS app to testers via AppCenter
- AppCenter dSYM upload: uploads the iOS symbols to AppCenter for use when debugging crashes, at least in theory
- Git tag: adds a custom tag based on environment and build number to the git commit that was cloned for this build
- Bitrise.io Cache:Push: updates the Bitrise build cache with the npm and CocoaPods dependencies that were installed
- Deploy to Bitrise.io - Apps, Logs, Artifacts: stores the results, including the app builds, in Bitrise for access via the web; also notifies via email that these artifacts are available, which can make for a more convenient iOS install than via AppCenter
At this point you may be wondering why so many of the steps have conditional behavior based on the environment, given that this is specifically the dev workflow. The answer is that the same steps are reused in other workflows. The
prod-deploy workflow is identical to the above other than these few differences:
Step 13 is omitted, leaving our standard app icon.
Step 16 is replaced by Deploy to Google Play, which sends our AAB to an internal test track in Google Play.
Step 20 is replaced by Deploy to iTunes Connect - Application Loader, which ships our IPA via TestFlight. (This step was created back when App Store Connect was known as iTunes Connect.)
Variables, Secrets, and Signing
As you probably expect, Bitrise allows you to provide environment variables to your build process. These can be configured both at the individual workflow scope and across the entire project. Some of these are in the form of protected secrets, for sensitive information like credentials, although some caution should be used when enabling them for builds triggered by pull requests. Additionally, Bitrise has a dedicated configuration related to code signing where you can store your Xcode provisioning profiles and certificate, as well as your Android keystore information and up to 5 arbitrary "generic" files. In our project, we use all of these features.
In a workflow, steps are configured with input parameter values. Generally, it is possible to use environment variables to populate these fields. This can be a convenient practice, centralizing all the bits which might be subject to change, as opposed to individually managing each of the steps in each of the workflows. Steps can also export variables for use in subsequent steps. For example, the Android Build step exports an AAB path which is used as an input for the Android Sign step.
Our project uses many variables, some of which are assembled from others to reduce the chance for misconfiguration across partially redundant values. Our individual workflows have a handful of environment-specific variables:
- The deployment environment, e.g.
dev, used to pick the right
- The application identifier, used for both Android and iOS
- The deeplink URL scheme, i.e., the prefix for our app's deeplinks
- The match distribution type, e.g.
AdHoc, used to specify the provisioning profile
- The Bitrise export method, e.g.
ad-hoc, used when building the iOS app
It should be clear how 1, 2, and 3 are used in the workflow steps described above. You may notice that 4 and 5 seem similar. They are effectively the same value written slightly differently. This is a side effect of the tools we have chosen, and it could be improved upon but does not cause us any significant pain.
We manage our provisioning profiles in Apple Developer Center by manually running fastlane match commands, which names them in the format
"match <type> <id>", where
<type> is the distribution type (
<id> is the app identifier. It is possible to run match in a workflow step to fetch the appropriate profile, but we do not use it this way. Instead we store our
AppStore distribution profiles in Bitrise's Code Signing section. In each workflow, we use a Bitrise-provided step to fetch all of the profiles (step 17) and specify which one to use by dynamically writing the Provisioning Profile Specifier in
project.pbxproj (step 12). Bitrise's Xcode Archive step requires an export method parameter in the format
app-store, which differs from the spelling used by match. This is why we use two similar variables.
It's worth noting that our custom steps perform variable substitution in the
project.pbxproj file but not in the
Info.plist file of our Xcode project. The way we accomplish this is by having the
Info.plist fields refer to project variables. For example,
Info.plist is set to a value of
$(PARENT_APP_BUNDLE_SHORT_VERSION). The first and simpler reason for this is that it's easier to write quick and dirty sed commands to replace values in the
project.pbxproj file than it is to parse and rewrite the
Info.plist file. The second and more significant reason is that our project has multiple targets to build because we use OneSignal for push notifications, which requires an iOS extension. This approach allows the
Info.plist files in both our app and the extension to refer to the same identifiers via project variables, regardless of environment.
Android code signing in Bitrise is much easier. We store our keystore file and credentials in the Code Signing section, and they are used as the default inputs to the Bitrise-provided Android Sign step.
Finally, there are the sensitive generic files. These allow you to store data in Bitrise and expose it to your build in the form of fetchable URLs. We use this to store a service credentials JSON file for our Google Play API user. The URL is used as a default input to Bitrise's Deploy to Google Play step. It would also be feasible to store an npmrc file here, although in our workflows we choose to store the npm auth token as a secret and write it to file with a shell script (step 6).
When establishing a Bitrise project, you connect it to a git repository and specify a default branch. You can edit the workflows and variables visually in your web browser, but ultimately they get saved as a
bitrise.yml file (except for the secrets and code signing data). Bitrise is able to host this configuration, or you can specify that your repository is responsible for it and store that file in the default branch. Even if you use Bitrise as the source of truth, storing the
bitrise.yml file in your repository allows you to record its change history, which can be helpful. The Workflow Editor dashboard provides a
bitrise.yml tab which lets you toggle that setting, view the contents of the file, and download it. They've also open-sourced the Workflow Editor, if you prefer to run it yourself.
In case it's helpful to anyone exploring this technology, I have created a public GitHub Gist containing a
bitrise.yml file with the workflows described above and a generic
Info.plist file demonstrating how some of the variables can be used. It's important to note that the Configure iOS project environment step also requires certain variables to be defined in the Xcode project, i.e., in
project.pbxproj. I chose not to share that file because it is rather large and complex and would not make much sense without annotation. Also be aware that the step numbers used in this article will not match the 0-based step numbers shown in Bitrise's execution logs.
Caveats and Considerations
What I want to tell you here is that Bitrise is not cheap. They offer a free hobbyist plan with a severe build time limit, and you will probably not build a React Native application on it. They offer an individual developer plan for $40/month, which limits you to a build concurrency of 1 with a 45-minute time limit. This was enough to serve our team's needs for over a year, with our workflows able to build and deploy both Android and iOS in about 35-40 minutes. Recently however, since upgrading from React Native 0.61 to 0.63 and changing out some third party dependencies, we can no longer build the iOS app in under 45 minutes, even with some tweaks to our caching. (The Android app still builds quickly.) To get back on track, we were forced to upgrade to the minimal organization plan at $100/month, providing us with 90-limit minutes and a build concurrency of 2.
It is possible to run workflows locally using their Bitrise CLI tool, but it's not clear to us how (or whether) some of the Bitrise-specific steps would work with this tool. It also seems prudent to run in a dedicated VM like Bitrise does, and they provide some resources to that end. Ultimately time is money, and sometimes it's easier to pay for the solution that is already provided and works.
There are plenty of alternatives to using Bitrise, numerous tools and services with various levels of setup, maintenance, and degrees of overlap: Jenkins, CircleCI, GitHub Actions, fastlane, and so on. You can run your builds on a dedicated local machine or in the cloud. One major limiting factor is that iOS builds need to occur on Apple hardware, which precludes certain options like AWS CodePipeline. There are many factors and trade-offs, but in the end, the decision of what to use is often made arbitrarily, based on familiarity or inertia.
With that in mind, I share this article not to try to convince anyone that Bitrise is the best solution for building React Native apps but rather to exhibit a way that works for us. If you are considering Bitrise for your React Native deployments, whether you're in an org already using it or you just think it looks nice, I hope that this information helps you. And as a final recommendation, one resource that I find useful for discussing these topics is the
#react-native channel on the Infinite Red Community Slack.