React Native CI/CD that only builds the platform that changed
Our 2020 React Native CI/CD on GitHub Actions: path filters per platform, versions from git tags, and fastlane match for shared signing.
From 2020 · 2 min read · Published September 8, 2026
In late 2020 I set up a template that we reused for a couple of years: a monorepo with a NestJS GraphQL API, a React Native app that also shipped to web, and a React Native CI/CD setup with one GitHub Actions workflow per platform. The workflows were the part that paid off the most, so this is about them.
The problem they solved is simple. Mobile builds are slow and expensive. A macOS runner building an iOS app is the priciest minute in your CI bill, and most pull requests don't touch anything that requires one.
Path filters per platform
Each platform got its own workflow, triggered only by the files that could actually change its output:
name: App Distribution (ios)
on:
pull_request:
paths:
- '**/ui/app/**'
- '**/ui/ios/**'
release:
types: [published]An API-only change builds no apps. A change to Android native code builds Android and not iOS. Shared app code builds both. It sounds obvious written down, but the default in most templates is "build everything on every push," and teams live with it for years.
The first step in each job also cancels older runs of the same workflow on the same branch. If you push three commits in a row, you only pay for the last one.
Versions come from tags
Nobody edited version numbers by hand. A small script read the previous git tag, parsed it as semver, and bumped it based on the release type:
set -euo pipefail
possible_release_types="major feature bug alpha beta rc"
regex="^([0-9]+).([0-9]+).([0-9]+)((-[a-z]+)([0-9]+))?$"
if [[ $prev_version =~ $regex ]]; then
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch="${BASH_REMATCH[3]}"
else
echo "previous version '$prev_version' is not a semantic version"
exit 1
fiThe important line is the exit 1. If the tag history is broken, the build fails loudly instead of shipping a binary with a nonsense version that the App Store will reject an hour later.
Signing is shared with fastlane match, not personal
iOS code signing is where mobile CI usually falls apart: certificates live in one developer's keychain, and when that person is on vacation, nobody can ship. We used fastlane match, which stores encrypted certificates and profiles in a private git repo. CI decrypts them with a secret, the same way any developer can, so there's one source of truth.
Builds went out through Firebase App Distribution for testers on every qualifying PR, and to the stores on a published release.
Caches, in the right order
The workflow cached three things separately: Ruby gems for fastlane, the Yarn cache plus node_modules, and CocoaPods keyed on Podfile.lock. yarn install and pod install only ran on a cache miss. That alone took several minutes off a typical iOS build.
A lot has changed since then. EAS Build handles much of this for Expo apps now, and it's what we reach for first. But the principles carry over to any setup: build only what changed, derive versions from history, and never let signing depend on one person.
Related: Hash the native layer: skipping React Native builds before Expo Fingerprint · Fastlane for iOS: half my commits were build numbers