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:

#!/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

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

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 summarize, this way of versioning multiple configrations/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)
  • 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.