React Native Build Pipeline - with GitHub Actions and EAS
A how-to instruction for an automated build pipeline with code examples, tips and semantic versioning.

Scope
In my current project, I have to build a native iOS app. I decided to use React Native with Expo as my Tech Stack. It allows me to gain momentum immediately due to my experience with TypeScript, React, and Redux stores. Additionally, I can easily create Android apps when needed.
The focus of this article is the automation of the build pipeline. As a code repository, I use GitHub and, therefore, GitHub Actions for the build pipeline. I must distribute the application via my customer's MDM (Mobile Device Management) system. So, I need a versioned build artifact, which I can upload into the MDM.
As a precondition, I set up the iOS certificate and application via Apple developer tools. This can be automated, too, but I want to focus on the build itself.
Objectives
Before automating, I need to define my objectives. So here we go:
- Build a new release for each push on the main branch.
- Use semantic versioning to determine the new app version.
- Generate a versioned IPA file as a build artifact.
Solution
Step 1: Continuous integration
I have a ci.yml
within my repository running on all branches, so quality checks also cover my feature branches.
name: CI
on:
push:
branches:
- "**"
Quality checks
Before building the app, I ran a couple of steps to secure quality:
- Auditing npm packages via
better-npm-audit.
If needed, this wrapper around the defaultnpm audit
allows me to configure ignored vulnerabilities in the.nsprc
file. - Spell checking via
cspell
. Typos, e.g., in variable names, often lead to nasty bugs. I can configure several languages next to English, in case I have, e.g., German text passages in my code. Additionally, I can add a dictionary with known words ascspell-words.txt
. - Linting the code by applying my lint rules, including
prettier,
which are mandatory for every decent project. This prevents discussions about how to format. The standard just gets applied. - TypeScript checking. I use TypeScript to get autocompletion, easier refactoring, and a safety net for compilation time.
- (Unit—) tests to ensure that stuff works as expected. This is part of the test-driven philosophy and helps with refactoring to detect side effects.
jobs:
initial_checks:
name: Verify build and prepare cache
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Read .nvmrc
run: echo "NODE_VERSION=$(cat .nvmrc)" >> $GITHUB_OUTPUT
id: nvm
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ steps.nvm.outputs.NODE_VERSION }}
- name: Cache dependencies
id: cache
uses: actions/cache@v4
with:
path: |
./node_modules
!node_modules/.cache
# The hashFiles function generates a unique key for the cache based on the contents of the package-lock.json file.
# This ensures that the cache is only invalidated when the dependencies of the project change.
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
# 'restore-keys' are fallback keys used if a cache miss occurs on the 'key' field. They provide a way to restore
# a cache from a previous state that doesn't match the 'key' exactly. They are checked sequentially until a cache hit is found.
# A list of restore keys is useful when restoring a cache from another branch because restore keys can partially match cache keys.
restore-keys: |
${{ runner.os }}-node-
${{ runner.os }}-
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: npm ci --ignore-scripts
- name: NPM Audit
run: npm run audit
- name: Run spell check
run: npm run cspell:lint
- name: Run ESLint
run: npm run lint
- name: Run Typescript checks
run: npm run ts
- name: Run Tests
env:
NODE_OPTIONS: "--max_old_space_size=8192"
run: npm run test -- --ci --maxWorkers=2
Tagging
On my main branch, I tag my code with a semantic release approach, as requested in the objectives. For a React Native + Expo application, I need to ensure all relevant files get updated. This includes:
package.json
and its lock file with the latest version.app.json
from expo → It is essential to have the correct version number in the EAS build!
To complete this, I need to list these files in the git commit
& git push
of my semantic version configuration. Therefore, the .releaserc
has to be configured this way:
{
"tagFormat": "${version}",
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/github",
[
"@semantic-release/npm",
{ "npmPublish": false }
],
[
"semantic-release-expo",
{
"manifests": ["app.json"]
}
],
[
"@semantic-release/git",
{
"assets": [
"package.json",
"package-lock.json",
"app.json"
],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
]
]
}
Note: To ensure the semantic release is working, make sure your commit messages apply the syntax rules. This can be done by Git hooks and the appropriate npm package @semantic-release/commit-analyzer.
Step 2: Release
Within my repository, I have a release.yml
running on all newly created tags, so my artifact gets created.
name: Release
on:
push:
tags:
- "**"
release:
types:
- created
I trigger this flow on each tag written by ci.yml.
As part of this flow, I trigger the build on EAS. EAS Build is a hosted service for building app binaries for your Expo and React Native projects.
The GitHub Action will finish once the job is put into the EAS build queue. EAS itself may take several minutes up to one hour to build the application—this time is not added to your GitHub Action build minutes! So, I save a lot of time with automation without increasing my costs.
jobs:
build:
name: Install and build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Read .nvmrc
run: echo "NODE_VERSION=$(cat .nvmrc)" >> $GITHUB_OUTPUT
id: nvm
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: ${{ steps.nvm.outputs.NODE_VERSION }}
cache: npm
- name: Setup Expo and EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
run: npm ci
- name: Build on EAS
run: eas build --platform ios --profile production --non-interactive --no-wait
Conclusion
With the help of GitHub Actions and the Expo tool EAS, reliable and time-saving automation is set up, fulfilling the objectives of building a new semantic versioned release for each push on the main branch with an IPA artifact as output.