Automated EAS Updates in a bare React Native app with Multiple Flavors & Schemes.

Example repo: https://github.com/harrisrobin/bare-eas-updates-tutorial
With Microsoft sunsetting App Center and CodePush, many in the React Native community are seeking alternatives for over-the-air (OTA) updates. EAS Updates by Expo is a strong candidate, but existing guides often assume you’re using Expo and EAS. This guide will show you how to integrate EAS Updates into a bare React Native app using Fastlane, without sacrificing the developer experience that Expo projects offer.
If you don’t need to support a bare React Native app, consider using Expo with the Ignite boilerplate and skip this guide! Rolling your own Expo updates involves several components, and if it can be avoided, I recommend doing so. However, if you need to support Expo updates in a bare React Native app with multiple schemes/flavors, keep reading.
Objectives
- Integrate EAS Updates into a bare React Native app.
- Write Fastlane scripts to manage updates in CI.
- Support EAS Updates across multiple channels based on build flavors/schemes (e.g., Dev, Prod, QA).
Prerequisites
This guide assumes you have a bare React Native app based on the official template. If you’re using Ignite, much of this is already set up for you. A sample GitHub repository is available here for reference, starting from a fresh bare React Native project generated with
npx @react-native-community/cli
Installation
Install expo-modules:
npx install-expo-modules@latest
Assuming the automatic expo installation worked for you, here’s what the diff should look like after running
npx install-expo-modules@latest
This adds expo-modules which is a pre-requisite for using Expo Updates. There are quite a few changes that will be introduced in your project, so I recommend pausing here and just making sure your project still runs properly on iOS and Android.
Install expo-updates:
Now, you’ll want to install expo-updates according to the official instructions here.
Don’t forget to also create an Expo project and link it to your local project. This will link your project to the project on Expo and update the app.json file for you.
Here is what my app.json file looks like at the moment:
{
"name": "EasUpdatesExampleBare",
"displayName": "EasUpdatesExampleBare",
"expo": {
"name": "EasUpdatesExampleBare",
"slug": "easupdatesexamplebare",
"ios": {
"bundleIdentifier": "com.easupdatesexamplebare"
},
"android": {
"package": "com.easupdatesexamplebare"
},
"runtimeVersion": "1.0.0",
"updates": {
"url": "https://u.expo.dev/fc4d40b5-21ad-44d0-a62f-7c9f2851d363"
},
"extra": {
"eas": {
"projectId": "fc4d40b5-21ad-44d0-a62f-7c9f2851d363"
}
},
"owner": "harrisrobin"
}
}
At this stage, your diff should look something like this.
Modifying Configs for Multiple Schemes
Now we’re going to diverge a little bit from Expo’s “happy path”. In my case, the client’s app had different flavours/schemes: Dev, Qa & Release. In order to support multiple schemes, I need to be able to dynamically load configs into app.json. Thankfully, Expo allows us to do this by using a Typescript file instead of JSON. Go ahead and rename your app.json file into app.config.ts and install “dotenv” if you’d also like the ability to load environment variables into app.config.ts.
At this point, here’s what my app.config.ts file looks like:
import 'dotenv/config';
import {ExpoConfig, ConfigContext} from 'expo/config';
const bundleIdentifierForEnvironment = (environment: string) => {
switch (environment) {
case 'dev':
return 'com.easupdatesexamplebare.dev';
case 'qa':
return 'com.easupdatesexamplebare.qa';
case 'release':
return 'com.easupdatesexamplebare';
default:
return 'com.easupdatesexamplebare.dev';
}
};
const easProjectId = 'your_eas_project_id';
export default ({config}: ConfigContext): ExpoConfig => {
return {
...config,
name: 'EasUpdatesExampleBare',
slug: 'eas-updates-example-bare',
owner: 'harrisrobin',
ios: {
bundleIdentifier: bundleIdentifierForEnvironment(
process.env.APP_ENV as string,
),
},
android: {
package: bundleIdentifierForEnvironment(process.env.APP_ENV as string),
},
runtimeVersion: '1.0.0',
updates: {
url: `https://u.expo.dev/${easProjectId}`,
},
extra: {
eas: {
projectId: easProjectId,
},
},
};
};
Being able to load environment variables will come in handy later so we can support building the app with EAS Updates on different schemes/flavours. We will also be updating the `runtimeVersion` dynamically as well, but that will come in later when we start using Fastlane and Github Actions to automate our builds and OTA Updates.
At this stage, you have an app with EAS Updates. However if you’re like me and want to support EAS Updates with multiple flavours, along with automating it using Fastlane you’ll quickly see the limitations. Right now, runtimeVersion is hardcoded and cannot be automatically incremented and we have no way to dynamically pass a different channel for every build when using Expo Updates. We’re going to want to have a different channel for every flavour (dev, qa & release).
Before we continue, I would like to add one more value to my Expo Updates configuration files and that is the Channel Name. Expo uses channels to determine where to send your bundle over the air, which ensures you are not sending your bundle to the wrong audience (e.g prod vs dev).
Channels and Environments
To do this, make sure you modify the AndroidManifest.xml that you created when installing Expo Updates and add the following meta-data:
<meta-data
android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY"
android:value="{"expo-channel-name":"dev"}"/>
In my case, I put “dev” for now as the value for expo-channel-name. Later, we’ll insert this value dynamically depending on the flavour we’re building.
Now, your AndroidManifest.xml file should look like this:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:usesCleartextTraffic="true">
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="{"expo-channel-name":"dev"}"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://u.expo.dev/fc4d40b5-21ad-44d0-a62f-7c9f2851d363"/>
<activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
In ios/Supporting/Expo.plist, you’ll want to add the following key and value:
<key>EXUpdatesRequestHeaders</key>
<dict>
<key>expo-channel-name</key>
<string>dev</string>
</dict>
At this point, your Expo.plist file should look like this:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<true/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesRequestHeaders</key>
<dict>
<key>expo-channel-name</key>
<string>dev</string>
</dict>
<key>EXUpdatesRuntimeVersion</key>
<string>1.0.0</string>
<key>EXUpdatesURL</key>
<string>https://u.expo.dev/fc4d40b5-21ad-44d0-a62f-7c9f2851d363</string>
</dict>
</plist>
Finally, we’re going to add the Expo Channel Name inside the app.config.ts file as well, specifically in the updates object inside the ExpoConfig. Here’s what it will look like now:
import 'dotenv/config';
import {ExpoConfig, ConfigContext} from 'expo/config';
const bundleIdentifierForEnvironment = (environment: string) => {
switch (environment) {
case 'dev':
return 'com.easupdatesexamplebare.dev';
case 'qa':
return 'com.easupdatesexamplebare.qa';
case 'release':
return 'com.easupdatesexamplebare';
default:
return 'com.easupdatesexamplebare.dev';
}
};
const easProjectId = 'fc4d40b5-21ad-44d0-a62f-7c9f2851d363';
export default ({config}: ConfigContext): ExpoConfig => {
return {
...config,
name: 'EasUpdatesExampleBare',
slug: 'eas-updates-example-bare',
owner: 'harrisrobin',
ios: {
bundleIdentifier: bundleIdentifierForEnvironment(
process.env.APP_ENV as string,
),
},
android: {
package: bundleIdentifierForEnvironment(process.env.APP_ENV as string),
},
runtimeVersion: '1.0.0',
updates: {
url: `https://u.expo.dev/${easProjectId}`,
requestHeaders: {
'expo-channel-name': 'dev',
},
},
extra: {
eas: {
projectId: easProjectId,
},
},
};
};
Now let’s shift gears and go ahead and start using Fastlane, in order to solve the pain points mentioned above. This tutorial assumes that you are proficient with Fastlane so it will not cover Fastlane much. Nevertheless, I’ll highlight the parts that are relevant to this tutorial when it comes to managing EAS Updates when we build our apps using Fastlane.
Fastlane Integration
You’ll want to initialize Fastlane if you don’t already have it setup in your project. Let’s start with iOS:
cd ios
fastlane init
Here’s what my iOS App File looked like:
for_platform :ios do
apple_id("YOUR_APPLE_ID") # Your Apple email address
itc_team_id("") # App Store Connect Team ID
team_id("") # Developer Portal Team ID
for_lane :dev do
app_identifier('com.easupdatesexamplebare.dev')
end
for_lane :qa do
app_identifier('com.easupdatesexamplebare.qa')
end
for_lane :release do
app_identifier('com.easupdatesexamplebare')
end
end
Here is my iOS app’s Fastfile:
default_platform(:ios)
PROJECT = "EasUpdatesExampleBare"
XCODE_PROJECT = "#{PROJECT}.xcodeproj"
XCODE_WORKSPACE = "#{PROJECT}.xcworkspace"
XCODE_SCHEME = PROJECT
XCODE_SCHEME_DEV = "EasUpdatesExampleBareDev"
XCODE_SCHEME_QA = "EasUpdatesExampleBareQA"
XCODE_SCHEME_RELEASE = "EasUpdatesExampleBareRelease"
BUILD_DIR = "build"
platform :ios do
before_all do |lane, options|
setup_ci
api_key = app_store_connect_api_key(
is_key_content_base64: true
)
match(
type: "appstore",
api_key: api_key,
readonly: is_ci,
)
end
lane :compile do |options|
# compile the code
track = options[:track]
if (track.nil?) then
UI.user_error!('The track is required. Either dev, qa or release')
end
scheme_to_use = case track
when :dev
XCODE_SCHEME_DEV
when :qa
XCODE_SCHEME_QA
when :store
XCODE_SCHEME_RELEASE
else
XCODE_SCHEME
end
build_ios_app(
scheme: scheme_to_use,
workspace: XCODE_WORKSPACE,
export_method: "app-store",
silent: true,
clean: true,
output_directory: "#{BUILD_DIR}/#{track}"
)
end
lane :dev do |options|
track = :dev
compile(track: track)
end
lane :qa do |options|
track = :qa
compile(track: track)
end
lane :store do |options|
track = :store
compile(track: track)
end
# error block is executed when a error occurs
error do |lane, exception|
UI.error(exception.to_s)
# Notify slack?
end
end
This is a basic Fastfile that allows you to build an app with multiple schemes, in my case dev, qa and release.
Initialize Fastlane for Android:
You’ll want to have roughly the same for Android, except use Gradle instead of build_ios_app and so on:
fastlane_require 'dotenv'
import '../../fastlane/Fastfile'
default_platform(:android)
platform :android do
lane :compile do |options|
# compile the code
track = options[:track]
if (track.nil?) then
UI.user_error!('The track is required. Either dev, qa or release')
end
gradle(task: 'clean')
gradle(
task: 'assemble',
flavor: track.to_s,
build_type: 'Release',
)
end
lane :dev do |options|
track = :dev
compile(track: track)
end
lane :qa do |options|
track = :qa
compile(track: track)
end
lane :store do |options|
track = :store
compile(track: track)
end
# error block is executed when a error occurs
error do |lane, exception|
UI.error(exception.to_s)
end
end
Earlier, when we installed Expo Updates in our project, we modified some native code that I would like to highlight.
We added an expo_runtime_version value to android/app/src/main/res/values/strings.xml, which looks like this:
<resources>
<string name="app_name">EasUpdatesExampleBare</string>
<string name="expo_runtime_version">1.0.0</string>
</resources>
This value maps to…
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION"
android:value="@string/expo_runtime_version"/>
…inside AndroidManifest.xml.
We also added the following for the Expo Channel Name:
<meta-data
android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY"
android:value="{"expo-channel-name":"dev"}"/>
We added an Expo.plist file in ios/Supporting/ that looks like this:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<true/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesRequestHeaders</key>
<dict>
<key>expo-channel-name</key>
<string>dev</string>
</dict>
<key>EXUpdatesRuntimeVersion</key>
<string>1.0.0</string>
<key>EXUpdatesURL</key>
<string>https://u.expo.dev/fc4d40b5-21ad-44d0-a62f-7c9f2851d363</string>
</dict>
</plist>
Considering the fact that I have three flavours/schemes, I would like to avoid having to modify these values manually every time I create a build, particularly the `runtime version` and `expo channel name`. In order to be able to properly manage having Expo Updates on multiple apps, we’ll need to come up with a way to dynamically inject these values and automate this. Thankfully, Fastlane and Ruby are very good at this.
Let’s take a break and commit our work! So far, your diff should look like this.
Now, let’s start writing our first Fastlane lane to handle updating our Expo.plist file with the correct expo channel name and runtime version.
Personally, I like to create a fastlane directory in the root of the project for “shared” fastlane lanes. For this, let’s create a file in `fastlane/Fastfile`.
Let’s start this file out by defining some variables that we’ll be interacting with:
RUNTIME_VERSION = "1.0.0" # do not change
EXPO_PLIST_FILE = "../ios/Supporting/Expo.plist"
ANDROID_MANIFEST_FILE = '../android/app/src/main/AndroidManifest.xml'
Now you’ll want to use the plist package to set the RUNTIME_VERSION inside Expo.plist. For that, make to to require ‘plist’ and use the following lane, which I’ve called write_expo_plist:
lane :write_expo_plist do |options|
track = options[:track]
expo_plist_file = File.expand_path(EXPO_PLIST_FILE, __dir__)
UI.message("Writing to file: #{expo_plist_file}")
plist = Plist.parse_xml(expo_plist_file)
plist['EXUpdatesRuntimeVersion'] = RUNTIME_VERSION
plist['EXUpdatesRequestHeaders']['expo-channel-name'] = track.to_s # Correctly target the nested dictionary
UI.success("Wrote expo plist file with runtime version: #{RUNTIME_VERSION} and expo-channel-name to: #{track}")
File.write(expo_plist_file, plist.to_plist)
end
This script will modify the Plist file and set the correct values for runtime version and expo channel name.
Feel free to adjust the script to fit your needs. In my case, I typically pass my fastlane lanes a “track” value to determine what flavour/scheme we’re trying to build.
And now for Android; we’ll want to do the same thing, but we’re dealing with an XML file (AndroidManifest.xml), so in this case we have to use “nokogiri” to parse the XML file and then manipulate it. Go ahead and add “nokogiri” to your Gemfile by adding “gem nokogiri” and then running bundle install in the root directory. Here’s what my gem file looks like:
source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10"
# Cocoapods 1.15 introduced a bug which break the build. We will remove the upper
# bound in the template on Cocoapods with next React Native release.
gem 'cocoapods', '>= 1.13', '< 1.15'
gem 'activesupport', '>= 6.1.7.5', '< 7.1.0'
gem 'nokogiri'
and here’s what my lane looks like:
desc 'write_android_manifest'
lane :write_android_manifest do |options|
track = options[:track]
android_manifest_file = File.expand_path(ANDROID_MANIFEST_FILE, __dir__)
UI.message("Writing to file: #{android_manifest_file}")
xml_doc = File.open(android_manifest_file) { |f| Nokogiri::XML(f) }
updates_config = xml_doc.xpath("//meta-data[@android:name='expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY']").first
if updates_config
# Update the expo-channel-name value
current_value = updates_config['android:value']
new_value = current_value.gsub(/"expo-channel-name":"[^"]*"/, "\"expo-channel-name\":\"#{track.to_s}\"")
updates_config['android:value'] = new_value
# Write the changes back to the AndroidManifest.xml
File.write(android_manifest_file, xml_doc.to_xml)
UI.success("Updated expo-channel-name to #{track} in AndroidManifest.xml")
else
UI.error("Couldn't find the expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY meta-data in AndroidManifest.xml")
end
end
This does the same thing but inside our AndroidManifest.xml file.
We will also want to access the runtime version in our Javascript/Typescript code on the fly. For example we will need it for our app.config.ts file so that we can dynamically inject the runtimeVersion value.
To do this, I like to create a version.ts file which will hold this information, along with other info I find useful like the build number and app version. For the purpose of this tutorial, we’ll only worry about writing the RUNTIME_VERSION.
First create a variable to keep track of where your version.ts file will exist, which in my case is:
VERSION_TS_FILE = "../app/utils/version.ts"
Finally, here’s the lane script to write the content to the file:
lane :write_version_ts do
version_ts_file = File.expand_path(VERSION_TS_FILE, __dir__)
UI.message("Writing to file: #{version_ts_file}")
# Define the content of the TypeScript file
content = <<~HEREDOC
export const RUNTIME_VERSION = "#{RUNTIME_VERSION}"
HEREDOC
# Write the content to the file
File.open(version_ts_file, "w") { |file| file.write(content) }
end
Automate Config Updates with Fastlane
At this stage, my Fastfile looks like this:
require 'plist'
require 'nokogiri'
RUNTIME_VERSION = "1.0.0" # do not change
EXPO_PLIST_FILE = "../ios/Supporting/Expo.plist"
ANDROID_MANIFEST_FILE = '../android/app/src/main/AndroidManifest.xml'
VERSION_TS_FILE = "../app/utils/version.ts"
lane :write_expo_plist do |options|
track = options[:track]
expo_plist_file = File.expand_path(EXPO_PLIST_FILE, __dir__)
UI.message("Writing to file: #{expo_plist_file}")
plist = Plist.parse_xml(expo_plist_file)
plist['EXUpdatesRuntimeVersion'] = RUNTIME_VERSION
plist['EXUpdatesRequestHeaders']['expo-channel-name'] = track.to_s # Correctly target the nested dictionary
UI.success("Wrote expo plist file with runtime version: #{RUNTIME_VERSION} and expo-channel-name to: #{track}")
File.write(expo_plist_file, plist.to_plist)
end
desc 'write_android_manifest'
lane :write_android_manifest do |options|
track = options[:track]
android_manifest_file = File.expand_path(ANDROID_MANIFEST_FILE, __dir__)
UI.message("Writing to file: #{android_manifest_file}")
xml_doc = File.open(android_manifest_file) { |f| Nokogiri::XML(f) }
updates_config = xml_doc.xpath("//meta-data[@android:name='expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY']").first
if updates_config
# Update the expo-channel-name value
current_value = updates_config['android:value']
new_value = current_value.gsub(/"expo-channel-name":"[^"]*"/, "\"expo-channel-name\":\"#{track.to_s}\"")
updates_config['android:value'] = new_value
# Write the changes back to the AndroidManifest.xml
File.write(android_manifest_file, xml_doc.to_xml)
UI.success("Updated expo-channel-name to #{track} in AndroidManifest.xml")
else
UI.error("Couldn't find the expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY meta-data in AndroidManifest.xml")
end
end
lane :write_version_ts do
version_ts_file = File.expand_path(VERSION_TS_FILE, __dir__)
UI.message("Writing to file: #{version_ts_file}")
# Define the content of the TypeScript file
content = <<~HEREDOC
export const RUNTIME_VERSION = "#{RUNTIME_VERSION}"
HEREDOC
# Write the content to the file
File.open(version_ts_file, "w") { |file| file.write(content) }
end
Now, we need to decide on where we’re going to store the RUNTIME_VERSION; as ideally we need a single source of truth for our versions. You could choose to store this information wherever you want, however, I personally like to store this information inside the Github repo’s description section.
In your github description, you’ll want to initialize it with a value, probably 1.0.0:

And in my case, I wrote a simple fastlane script that pulls this version from the repo:
lane :get_versions do
response = HTTParty.get("https://api.github.com/repos/harrisrobin/bare-eas-updates-tutorial",
headers: {
"Accept" => "application/vnd.github.v3+json",
"Authorization" => "token #{ENV["GITHUB_TOKEN"]}",
})
unless response.code == 200
UI.user_error!("Invalid Github token provided.")
exit(1)
end
begin
description = JSON.parse(response.body)["description"].strip
description.split(" ").each do |version|
version_string = version.gsub(/android@|ios@|next@|runtime@|build/, "")
version_parts = version_string.split("+")
version_number = version_parts[0]
build_number = version_parts.length > 1 ? version_parts[1] : nil
if version.include?("runtime")
RUNTIME_VERSION = version_number
UI.message("Set Runtime version to #{RUNTIME_VERSION}")
end
end
UI.message("Writing version.ts file")
write_version_ts
rescue => e
UI.error("Error: #{e.message}")
UI.error("Backtrace: #{e.backtrace.join("\n")}")
UI.user_error!("Could not parse Github response for the version/build numbers.")
exit(1)
end
end
While this currently only gets the runtime version, the script is written in a way that lets you add any other version in that description field. Here’s an example of what this looks like on another project:

Special thanks Yulian for the inspiration on this.
Increment Runtime Version
With that, we’ll need a way to increment the runtime version in the github description. I wrote a lane script that relies on sem_version to do this.
First, add sem_version to your Gemfile:
gem "sem_version", "~> 2.0", ">= 2.0.1"
and then run “bundle install” from the root of your project.
With that done, here’s the script I came up with to do this:
lane :increment_runtime_version_and_save do |options|
segment = options[:segment]
track = options[:track]
v = SemVersion.new(RUNTIME_VERSION)
v = SemVersion.new(v.major, v.minor, v.patch, nil)
if segment == "major"
v.major = v.major + 1
v.minor = 0
v.patch = 0
end
if segment == "minor"
v.minor = v.minor + 1
v.patch = 0
end
if segment == "patch"
v.patch = v.patch + 1
end
next_v = SemVersion.new(v.major, v.minor, v.patch, nil)
RUNTIME_VERSION = next_v.to_s
new_version_and_build_numbers = [
"runtime@#{RUNTIME_VERSION}"
].join(" ")
UI.message("Runtime version: #{RUNTIME_VERSION}")
response = HTTParty.post("https://api.github.com/repos/harrisrobin/bare-eas-updates-tutorial",
headers: {
"Accept" => "application/vnd.github.v3+json",
"Authorization" => "token #{ENV["GITHUB_TOKEN"]}",
},
body: {
description: new_version_and_build_numbers,
}.to_json)
unless response.code == 200
UI.user_error!("Could not update runtime version number. HTTP Error #{response.code}: #{response.body}")
exit(1)
end
write_version_ts
write_expo_plist(track: track)
write_android_manifest(track: track)
end
This script takes a segment (major, minor, patch) and a track (which flavor/variant). In my case, the track is also the expo channel name. I find this makes it easy to understand.
To tie all the lanes together, let’s create a new increment_expo_runtime_version lane which gets the latest version from the repo’s description, increments it and writes it to the appropriate files.
This lane script looks like this:
desc 'increment expo runtime version'
lane :increment_expo_runtime_version do |options|
segment = options[:segment]
track = options[:track]
get_versions
increment_runtime_version_and_save(segment: segment, track: track)
end
Now you can run:
fastlane increment_expo_runtime_version segment:patch track:alpha
and this will fetch the latest version, set it to the local variable, increment it and write it Expo.plist, AndroidManifest.xml and version.ts, along with the appropriate expo channel name where relevant.
At this stage, you should have a bunch of new fastlane lane scripts and your diff should look like this.
Now, let’s shift gears again and go back to our ios and android fastfiles.
We’re going to want to get the latest runtime version and write it to the appropriate files every-time you trigger a build. Starting with ios/fastlane/Fastfile, To do this, we’ll simply add a new private lane called get_and_write_versions:
private_lane :get_and_write_versions do |options|
track = options[:track]
# get latest version and build numbers
get_versions
# Set the runtime version in the Expo.plist file
write_expo_plist(track: track)
# write versions in the version.ts file
write_version_ts
end
As you can see, this relies on the new shared fastlane lane scripts we created earlier.
We’ll also want to do this before the compile step of every track:
lane :dev do |options|
track = :dev
get_and_write_versions(track: track)
compile(track: track)
end
lane :qa do |options|
track = :qa
get_and_write_versions(track: track)
compile(track: track)
end
lane :store do |options|
track = :store
get_and_write_versions(track: track)
compile(track: track)
end
At this stage, my `ios/fastlane/Fastfile` looks like this:
import '../../fastlane/Fastfile'
default_platform(:ios)
PROJECT = "EasUpdatesExampleBare"
XCODE_PROJECT = "#{PROJECT}.xcodeproj"
XCODE_WORKSPACE = "#{PROJECT}.xcworkspace"
XCODE_SCHEME = PROJECT
XCODE_SCHEME_DEV = "EasUpdatesExampleBareDev"
XCODE_SCHEME_QA = "EasUpdatesExampleBareQA"
XCODE_SCHEME_RELEASE = "EasUpdatesExampleBareRelease"
BUILD_DIR = "build"
platform :ios do
before_all do |lane, options|
setup_ci
api_key = app_store_connect_api_key(
is_key_content_base64: true
)
match(
type: "appstore",
api_key: api_key,
readonly: is_ci,
)
end
lane :compile do |options|
# compile the code
track = options[:track]
if (track.nil?) then
UI.user_error!('The track is required. Either dev, qa or release')
end
scheme_to_use = case track
when :dev
XCODE_SCHEME_DEV
when :qa
XCODE_SCHEME_QA
when :store
XCODE_SCHEME_RELEASE
else
XCODE_SCHEME
end
build_ios_app(
scheme: scheme_to_use,
workspace: XCODE_WORKSPACE,
export_method: "app-store",
silent: true,
clean: true,
output_directory: "#{BUILD_DIR}/#{track}"
)
end
private_lane :get_and_write_versions do |options|
track = options[:track]
# get latest version and build numbers
get_versions
# Set the runtime version in the Expo.plist file
write_expo_plist(track: track)
# write versions in the version.ts file
write_version_ts
end
lane :dev do |options|
track = :dev
get_and_write_versions(track: track)
compile(track: track)
end
lane :qa do |options|
track = :qa
get_and_write_versions(track: track)
compile(track: track)
end
lane :store do |options|
track = :store
get_and_write_versions(track: track)
compile(track: track)
end
# error block is executed when a error occurs
error do |lane, exception|
UI.error(exception.to_s)
# Notify slack?
end
end
You’ll want to do the same thing in android/fastlane/Fastfile with the only difference being that we will write the runtime version to AndroidManifest.xml instead of Expo.plist:
fastlane_require 'dotenv'
import '../../fastlane/Fastfile'
default_platform(:android)
platform :android do
lane :compile do |options|
# compile the code
track = options[:track]
if (track.nil?) then
UI.user_error!('The track is required. Either dev, qa or release')
end
gradle(task: 'clean')
gradle(
task: 'assemble',
flavor: track.to_s,
build_type: 'Release',
)
end
private_lane :get_and_write_versions do |options|
track = options[:track]
# get latest version and build numbers
get_versions
# Set the runtime version in the Expo.plist file
write_android_manifest(track: track)
# write versions in the version.ts file
write_version_ts
end
lane :dev do |options|
track = :dev
get_and_write_versions(track: track)
compile(track: track)
end
lane :qa do |options|
track = :qa
get_and_write_versions(track: track)
compile(track: track)
end
lane :store do |options|
track = :store
get_and_write_versions(track: track)
compile(track: track)
end
# error block is executed when a error occurs
error do |lane, exception|
UI.error(exception.to_s)
end
end
Finally, you need to make sure that you import the RUNTIME_VERSION constant we are writing to version.ts in app.config.ts to make sure that your app uses the correct one. As for the expo-channel-name, I prefer to do it via environment variables, but you can also write it in your version.ts if you prefer.
In my case, I use typescript to manage my expo’s app config file, so in order to import typescript files you will need to use “ts-node/register” since Expo won’t compile your imported typescript files automatically. Alternatively, you can just create a JS file instead (version.js). If you choose to import typescript files in app.config.ts, go ahead and install ts-node to your devDependencies and add it to the top of your app.config.ts like so:
import 'ts-node/register';
import 'dotenv/config';
import {ExpoConfig, ConfigContext} from 'expo/config';
const bundleIdentifierForEnvironment = (environment: string) => {
switch (environment) {
case 'dev':
return 'com.easupdatesexamplebare.dev';
case 'qa':
return 'com.easupdatesexamplebare.qa';
case 'release':
return 'com.easupdatesexamplebare';
default:
return 'com.easupdatesexamplebare.dev';
}
};
const easProjectId = 'fc4d40b5-21ad-44d0-a62f-7c9f2851d363';
export default ({config}: ConfigContext): ExpoConfig => {
return {
...config,
name: 'EasUpdatesExampleBare',
slug: 'eas-updates-example-bare',
owner: 'harrisrobin',
ios: {
bundleIdentifier: bundleIdentifierForEnvironment(
process.env.APP_ENV as string,
),
},
android: {
package: bundleIdentifierForEnvironment(process.env.APP_ENV as string),
},
runtimeVersion: '1.0.0',
updates: {
url: `https://u.expo.dev/${easProjectId}`,
requestHeaders: {
'expo-channel-name': 'dev',
},
},
extra: {
eas: {
projectId: easProjectId,
},
},
};
};
Now make sure you import RUNTIME_VERSION and use it, so your app.config.ts will look like this:
import 'ts-node/register';
import 'dotenv/config';
import {ExpoConfig, ConfigContext} from 'expo/config';
import {RUNTIME_VERSION} from './app/utils/version';
const bundleIdentifierForEnvironment = (environment: string) => {
switch (environment) {
case 'dev':
return 'com.easupdatesexamplebare.dev';
case 'qa':
return 'com.easupdatesexamplebare.qa';
case 'release':
return 'com.easupdatesexamplebare';
default:
return 'com.easupdatesexamplebare.dev';
}
};
const easProjectId = 'fc4d40b5-21ad-44d0-a62f-7c9f2851d363';
export default ({config}: ConfigContext): ExpoConfig => {
return {
...config,
name: 'EasUpdatesExampleBare',
slug: 'eas-updates-example-bare',
owner: 'harrisrobin',
ios: {
bundleIdentifier: bundleIdentifierForEnvironment(
process.env.APP_ENV as string,
),
},
android: {
package: bundleIdentifierForEnvironment(process.env.APP_ENV as string),
},
runtimeVersion: RUNTIME_VERSION,
updates: {
url: `https://u.expo.dev/${easProjectId}`,
requestHeaders: {
'expo-channel-name': process.env.EXPO_CHANNEL_NAME,
},
},
extra: {
eas: {
projectId: easProjectId,
},
},
};
};
With that done, you now have a reliable way of managing Expo Updates within a bare project that has multiple schemes/flavours using Fastlane!
The latest diff will look like this.
If you build your project using bundle exec fastlane ios alpha, you can be certain that your project’s EAS Updates will be properly configured and if you need to bump the runtime version, you can simply run:
bundle exec fastlane increment_expo_runtime_version segment:patch track:alpha
These scripts will work for whatever tracks you have, whether it’s locally or in CI.
Where to go next
Join the Infinite Red Community
Want to discuss this with other React Native developers? Join thousands of other developers over in our Slack community.
Hire the React Native Experts for your next project
Our U.S.-based experts are here to build, optimize, and support your React Native app — and level up your team along the way! Get in touch with us at Infinite Red.