I feel like the last 3-4 years have been a turning point, React Native has become the best option to build cross-platform mobile applications in my opinion. The community has been hard at work with notable changes including Fabric and Hermes, the structure around trusted packages has improved and matured, Expo is killing it with friendly tooling, the core team is working on built in accessibility concepts, etc. We here at Knack can’t be more excited about the future of React Native and are proud to say we have been shipping a React Native mobile app for the past 7 years. In this post, I’d like to share just a few things around versioning that have helped us ship reliably over the years.

A primer on our setup, we ship what’s been called a “bare” React Native iOS and Android app, which means we don’t run Expo, handle React Native upgrades ourselves, maintain build and deployment pipelines via fastlane, and debug using tools like Flipper. For jobs that need macOS-specific environments or extra horsepower we have a fleet of M1/M2 Mac Mini self-hosted Github Runners. We have found self-hosting our macOS-specific environments to be one of the best decisions we’ve made from both a cost and flexibility perspective as we are able to keep our runner environment up-to-date with the latest macOS and Xcode versions (hosted runners always tend to be 4-6 months behind the latest macOS version).

With that out of the way I’d like to talk about versioning. To understand how we version our mobile app we have to first define the different flavors of our mobile app that we have here at Knack:

  • Production — User-facing mobile application deployed to iOS/Google Play — backups stored on S3
  • Beta — Internal-facing mobile application deployed for company-wide testing via AppCenter, backups stored on S3
  • PR Mobile Builds — Internal-facing ephemeral mobile application to be able to test changes at a PR level, testing and storage via S3
  • Nightly — Internal-facing mobile application that runs on a nightly schedule to be able to monitor the health of the deployment process, testing and storage via S3

We have found that these flavors are essential to making sure that we are able to catch bugs and monitor any failures in our deployment process as early as possible; however, as you can imagine the amount of builds that accumulate over time with having all of these different flavors of our application can get overwhelming. Having a strong versioning strategy is the only thing we can do to differentiate all of these flavors and make sure we are pointing internal users to the right version for testing.

With that our versioning strategy is the following, all apps have either a build and/or a version number:

  • Build Number — Referenced internally. On iOS it’s the CFBundleVersion and on Android it’s the versionCode. In our case we set this to be the number of git commits: git rev-list HEAD --count
  • Version Number — Referenced externally. On iOS it’s CFBundleShortVersionString and on Android its the versionName. This is the version that is published on the App/Google Play Store which is given a minor bump version every release (ex. 3.15.0 → 3.16.0 between releases) and a major bump around any large milestones

Making your build number into a pattern is ultimately optional but it’s helpful because whenever we make any build that isn’t for production, we need to be able to make some sense of it and ultimately track it down to a commit. Why do we use the number of git commits? Because the number of git commits is always an incrementing number! This can give high level context when looking through a multitude of builds in an orderly fashion while still having the ability to get a commit hash if looking at a specific build for errors or debugging.

For bumping versions we use the following API’s that fastlane gives us and have it run as part of our deployment steps:

  • CFBundleVersionincrement_build_number
  • versionCodeincrement_version_code
  • CFBundleShortVersionStringincrement_version_number
  • versionNameincrement_version_name

Of course each are platform specific so we call all of them when doing a cross-platform build. For version numbers we rely on an input to the Github Action from the user starting the job for a beta/production build so that we can ensure the correct number is given. It would be nice to be able to grab the last version number from the App/Google Play Store and give it a bump but from our research there’s no way to do that without using a service.

With that out of the way we can go back to the flavors of our mobile app and discuss more about how versioning plays a role in them:

  • Production — We give both a version number and a build number, necessary for App/Google Play stores
  • Beta — We give both a version number and a build number, used as a differentiator between releases on AppCenter
  • PR Mobile Builds — We only give a build number to since these are more ephemeral
  • Nightly — We only give a build number to since these are more ephemeral

We have found that for PR and Nightly builds we don’t need to give a version number since these flavors are reported back to the user via a comment on a PR or a check on the repo itself, if any digging occurs we’re normally looking for the “latest” build and in that case we know to look for the highest number available.

In conclusion, having a versioning strategy that works for your team can help make sense out of the chaos and multitude of builds when you’re shipping a mobile app.