How to Cache Your React Native Dependencies in GitHub Actions

Caching allows for faster builds by reusing downloaded packages. With a little bit of trickery, we can cache our dependencies in GitHub Actions

Aryella Lacerda
Better Programming

--

Unsplash image by Lucian Alexe

Introduction

You’re building your React Native app on a CI/CD provider for the first time. One of the first things you notice is that it takes a while. It takes longer than on your local machine, especially if you use GitHub-hosted runners.

Part of the reason the build is quicker on your machine is because the dependencies you need are cached between builds. Caching dependencies allows for faster builds by reusing downloaded packages instead of fetching them again.

With some trickery, we can achieve the same effect on GitHub Actions.

What to Cache

Modern React Native apps have dependencies of three types:

NodeJS

These are the third-party packages you’re most used to installing. Some of these packages are pure JavaScript/TypeScript. Some of them include React hooks and components. Some of them also include native iOS and Android code, which we’ll talk more about later.

  • Dependency manager: yarn or npm or pnpm
  • Dependency installation location: node_modules
  • Dependency list: package.json
  • Dependency lock file: yarn.lock or package-lock.json

Ruby

At least one Ruby gem familiar to most React Native developers: Cocoapods. Cocoapods is the package manager we use to handle our iOS dependencies. Another popular gem is Fastlane, which facilitates app deployment.

Developers used to install Cocoapods globally on their machines. This meant they had to keep their version synchronized with their teammates’. If not, this might have led to inconsistent dependency resolution among teammates.

React Native starter projects now come with a Gemfile in the project’s root. The only dependency currently listed is Cocoapods, which you can install locally using Bundler. No more manually synchronizing global gem versions.

  • Dependency manager: Bundler
  • Dependency installation location: vendor/bundler
  • Dependency list: Gemfile
  • Dependency lock file: Gemfile.lock

Swift and Objective-C

In an inception-like turn of events, you use Bundler to install Cocoapods to install Swift/Obj-C dependencies for iOS. They usually get shipped alongside your NodeJS packages. Whenever a package tells you to run pod install during installation, which means it ships native code.

  • Dependency manager: Cocoapods
  • Dependency installation location: ios/Pods
  • Dependency list: ios/Podfile
  • Dependency lock file: ios/Podfile.lock

How to Cache

First, we will create a custom action to create and read caches during installation.

Create a custom action

Inside your .github folder, create an actions/install-dependencies/action.yml file. GitHub will understand that this is a custom action. Let's start by giving it a name and a description:

# .github/actions/install-dependencies/action.yml

name: Install dependencies
description: Install React Native dependencies (NodeJS packages/Gems/Cocoapods) with caching for each.

runs:
using: 'composite'
steps:
- ...

Set up the environment

Starter projects now come with .node-verion and .ruby-version files. We’ll use them to set up Node and Ruby:

...
steps:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version-file: '.node-version'

# Detects .ruby-version automatically
- name: Setup Ruby
uses: ruby/setup-ruby@v1

Cache Ruby gems

The setup-ruby action will do several things in quick succession. First, it’ll install the correct Ruby version. Then, it’ll install your gems using Bundler. And then it’ll cache the gems if you request it.

The setup-node step caches versions of Node. We can ask to cache global packages as well. Either way, this step does not cache the node_modules folder.

...
steps:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version-file: '.node-version'
cache: 'yarn' # or 'npm' and 'pnpm'

# Detects .ruby-version automatically
- name: Setup Ruby + Cache Gems
uses: ruby/setup-ruby@v1
with:
# Manually caching gems isn't trivial and the recommendation is always
# to rely on this action's bundler-cache option.
bundler-cache: true

Cache NodeJS dependencies

Now, we want to cache our NodeJS dependencies. We need two steps to do that. First, a step that tells GitHub Actions that we want to pull the node_modules from cache. The action's documentation explains it well:

If the provided key matches an existing cache, a new cache is not created, and if the provided key doesn't match an existing cache, a new cache is automatically created, provided the job completes successfully.

Secondly, we install the node_modules conditionally. If a cache exists, we can go ahead and skip this step:

...
steps:
- name: Setup Node
...

- name: Cache node_modules
id: modules-cache
uses: actions/cache@v3
with:
path: ./node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
# key: ${{ runner.os }}-modules-${{ hashFiles('package-lock.json') }}

- name: Install node_modules if necessary
if: steps.modules-cache.outputs.cache-hit != 'true'
shell: bash
run: yarn install --frozen-lockfile

- name: Setup Ruby + Cache Gems
...

Cache Cocoapods

The same logic applies to our last and most time-consuming step: installing our pods. We create the cache in one step and then conditionally install our pods in the next one:

...
steps:
- name: Setup Node
...

- name: Cache node_modules
...

- name: Install node_modules if necessary
...

- name: Setup Ruby + Cache Gems
...

- name: Cache Pods
if: runner.os == 'macOS'
uses: actions/cache@v3
id: pods-cache
with:
path: ./ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}

- name: Install pods
if: runner.os == 'macOS'
shell: bash
run: cd ios && bundle exec pod install && cd ..

Here’s what the finished product should look like:

name: Install dependencies
description: Install React Native dependencies (NodeJS packages/Gems/Cocoapods) with caching for each.

runs:
using: 'composite'
steps:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version-file: '.node-version'
cache: 'yarn'

- name: Cache node_modules
id: modules-cache
uses: actions/cache@v3
with:
path: ./node_modules
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}

- name: Install node_modules
if: steps.modules-cache.outputs.cache-hit != 'true'
shell: bash
run: yarn install --frozen-lockfile

# Manually caching gems isn't trivial and the recommendation is always
# to rely on this action's bundler-cache options.
- name: Setup Ruby + Cache Gems
uses: ruby/setup-ruby@v1
with:
# Detects .ruby-version automatically
bundler-cache: true

- name: Cache Pods
if: runner.os == 'macOS'
uses: actions/cache@v3
id: pods-cache
with:
path: ./ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}

- name: Install pods
if: runner.os == 'macOS'
shell: bash
run: cd ios && bundle exec pod install && cd ..

Though this action can create and read caches, we actually need to perform these operations at different times.

Creating the Cache

The most important thing to know about creating your cache is:

Workflow runs can restore caches created in either the current branch or the default branch (usually main). If a workflow run is triggered for a pull request, it can also restore caches created in the base branch, including base branches of forked repositories. […] Workflow runs cannot restore caches created for child branches or sibling branches.

In short: it’s best to create caches on the default branch so all other branches can access them. So, let’s create a workflow that runs only on main/master. It’ll create a new cache whenever a dependency change occurs.

In GitHub Actions, there's no such thing as "updating" a cache. You create a new one when necessary, and the old one eventually gets deleted to make space for more recent ones.

Create on-dependency-update workflow

Inside your .github folder, create a workflows/on-dependency-update.yml file. GitHub will understand that this is a new workflow. It's pretty simple after that, as you can see below:

name: On Dependency Update
description: Triggers an update to the app's caches when a dependency list changes.

on:
workflow_dispatch:
push:
branches:
# Run only on the default branch
- "main" # or "master"
paths:
# Run only if one of these files have changed
- "yarn.lock"
- "ios/Podfile.lock"
- "Gemfile.lock"

jobs:
rebuild-android-cache:
name: Rebuild Android Cache
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Rebuild cache
uses: ./.github/actions/install-dependencies

rebuild-ios-cache:
name: Rebuild iOS Cache
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Rebuild cache
uses: ./.github/actions/install-dependencies

Reading the Cache

Now, let’s read the cache. You can call the same action in your build/deploy workflow instead of the usual yarn install and bundle install and bundle exec pod install:

name: Deployment
description: Build and deploy the app stores.

jobs:
deploy:
steps:
- name: Checkout selected branch
uses: actions/checkout@v3

- name: Install dependencies
uses: ./.github/actions/install-dependencies

# Build and deploy to App Store and Play Store
# ...

There you have it! If you'd like some more GitHub Actions tips:

References

--

--

Software Developer | Accessibility Enthusiast | 5+ years working with React Native