Single point versioning with Fastlane for React Native
Tags: fastlane, reactnative, xcode, gradle
Published: 2022-01-31 08:39:40

> This post originally appeared on our [ITP Dev blog](https://inthepocket.dev/posts/2022-01-27-single-point-versioning-with-fastlane-for-react-native/)

## The issue

When upgrading (or "bumping") a React native app you often need to touch multiple files. You need to change the version in the node package manifests, update that same version and the build id in every Xcode scheme you use, and on Android either set the version via `gradle.properties` or via CLI flags when executing Gradle.

Previously at ITP we tackled this with a conceptually simple bash script. You supplied it the current version and desired version and it did basic find and replace with `sed`:

```bash
#!/usr/bin/env bash
set -e

function _replace_in_file() {
  if command -v sed > /dev/null; then
    sed -i -e "s/$1/$2/g" "$3"
  else
    echo "You don't have sed and this script relies on it."
    echo "Please install sed via apt or brew"
    exit 1
  fi
}

function bump_android() {
  echo "Updating gradle.properties"

  _replace_in_file "$1" "$2" ./android/gradle.properties
  # shellcheck disable=SC2001
  _replace_in_file "$(echo "$1" | sed -e 's/\.//g')0" "$(echo "$2" | sed -e 's/\.//g')\0" ./android/gradle.properties
}

function bump_ios() {
  local schemes=("appstore" "client" "dev" "release")
  for scheme in "${schemes[@]}"; do
    echo "Updating Xcode scheme: $scheme"
    _replace_in_file "$1" "$2" "./ios/RNApp/Info-$scheme.plist"
  done
}

function bump_node() {
  echo "Updating package.json, package-lock.json"

  _replace_in_file "\"version\": \"$1\"" "\"version\": \"$2\"" package.json

  # Regenerate package-lock
  npm i --ignore-scripts
}

function main() {
  if [[ -z "$1" || -z "$2" ]]; then
    echo "Commands supplied were not valid."
    echo "This script expects the following form:"
    echo "  $ ./bump-version.sh <old_version> <new_version>"
    exit 1
  fi

  echo "Performing version bump from $1 👉 $2..."

  bump_node "$@"
  bump_android "$@"
  bump_ios "$@"

  # sed outputs some clutter files suffixed with -e, we delete them with xargs
  if [ "$(uname)" == "Linux" ]; then
    find . -type f -name \*-e -print0 | xargs --null /bin/rm -rf
  else
    find . -type f -name \*-e -print0 | /usr/bin/xargs -0 /bin/rm -rf
  fi
}

main "$@"
```

However, for people not familiar with Bash this script is some advanced dark magic. It was also reliant on which `sed` you have available on the system, GNU or BSD (see the check at the end of the script). The files changed were standards for iOS & node, but for Android we used an older way of putting the version in a variable inside of `gradle.properties` and then read that variable during the `gradle` assemble task.

## Why automate

Why automate something you could just change in VS Code or Xcode in the relative files? Well, version upgrades might not happen that often and then you need to either take your chances or ask around with coworkers if they remember the files that need to be changed. It's also super fast for newcomers in the team or to native development to just run the script (however they won't know the deeper details of what's happening).

You could, if desired, also just run this in a CI pipeline and have CI take care of automating version upgrades instead of making it a semi-automatic process via script.

This worked great for a few years, but when I came to think about it this way is still a bit to over engineered. We already use the great Fastlane tool in all our projects for building iOS & Android so surely there must be a way to make it easier __and__ integrated with Fastlane.

## Single point versioning via Fastlane

The solution to this in Fastlane is really easy:

1. Use Fastlane's `load_json` plugin and read node's `package.json`
2. Get the semver version e.g `1.0.0` from the `version` property
3. Append a unique build identifier. We use the job ID from our CI/CD pipeline
4. Join version and build id to a string
5. Set that as the version in Fastlane `gradle` & `gym` tasks.

This gives us a single point to manage the version: `package.json`. Updating the version there makes Fastlane read that and supply it to gym/gradle relative commands.
We abstracted this to a `utils.rb` ruby module since we use different schemes and build configurations, but if you have a simpler app it could just as well be written inline or at the top of your Fastfile

__utils.rb__

```rb
def load_build_metadata(opts)
  package = load_json(json_path: './package.json')
  build_id = ENV['AZURE_UNIQUE_BUILD_ID']

  if build_id.nil? || build_id.empty?
    puts "Required environment variable 'AZURE_UNIQUE_BUILD_ID' not found. Got: #{build_id}"
    exit(1)
  end

  app_version = package['version']
  build_number = build_id.gsub('.', '') # We expect a date format like 20211122.6 here and trim the dot

  # Return a hashmap with all version metadata we could be interested in
  Hash[
    'build_number' => build_number,
    'app_version' => app_version,
    'full_version' => "#{app_version}#{opts[:suffix] || ''}.#{build_number}"
  ]
end
```

__Fastfile__

```diff
platform :ios do
  desc '📱🥉 Build iOS Client'
  lane :build_client do
    add_app_icon_badge(label: 'client')
+   build_metadata = load_build_metadata(suffix: opts[:suffix])

    update_settings_bundle(
      xcodeproj: "ios/RNApp.xcodeproj",
      configuration: 'Client',
      target: 'RNApp-iOS',
      key: 'version_preference',
+     value: build_metadata['full_version']
    )

    set_info_plist_value(
      path: "ios/RNApp-iOS/Info-Client.plist",
      key: 'CFBundleShortVersionString',
+     value: build_metadata['app_version']
    )

    set_info_plist_value(
      path: "ios/RNApp-iOS/Info-Client.plist",
      key: 'CFBundleVersion',
+     value: build_metadata['full_version']
    )

    gym(...)
  end
end

platform :android do
  desc '🤖🥉 Build Android Client'
  lane :build_client do
    add_app_icon_badge(label: 'client')
+   build_metadata = load_build_metadata(suffix: opts[:suffix])

    gradle(project_dir: 'android', task: 'clean')

    gradle(
      project_dir: 'android',
      task: 'assemble',
      build_type: 'Clientrelease',
      properties: {
+       "android.injected.version.name": build_metadata['full_version'],
+       "android.injected.version.code": build_metadata['build_number']
      }
    )
  end
end
```

## Conclusion

__We now just need to do `npm version minor` and commit that the branch so the CI pipeline can pick it up 👷🏻‍♂️__

And then to summarise, this way of versioning multiple configurations/schemes is better for us because:

* We stopped using `gradle.properties` to version Android and inject it into Gradle by reading from the properties file! Instead we now use CLI flags passed to Gradle under the hood to configure versions at build time rather than in code.
* Creating a new version is actually handled by the one tool in control: Fastlane. The need to manually bump the correct files is gone. We narrowed it down to one: `package.json`
* This works regardless of which OS you are running on or which version/flavour of bash and subtools you use (e.g `sed`, `xargs`)
* It's more declarative than the bash script which required some knowledge of bash. That's not a common thing.
* The file typically doesn't change a lot, and if it does, anyone can easily solve/adapt it.