Red Shift

The official Infinite Red publication for React Native design & development. We’re a fully distributed team building world-class apps for over 20 years for clients all around the world.

Follow publication

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

Harris Robin
Red Shift
Published in
17 min readDec 5, 2024

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

  1. Integrate EAS Updates into a bare React Native app.
  2. Write Fastlane scripts to manage updates in CI.
  3. 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="{&quot;expo-channel-name&quot;:&quot;dev&quot;}"/>

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="{&quot;expo-channel-name&quot;:&quot;dev&quot;}"/>
<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="{&quot;expo-channel-name&quot;:&quot;dev&quot;}"/>

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.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Published in Red Shift

The official Infinite Red publication for React Native design & development. We’re a fully distributed team building world-class apps for over 20 years for clients all around the world.

Responses (4)

Write a response

If anyone encounters issue with assets not showing up in release mode - remember to add `import "expo-assets"` inside index.js - this solves it!

--

I am getting this error after running the first command:
npx install-expo-modules@latest
Error:
/node_modules/expo-modules-autolinking/scripts/android/autolinking_implementation.gradle' line: 453 * What went wrong:
A problem occurred evaluating project…

--

I have been following this tutorial a lot and has been really useful!
The updates work as expected on android, but for iOS nothing happens. Is the Expo.plist file definitely located in the "ios/Supporting" folder? When I initially ran "eas…

--