React Native Builds on GitHub Actions
Run React Native builds on GitHub Actions by splitting Android onto warp-ubuntu-latest-x64-8x and iOS onto warp-macos-15-arm64-6x, with per-platform caches.
Last verified:
A React Native pipeline on GitHub Actions needs two runner families, because the Android build wants a Linux machine with cores and memory while the iOS build has to run on macOS for xcodebuild and CocoaPods. On WarpBuild that is one workflow file with the Android job on warp-ubuntu-latest-x64-8x at $0.016 per minute and the iOS job on warp-macos-15-arm64-6x at $0.08 per minute, each keeping its own cache keys.
This page covers the workflow YAML for that split, the catalog rows and per-minute rates behind both labels, the four bottlenecks that dominate React Native wall clock time, the instrumentation-test setup for Android emulators, and the arithmetic against GitHub-hosted list prices.
Overview
A React Native repository produces three kinds of work on every pull request, and they want three different machines.
The first is pure JavaScript: install dependencies, typecheck, lint, and run Jest. It never touches a native toolchain, so it belongs on a small Linux runner and can start immediately.
The second is the Android build: Gradle, the Android SDK, the Kotlin and Java compilers, the NDK for any project with native modules, and a Metro bundle step that Gradle invokes as bundleReleaseJsAndAssets. This is CPU-bound and memory-hungry, and it runs on Linux.
The third is the iOS build: pod install, then xcodebuild against a workspace, with the same Metro bundle step invoked from the Xcode build phase. This has to run on macOS.
WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so all three jobs live in one workflow file and differ only in the warp- label on runs-on. Every label, size, and per-minute rate is in the cloud runner catalog, and the full rate card is on the pricing page.
Two platform limits shape the layout. WarpBuild cache is enabled by default on Linux runners, so Linux jobs get a cache backend without any workflow change, while the macOS job uses actions/cache against the GitHub Actions cache backend. And macOS runners do not support nested virtualization and cannot run Docker, so anything containerized, including an Android emulator, stays on a Linux x86-64 label.
For the Flutter equivalent of this split see Flutter builds on GitHub Actions, and for the deeper Xcode and signing material see iOS builds on GitHub Actions.
Configuration
Moving an existing React Native workflow across is a label change. The images carry the same tooling as GitHub-hosted runners, so the Android SDK, Node, Ruby, Xcode, and the CocoaPods toolchain are already on disk.
The Linux jobs can go one step further and use the WarpBuild forks of the common setup actions. WarpBuilds/setup-node@v6 and WarpBuilds/gradle-actions/setup-gradle@v5 accept the same inputs as their upstream counterparts and route their caching through WarpBuild cache automatically on WarpBuild runners. The full list is in the setup actions reference.
Metro needs one configuration change before its cache is portable. By default the transform cache lands in the system temp directory, which an ephemeral runner throws away at the end of every job. Point it at a path inside the workspace instead:
// metro.config.js
const path = require('path');
const {FileStore} = require('metro-cache');
module.exports = {
cacheStores: [
new FileStore({root: path.join(__dirname, 'node_modules/.cache/metro')}),
],
};With that in place, the three jobs look like this:
name: react-native
on:
pull_request:
push:
branches: [main]
concurrency:
group: rn-${{ github.ref }}
cancel-in-progress: true
jobs:
js-checks:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-node@v6
with:
node-version: 20
cache: yarn
- run: yarn install --immutable
- run: yarn lint
- name: Restore the Metro transform cache
uses: WarpBuilds/cache@v1
with:
path: node_modules/.cache/metro
key: metro-jest-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
restore-keys: |
metro-jest-${{ hashFiles('yarn.lock') }}-
metro-jest-
- run: yarn jest --ci
android:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-node@v6
with:
node-version: 20
cache: yarn
- run: yarn install --immutable
- uses: WarpBuilds/setup-java@v5
with:
distribution: temurin
java-version: '17'
- uses: WarpBuilds/gradle-actions/setup-gradle@v5
- name: Restore the Metro transform cache
uses: WarpBuilds/cache@v1
with:
path: node_modules/.cache/metro
key: metro-android-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
restore-keys: |
metro-android-${{ hashFiles('yarn.lock') }}-
metro-android-
- name: Assemble release
working-directory: android
run: ./gradlew assembleRelease --build-cache
ios:
runs-on: warp-macos-15-arm64-6x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
- run: yarn install --immutable
- name: Restore the CocoaPods cache
uses: actions/cache@v4
with:
path: |
ios/Pods
~/Library/Caches/CocoaPods
key: pods-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: pods-
- name: Restore the Metro transform cache
uses: actions/cache@v4
with:
path: node_modules/.cache/metro
key: metro-ios-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
restore-keys: metro-ios-${{ hashFiles('yarn.lock') }}-
- name: Install pods
working-directory: ios
run: pod install
- name: Build for the simulator
run: |
xcodebuild build \
-workspace ios/App.xcworkspace \
-scheme App \
-configuration Release \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath ios/DerivedData \
CODE_SIGNING_ALLOWED=NOFour details in that file are worth calling out.
The Metro cache key is per platform. Android and iOS transform the same source with different platform extensions and different Babel output, so one shared key produces a cache that both jobs keep invalidating. Three prefixes, metro-jest-, metro-android-, and metro-ios-, keep them apart while the restore-keys fallback still lets a new commit start from the previous run.
The Linux jobs use the WarpBuilds forks and the macOS job does not. WarpBuild cache is enabled by default on Linux runners, so WarpBuilds/setup-node and WarpBuilds/cache write there with no extra configuration. The macOS job uses actions/setup-node and actions/cache, which store against the GitHub Actions cache backend.
The CocoaPods key hashes Podfile.lock alone. The resolved pod set changes only when the lockfile changes, so a broad key with a pods- fallback gives a high hit rate. Caching ~/Library/Caches/CocoaPods alongside ios/Pods keeps the downloaded pod archives, which is what makes a partial hit useful.
Gradle caching comes from two layers. WarpBuilds/gradle-actions/setup-gradle@v5 persists the Gradle User Home, meaning wrapper distributions, downloaded dependencies, and build caches, while --build-cache lets Gradle reuse task outputs across runs.
Android instrumentation tests need nested virtualization
Espresso and Detox Android tests boot a real Android system image, and the emulator runs at useful speed only with hardware acceleration through /dev/kvm. On WarpBuild cloud runners you enable that by appending a dynamic label to runs-on with a semicolon:
runs-on: warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=trueNested virtualization is available on Linux x86-64 cloud runners only. ARM64, Windows, and macOS runners do not support it, and the label is silently ignored there, so an emulator job that looks healthy but crawls is worth checking for a misplaced label first. On BYOC the label is unnecessary: BYOC runs on AWS, GCP, and Azure, and nested virtualization is enabled automatically when every instance type in the runner set supports it.
One more step is required after /dev/kvm exists. Default device permissions block the runner user, so add the udev rule before the emulator starts, exactly as documented in the nested virtualization reference. This step is standard on all GitHub and GitHub-compatible runners.
Detox on iOS needs none of this, because it drives the iOS simulator, which runs natively on the macOS runner.
Sizing
These are the Linux x86-64 rows from the cloud runner catalog. Ubuntu 22.04 and 26.04 labels exist at the same rates.
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 |
warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 |
warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 |
And the macOS rows for the iOS half:
| Runner label | macOS | vCPU | Memory | Storage | Price per minute | Alias |
|---|---|---|---|---|---|---|
warp-macos-26-arm64-6x | macOS 26 | 6 | 22GB | 120GB SSD | $0.08 | |
warp-macos-26-arm64-12x | macOS 26 | 12 | 44GB | 270GB SSD | $0.16 | |
warp-macos-15-arm64-6x | macOS 15 | 6 | 22GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x |
warp-macos-15-arm64-12x | macOS 15 | 12 | 44GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x |
warp-macos-14-arm64-6x | macOS 14 | 6 | 22GB | 120GB SSD | $0.08 |
The latest aliases track macOS 15, in sync with GitHub's macos-latest tag. The macOS 26 images ship Xcode 27.0 with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes on top of the upstream GitHub macOS 26 image, while GitHub's upstream macOS 27 runner image is in beta; a dedicated macOS 27 image follows once that image is released.
Recommended starting points for a React Native repository:
| Job | Label | Reasoning |
|---|---|---|
| JavaScript lint and Jest | warp-ubuntu-latest-x64-4x | Jest workers scale with cores, and 4 vCPU with 16GB covers a mid-sized suite. Move to 8x when the suite runs long enough to gate the pull request. |
| Android assemble | warp-ubuntu-latest-x64-8x | Kotlin, Java, and NDK compilation parallelize across modules, and the Gradle daemon plus Metro want real memory. Move to 16x when the module graph is wide or the project builds C++ from source. |
| Android instrumentation | warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=true | The emulator claims cores and memory for itself while Gradle and adb run beside it. 16x leaves room for two emulators in a sharded matrix. |
| iOS build and simulator tests | warp-macos-15-arm64-6x | A debug or simulator build with a warm Pods cache rarely saturates 12 vCPU. |
| iOS release archive | warp-macos-26-arm64-12x | Release configuration raises peak memory, and the 270GB disk holds Xcode, the runtimes, DerivedData, and an .xcarchive together. |
The CI observability view reports right-sizing recommendations from measured CPU and memory utilization per repository, workflow, job, and instance type, which settles the question faster than reading a single run.
Against GitHub-hosted list prices
GitHub publishes its per-minute rates on the GitHub Actions minute multipliers reference, and the shapes on its hosted runner reference. Both were checked on 2026-08-13. These are the rows a React Native pipeline touches:
| Shape | WarpBuild label | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | Lower list price |
|---|---|---|---|---|---|
| 4 vCPU, 16GB Linux | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent |
| 8 vCPU, 32GB Linux | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent |
| 16 vCPU, 64GB Linux | warp-ubuntu-latest-x64-16x | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent |
| 6 vCPU, 22GB macOS ARM64 | warp-macos-15-arm64-6x | $0.08 | largest GitHub-hosted macOS ARM64 runner, 5 vCPU and 14GB | $0.102 | 22 percent |
The macOS row is the one that moves a React Native bill, and it compares different hardware in WarpBuild's favor: 6 vCPU with 22GB against 5 vCPU with 14GB, at $0.102 - $0.080 = $0.022 per minute less. GitHub also lists a smaller standard macOS runner at $0.062 per minute with 3 vCPU and 7GB, which is a different amount of hardware rather than the same machine at another price.
Worked cost model
Take a team merging often enough to run the full pipeline 300 times a month, with three jobs per run: 6 minutes of JavaScript checks on 4 vCPU, 14 minutes of Android assemble on 8 vCPU, and 18 minutes of iOS build on the 6 vCPU macOS label. Job durations are held equal on both platforms so the table isolates the rate difference.
| Job | Minutes | WarpBuild rate | WarpBuild cost | GitHub-hosted rate | GitHub-hosted cost |
|---|---|---|---|---|---|
| JavaScript checks (4 vCPU) | 6 | $0.008/min | $0.048 | $0.012/min | $0.072 |
| Android assemble (8 vCPU) | 14 | $0.016/min | $0.224 | $0.022/min | $0.308 |
| iOS build (macOS 6 vCPU) | 18 | $0.08/min | $1.44 | $0.102/min | $1.836 |
| Per pipeline run | 38 | $1.712 | $2.216 | ||
| Per month at 300 runs | 11,400 | $513.60 | $664.80 |
The monthly gap is $151.20 before any duration change from caching. GitHub list prices checked on 2026-08-13; the WarpBuild rates are on the pricing page.
Bottlenecks
Four things dominate React Native wall clock time on GitHub Actions.
1. CocoaPods resolution. A cold pod install resolves the dependency graph and downloads every pod, and React Native projects carry dozens of them from the React core alone. Cache ios/Pods and ~/Library/Caches/CocoaPods keyed on Podfile.lock, and keep Podfile.lock committed so the key is stable across machines. When the resolution itself is slow rather than the download, check whether the Podfile still points at the full specs repository instead of the CDN source that current React Native templates use.
2. Gradle daemon cold start. Runners are ephemeral VMs, so every Android job starts a fresh Gradle daemon and a cold JVM, then re-reads the whole build script graph. Most of that time comes back through the configuration cache, --build-cache for task output reuse, and WarpBuilds/gradle-actions/setup-gradle@v5 persisting the Gradle User Home between runs. Projects with C++ native modules should also cache the CMake and NDK build directories, because recompiling native code is the longest step in a cold Android job.
3. Metro bundler cache. Metro transforms every JavaScript and TypeScript file through Babel, and a large app has thousands of them plus the module graph of its dependencies. With the FileStore configuration above, the transform cache survives between runs and Metro only re-transforms the files that changed. Without it, both the Android bundle step and the iOS bundle phase pay the full transform on every run. Keep the key per platform, and include the commit SHA in the primary key with a lockfile-scoped restore-keys prefix so each run writes a fresh entry while still starting warm.
4. Hermes compilation. The release bundle step compiles the JavaScript bundle to Hermes bytecode with hermesc, which is CPU-bound and runs after Metro finishes. React Native ships prebuilt hermes-engine artifacts for released versions, so a normal iOS build downloads the engine rather than compiling it. A project pinned to a nightly, or one patching React Native from source, can fall back to building the engine itself, which is the longest single step in a cold iOS job. Check the build log for a Hermes source build before blaming Xcode.
When a pipeline is still slow after these four, measure rather than guess. WarpBuild's CI observability correlates OpenTelemetry system metrics from the runner agent with GitHub Actions job logs, which shows whether a job is CPU-bound, memory-bound, or waiting on the network. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to inspect a Pods directory, a Gradle daemon, or /dev/kvm permissions on the live machine.
Proof
The migration cost is the label. The Linux and macOS images carry the same tooling as their GitHub-hosted counterparts, so Gradle, the Android SDK, xcodebuild, pod, Node, and every marketplace action behave the same way. Emulator jobs need the nested virtualization label described above; everything else moves as written.
FAQ
Which runner labels should a React Native pipeline use?
Put the Android job on warp-ubuntu-latest-x64-8x (8 vCPU, 32GB, $0.016 per minute), the iOS job on warp-macos-15-arm64-6x (6 vCPU, 22GB, $0.08 per minute), and JavaScript lint and Jest on warp-ubuntu-latest-x64-4x (4 vCPU, 16GB, $0.008 per minute).
Can the Android and iOS builds share one runner?
No. xcodebuild, CocoaPods, and the iOS simulator runtimes exist only on macOS, and macOS runners cannot run Docker. Keep the two platforms in separate jobs so each one sits on the right label and keeps its own cache keys.
How do I run Android instrumentation tests with an emulator?
Append nested-virtualization.enabled=true to an x86-64 label, for example warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=true, and add the KVM udev permissions step before the emulator starts. Nested virtualization is Linux x86-64 only on cloud runners.
What does a React Native pipeline cost on WarpBuild runners?
A pipeline of one 4x JavaScript job, one 8x Android job, and one macOS 6x iOS job bills at $0.008, $0.016, and $0.08 per minute respectively.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.