Use Expo, Ship faster: How Infinite Red ships 230% faster with Expo

Security notices9 minutes read

Tyler Williams

Tyler Williams

Guest Author

Speed up your React Native CI/CD with EAS Workflows, Maestro, and Expo Updates—run E2E tests on every PR without waiting on native builds.

Use Expo, Ship faster

This is a guest post from Tyler Williams, an engineer at Infinite Red. The post was originally published on Infinite Red's Red Shift.

...

A mindful, easy pace might be good for your nervous system, but it’s not the speed I want for my CI/CD. When it comes to React Native build pipelines in the cloud, I want servers overclocked, never sleeping, and racing to the finish line. I want my app to be built before I’ve even thought to ask for it.

This is what I want my datacenter machines to look like when they’re done running my blazing fast CI/CD pipelines. Photo credit Jasper Nance.

I think most of us can agree: we want fast CI, but we also want to have high confidence in the code we’re shipping. It would be great if we could run end-to-end tests on every PR. But with React Native, end-to-end tests usually mean waiting for long build times, right? What if we could skip those native build times and still run our tests — allowing us to ship quickly with confidence.

Let’s say you’ve got some fancy new Expo app, spun up with a boilerplate like Infinite Red’s Ignite. You want to use Maestro to run end-to-end tests on every PR, but you don’t want to slow down development. What do you do?

In a hurry? Here’s what we’ll do to speed up our CI:
1. use EAS Workflows to fingerprint your native build,
2. deploy JavaScript-only changes with EAS Update, and
3. skip native builds in CI when possible

If you already have your Expo and EAS project set up, and just want to see the code, jump to “Here’s the code”. Otherwise, read on to learn about the overall plan and setup.

Ignite, Expo, Maestro — getting started

Ignite comes with some pre-made Maestro tests in the boilerplate. To see those, you can run this command locally:

Terminal
yarn test:maestro

If you’ve got the app running on your local Android emulator or iOS simulator, those tests will pass.

With tests passing, you can set up the app in your Expo account with this command:

Terminal
eas init --id <your-project-id-here>

Now let’s say you want to run those Maestro tests on every PR. These days, I’m loving EAS workflows for running Maestro tests.

Set up EAS Workflows

If you haven’t already, follow the steps to set up your project with workflows:

  • Navigate to your Expo project’s GitHub settings (you may need to convert to an Expo Organization for this to work properly).
  • Follow the UI to install the GitHub app.
  • Select the GitHub repository that matches the Expo project and connect it.
  • Make sure you set up a paid subscription to Expo before going forward — it’s required to run the Maestro workflows. The on-demand plan will work fine if you’re just getting started.
  • Set up your eas credentials by running eas credentials and follow the prompts for both platforms.

Your first EAS workflow

Once you’ve done that, you can write a workflow file like this in .eas/workflows/smoke-test.yml :

Code
name: Smoke Test
on:
pull_request:
branches:
- main
jobs:
build_android:
type: build
params:
platform: android
profile: preview
build_ios:
type: build
params:
platform: ios
profile: preview
maestro_android:
type: maestro
needs: [build_android]
params:
build_id: ${{ needs.build_android.outputs.build_id }}
flow_path: .maestro/FavoritePodcast.yaml
maestro_ios:
type: maestro
needs: [build_ios]
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
flow_path: .maestro/FavoritePodcast.yaml

Here’s what this does:

  1. Builds for Android and iOS in parallel
  2. Once the Android is done, it runs the Maestro tests on the Android build
  3. Once the iOS build is done, it runs the Maestro tests on the iOS build.

These are all using pre-packaged EAS workflow jobs, so you don’t have to do too much work to wire it up. Write it down, and open up a PR with this new workflow. You’ll see this status in GitHub:

One of the most satisfying visuals for any modern programmer.

And you can see a graph of how those jobs ran in Expo:

Nice!

Can we run Maestro faster on EAS?

If you want to run this Maestro test on every pull request, that’s a lot of time to wait for tests to run! Most of it is waiting on native builds (19 minutes for Android, 9 minutes for iOS on that run).

If you mostly make JavaScript level changes to the app, why should you run a full native build every time? One of the major benefits of using React Native is that you can easily change JavaScript code with stable native dependencies.

Turns out, there’s an easy way to stitch together Expo Fingerprint, pre-packaged EAS workflows, EAS update, and Expo Development Builds to dramatically improve your CI time for Maestro tests with React Native. Here’s the main idea:

  1. On every PR, you send your code to EAS.
  2. You have EAS take a fingerprint of the native runtime for that commit.
  3. At the same time, you run an EAS update, and point it to a special cicd channel.
  4. If the fingerprint has been seen before, you can pull an old build and preview the EAS update on the existing build.
  5. If the fingerprint hasn’t been seen before, you run a full native build, and preview the EAS update on the new build, since something at the native level has changed.

This allows you to skip 10–20 minutes of build time per platform. With faster CI times, it becomes reasonable to run Maestro tests on every PR, and increase the confidence you have in your changes. If a particular changeset is going to update the native runtime, you’ll still get a test run of the native build step to make sure those changes don’t break anything.

Here’s the code

First, set up a development client profile in eas.json:

Code
"devClient": {
"developmentClient": true,
"distribution": "internal",
"android": {
"gradleCommand": ":app:assembleRelease"
},
"ios": {
"buildConfiguration": "Release",
"simulator": true
}

This adds a new profile to build called devClient , which specifically uses Expo Development Builds. You need to use the development build for this because any Expo Development build can preview any EAS update by constructing a special deep link URL. We specify both developmentClient: true AND set the android.gradleCommand and ios.buildConfiguration to release mode to improve the performance and reduce Maestro test flakes. We also make sure iOS is set up for simulators.

Speaking of which, you’ll need to opt-in to EAS updates. Configure EAS update by running:

Terminal
eas update:configure

Commit the changes created by that command, and then create a special cicd EAS update channel with:

Terminal
eas channel:create cicd

Now, update .eas/worfklow/smoke-test.yml to be a little more complex. It should follow the flow we discussed earlier:

Code
name: Smoke Test
on:
pull_request:
branches:
- main
jobs:
# Fingerprint this PR to see what native capabilities it needs
fingerprint:
name: Fingerprint
type: fingerprint
# Run the EAS update in parallel with the fingerprint, since we'll use it no matter what.
# This job outputs some data in updates_json that we'll use to construct a deep link later.
run_eas_update:
name: EAS update
type: update
params:
platform: all
channel: cicd # A specific channel only for CI/CD to use.
# Check if we have an existing Android development client
get_android_build:
name: Check for Android build
needs: [fingerprint]
type: get-build
params:
fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
profile: devClient
platform: android
# Check if we have an existing iOS development client
get_ios_build:
name: Check for iOS build
needs: [fingerprint]
type: get-build
params:
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
profile: devClient
# If we don't get a build_id from get_android_build, move forward with building from the ground up.
build_android:
name: Build Android
needs: [get_android_build]
if: ${{ !needs.get_android_build.outputs.build_id }}
type: build
params:
platform: android
profile: devClient
# If we don't get a build_id from get_ios_build, move forward with building from the ground up.
build_ios:
name: Build iOS
needs: [get_ios_build]
if: ${{ !needs.get_ios_build.outputs.build_id }}
type: build
params:
platform: ios
profile: devClient
# For new builds, use the one we just created in build_android,
# construct the deep link URL from the run_eas_update job
run_maestro_tests_new_build_android:
env:
MAESTRO_DEEP_LINK_URL: "exp+blogapp://expo-development-client/?url=https://u.expo.dev/1b0b7004-6a81-47f4-9bf7-727ab038dd10/group/${{ fromJSON(needs.run_eas_update.outputs.updates_json || '[]')[0].group }}"
name: Android Maestro - New Build
needs: [build_android, run_eas_update]
type: maestro
params:
build_id: ${{ needs.build_android.outputs.build_id }}
flow_path: '.maestro/FavoritePodcast.yaml'
run_maestro_tests_new_build_ios:
env:
MAESTRO_DEEP_LINK_URL: "exp+blogapp://expo-development-client/?url=https://u.expo.dev/1b0b7004-6a81-47f4-9bf7-727ab038dd10/group/${{ fromJSON(needs.run_eas_update.outputs.updates_json || '[]')[0].group }}"
name: iOS Maestro - New Build
needs: [build_ios, run_eas_update]
type: maestro
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
flow_path: '.maestro/FavoritePodcast.yaml'
# If we have builds we can recycle, use what we found in get_android_build,
# and construct the deep link URL from the run_eas_update job
run_maestro_tests_ota_update_android:
env:
MAESTRO_DEEP_LINK_URL: "exp+blogapp://expo-development-client/?url=https://u.expo.dev/1b0b7004-6a81-47f4-9bf7-727ab038dd10/group/${{ fromJSON(needs.run_eas_update.outputs.updates_json || '[]')[0].group }}"
name: Android Maestro - EAS
needs: [get_android_build, run_eas_update]
if: ${{ needs.get_android_build.outputs.build_id }}
type: maestro
params:
build_id: ${{ needs.get_android_build.outputs.build_id }}
flow_path: '.maestro/FavoritePodcast.yaml'
run_maestro_tests_ota_update_ios:
env:
MAESTRO_DEEP_LINK_URL: "exp+blogapp://expo-development-client/?url=https://u.expo.dev/1b0b7004-6a81-47f4-9bf7-727ab038dd10/group/${{ fromJSON(needs.run_eas_update.outputs.updates_json || '[]')[0].group }}"
name: iOS Maestro - EAS
needs: [get_ios_build, run_eas_update]
if: ${{ needs.get_ios_build.outputs.build_id }}
type: maestro
params:
build_id: ${{ needs.get_ios_build.outputs.build_id }}
flow_path: '.maestro/FavoritePodcast.yaml'

If you haven’t already, you’ll also need to actually install Expo Dev Client:

Terminal
npx expo install expo-dev-client

And add its config in app.json :

Code
"plugins": [
<other plugins here>
[
"expo-dev-client",
{
"launchMode": "most-recent"
}
]
],

Finally, update your Maestro test to use openLink to start with a specific deep link, specified with special Maestro environment variables.

Code
appId: com.blogapp
---
- openLink: ${MAESTRO_DEEP_LINK_URL}
# Conditional run for iOS deep link confirmation prompt
- runFlow:
when:
visible: 'Open'
commands:
- tapOn: 'Open'
- assertVisible: "Log In"
- tapOn:
text: "Tap to Log in!"
- assertVisible: "Your app, almost ready for launch!"
- tapOn:
text: "Let's go!"
- assertVisible: "Components to jump start your project!"

Now if you push this branch, Expo will see that it hasn’t yet fingerprinted this devClient build before, and run the full flow. Note how it takes just over 20 minutes for the full run on the example project:

And if you trigger the workflow again with an empty commit like this:

Terminal
git commit --allow-empty -m "Trigger Build" && git push

You’ll see that Expo matches the fingerprint, skips the native build, and instead deep links into the EAS update preview, and the flow runs much faster — about 6 minutes, down from 20!

So fast you won’t even have time to make a cup of coffee

Tradeoffs

  1. This development build is not 100% analogous to running your Maestro tests against a release build. So if you want to run your Maestro tests against what users will download to their device, you may want to set up another set of tests to do so. Fortunately — you can set these to run on a cadence, or on actual commits into main, and not bog down PR review on long native builds. You can still get strong signal from these per-PR tests.
  2. The Maestro tests you write will have to be augmented to tap through the Expo Splash Screen. With enough conditional statements and environment variables, you can write shared Maestro tests across use cases. But it can clutter things up a little bit in your Maestro files.

Use Expo, ship faster

Expo’s workflows are still in their early days, but it’s amazing how powerful they can be when you combine existing features like Expo fingerprint, EAS update, and built-in support for Maestro. Hopefully this example helps you ship a faster, with more confidence in your changes.

If you want to see the code used for these examples, check out the GitHub repository.

And if you’re interested in more helpful Expo and React Native tips, follow me on Bluesky, listen to our podcast, subscribe to our newsletter, or just come hang out with me in the Infinite Red Community Slack! And of course reach out to us if you have a React Native project that you need help with — we’d love to work with you.

CICD
EAS Workflows
Maestro
E2E Tests

Accelerate building apps with Expo and AI

Learn more