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.

6 minutes
Hero image

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 default npm 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 as cspell-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.

call to action background image

Subscribe to my newsletter

Receive once a month news from the areas of software development and communication peppered with book and link recommendations.