Avatar

Salut, I'm Julia.

Github Actions Workflow For a React Native App

#devtools #react-native

13 min read

We had some downtime recently in the team, where I had just finished delivering a big feature and was in between builds. It was therefore a prime opportunity to tackle some technical debt, and one of the key things on our list was moving our Continuous Integration (CI) workflow to Github Actions.

There were various reasons we wanted to move across to GH Actions, but a big one for us was the potential to cut costs, since our repo is public and open source (which qualifies us for free GH Actions minutes). It’s also nice to be able to keep our processes tight, limiting the number of third party tools our developers have to interact with, and reducing complexity and onboarding where possible (since we already use Github as a team).

Writing CI config YAML files was not something I’ve had experience with in the past, so I volunteered to kick this off for the team. I’ll be honest - it was a pretty painful experience... but I’m really glad that I stuck with it and got our workflows working, as it’s given me a much better understanding of how our React Native app is built and deployed to our beta testers and app stores. The pain comes from having to deploy config changes and wait for the CI process to go through the various steps, only to watch it fail, so you can then make the change that you think will fix it, to then push and deploy again...wait for the CI process to restart again from scratch, only for it to fail at the next step, so you make the fix, that you then push and deploy again... etc etc. You get the point. It’s a slow and repetitive process, with a lot of trial and error (at least for me), as this was my first time doing this.

Looking at the bigger picture, I think Github Actions is a great tool and would highly recommend all developers give it a try if they already push code to Github. It’s pretty easy to get a basic workflow integrated with your git environment, and worth seeing if GH Actions can help improve your developer experience by automating certain tasks and improving your checks and processes when deploying production code.

I started with a simple workflow whereby I wanted all feature branches pushed to Github to automatically kick off the following tasks:

  • Linting the code;
  • Checking that all translations were complete; and
  • Running Jest tests.

Here’s what my workflow looks like.

name: Feature branch checks
#
# Branch (Ignoring) [main]
# - Build app assets
# |-- Lint project
# |-- Check i18n string
# |-- Run Tests
#

on:
  push:
    branches:
      - '**'
      - '!main'

jobs:
  setup:
    name: Setup code and environment needed for linting, translations and tests
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Check out Git repository
        uses: actions/checkout@v3
      - name: Cache private assets
        uses: actions/cache@v3
        id: cache-private-assets
        with:
          path: |
            .private-assets
            assets/fonts
          key: private-assets
      - uses: webfactory/ssh-agent@v0.5.4
        with:
          ssh-private-key: ${{ secrets.CSS_ASSETS_SSH_PRIVATE_KEY }}
      - name: Install private assets if cache not present
        shell: bash
        run: .github/install_private_assets.sh
        if: steps.cache-private-assets.outputs.cache-hit != 'true'
      - name: Set up Node environment
        uses: actions/setup-node@v2
      - name: Cache node modules
        uses: actions/cache@v3
        id: cache-node-modules
        with:
          path: node_modules
          key: yarn-${{ hashFiles('yarn.lock') }}
      - name: Install node modules if cache not present
        run: yarn install --immutable
        if: steps.cache-node-modules.outputs.cache-hit != 'true'

  lint:
    runs-on: ubuntu-latest
    needs: setup
    timeout-minutes: 5
    steps:
      - name: Check out Git repository
        uses: actions/checkout@v3
      - name: Restore private assets from cache
        uses: actions/cache@v3
        with:
          path: |
            .private-assets
            assets/fonts
          key: private-assets
      - name: Restore node modules from cache
        uses: actions/cache@v3
        with:
          path: node_modules
          key: yarn-${{ hashFiles('yarn.lock') }}
      - run: mkdir -p ~/reports
      - name: Lint
        run: yarn lint:ci
      - name: Prettier
        run: yarn prettier
      - name: Upload linting report
        uses: actions/upload-artifact@v3
        with:
          name: lint-report
          path: ~/reports/eslint.xml

  i18n:
    runs-on: ubuntu-latest
    needs: setup
    timeout-minutes: 5
    steps:
      - name: Check out Git repository
        uses: actions/checkout@v3
      - name: Restore private assets from cache
        uses: actions/cache@v3
        with:
          path: |
            .private-assets
            assets/fonts
          key: private-assets
      - name: Restore node modules from cache
        uses: actions/cache@v3
        with:
          path: node_modules
          key: yarn-${{ hashFiles('yarn.lock') }}
      - name: Check Swedish translations
        run: yarn test:i18n 'sv-SE'

  test:
    runs-on: macos-latest
    needs: setup
    timeout-minutes: 20
    steps:
      - name: Check out Git repository
        uses: actions/checkout@v3
      - name: Restore private assets from cache
        uses: actions/cache@v3
        with:
          path: |
            .private-assets
            assets/fonts
          key: private-assets
      - name: Restore node modules from cache
        uses: actions/cache@v3
        with:
          path: node_modules
          key: yarn-${{ hashFiles('yarn.lock') }}
      - name: Run tests with JUnit as reporter
        run: yarn test:coverage
        env:
          JEST_JUNIT_OUTPUT_DIR: ./reports/junit/
      - name: Upload Jest JUnit test results
        uses: actions/upload-artifact@v3
        with:
          name: jest-test-results
          path: ./reports/junit/junit.xml

If you’re interested in reading more about GH Action’s workflows, check this out. To get this integrated into your Github workflow, the only thing you have to do is create a new directory in the root of your repo called .github. In this directory, create a folder called workflows and save the above file in there (calling it whatever you like). You can then go to your Github repo settings and enable Github Actions...and that’s it.

Defining when the workflow runs

This specific workflow will automatically run whenever we push a branch to Github that is NOT called main. This is set with this particular configuration:

on:
  push:
    branches:
      - '**'
      - '!main'

Defining the jobs

The workflow then goes on to say that we want to run 3 jobs simultaneously (lint, i18n and test), all 3 of which requires an initial job called setup to run. You’ll need to define an operating system runner for each of your jobs e.g. ubuntu or macOS (if you can, run your jobs on ubuntu as the minutes on Github are cheaper!). You can also define specific versions to use, but in my case, I just used the latest versions.

You can then optionally define how long the job should run for before it times out. It’s probably a good idea to include this in case something goes wrong, so you don’t end up wasting minutes on tasks which might (for instance) end up in some kind of erroneous infinite loop.

Defining the steps within a job

Going back to the setup job defined above, this does a checkout of our repo’s code, installs the necessary dependencies and caches it for subsequent jobs. In my case, we had some private assets and fonts our app needed access to as part of the build process, which in turn required SSH access to a private Github repo.

One of the nice things about GH Actions is the prebuilt actions that other community members have coded up, which more likely that not, will cover whatever you’re looking to do. Here’s the marketplace if you want to have a browse. As an example, I used this community action webfactory/ssh-agent@v0.5.4, to help me SSH with a private key, into our private repo. I then ran a custom script called install_private_assets.sh to install what I needed. I could’ve coded up the steps manually myself, but this saved me some hassle and made our workflow file easy to read and follow. Just a note here that you’ll likely want to delve into the details of a community-defined action before using it, just to verify that it’s not doing anything nefarious under the hood (like mining bitcoin!). 😝

The safer bet would be to use official actions provided by Github, where possible, which can be easily identified as their names start with actions/, e.g. actions/cache@v3 (which is an action that helps us with caching logic). Each action will come with instructions on what parameters you need to pass in to get it to work.

- name: Cache private assets
  uses: actions/cache@v3
  id: cache-private-assets
  with:
    path: |
      .private-assets
      assets/fonts
    key: private-assets
- uses: webfactory/ssh-agent@v0.5.4
  with:
    ssh-private-key: ${{ secrets.CSS_ASSETS_SSH_PRIVATE_KEY }}
- name: Install private assets if cache not present
  shell: bash
  run: .github/install_private_assets.sh
  if: steps.cache-private-assets.outputs.cache-hit != 'true'

To enable the workflow to be read intuitively from top to bottom, I named the various steps in each job with a descriptive name, using name:. In essence, the pattern for each step in a job goes like this:

  • Define the name of the step (optional).
  • Define the specific Github / community defined action you need with uses:. Alternatively, you can use run: if there’s a script command you want to run instead (e.g. yarn test:coverage kicks off our Jest tests as defined in our package.json).
  • Pass in the configuration or params for the action, if any, using with:
steps:
  - name: Check out Git repository
    uses: actions/checkout@v3
  - name: Restore private assets from cache
    uses: actions/cache@v3
    with:
      path: |
        .private-assets
        assets/fonts
      key: private-assets

If you need to pass in multiple arguments, use the | e.g.

path: |
  .private-assets
  assets/fonts

Workflow for building and deploying a React Native production app

If you’ve reached this point...thanks for sticking with me! As a bonus below, I’ve added another workflow I put together. This is the one that’s kicked off whenever anyone in the team deploys code to our production branch (main), which allows for a release via OTA, or a build and deploy to the Apple and Google app stores with Fastlane.

You might have noticed the reference to a secrets object in my various workflows e.g.

- uses: webfactory/ssh-agent@v0.5.4
  with:
    ssh-private-key: ${{ secrets.CSS_ASSETS_SSH_PRIVATE_KEY }}

These are essentially environment variables you can set in the Github user interface, within the Settings page of your repo. In the example below, you’ll note that ${{ secrets.CSS_ASSETS_SSH_PRIVATE_KEY }} is called from within the deploy-prod-ota job that runs in environment: production-ota. This requires that I have an environment setup in Github called production-ota, within which I can store my environment secrets. Alternatively, if you’ll be sharing secrets across environments, you can also store these secrets at a repository level.

name: Production deployment via main branch

on:
  push:
    branches:
      - 'main'

jobs:
  deploy-prod-ota:
    name: Deploy production OTA
    runs-on: macos-latest
    environment: production-ota
    timeout-minutes: 15
    steps:
      - name: Check out Git repository
        uses: actions/checkout@v3
      - name: Cache private assets
        uses: actions/cache@v3
        id: cache-private-assets
        with:
          path: |
            .private-assets
            assets/fonts
          key: private-assets
      - uses: webfactory/ssh-agent@v0.5.4
        with:
          ssh-private-key: ${{ secrets.CSS_ASSETS_SSH_PRIVATE_KEY }}
      - name: Install private assets if cache not present
        shell: bash
        run: .github/install_private_assets.sh
        if: steps.cache-private-assets.outputs.cache-hit != 'true'
      - name: Cache gems
        id: cache-gems
        uses: actions/cache@v3
        with:
          path: vendor/bundle
          key: gems-${{ hashFiles('**/Gemfile.lock') }}
      - name: Install bundler if cache not present
        run: gem install bundler
        if: steps.cache-gems.outputs.cache-hit != 'true'
      - name: Install gems if cache not present
        run: |
          bundle config path vendor/bundle clean 'true'
          bundle check || bundle install
        env:
          BUNDLE_JOBS: '4'
          BUNDLE_RETRY: '3'
        if: steps.cache-gems.outputs.cache-hit != 'true'
      - name: Set up Node environment
        uses: actions/setup-node@v2
      - name: Cache node modules
        uses: actions/cache@v3
        id: cache-node-modules
        with:
          path: node_modules
          key: yarn-${{ hashFiles('yarn.lock') }}
      - name: Install node modules if cache not present
        run: yarn install --immutable
        if: steps.cache-node-modules.outputs.cache-hit != 'true'
      - name: Prepare deploy environment
        run: |
          bundle update fastlane
          bundle exec fastlane update_plugins
          bash .github/make_deploy_env.sh
          yarn generate:git-hash
        env:
          RELEASE_TYPE: ${{ secrets.RELEASE_TYPE }}
      - run: echo "RELEASE_CHANNEL=$(grep EXPO_RELEASE_CHANNEL .env | cut -d '=' -f2)" >> $GITHUB_ENV
      - name: Expo OTA login
        run: npx expo-cli login -u ${{ secrets.EXPO_USERNAME }} -p ${{ secrets.EXPO_PASSWORD }}
      - name: Expo OTA publish
        run: npx expo-cli publish --non-interactive --max-workers 1 --release-channel ${{ env.RELEASE_CHANNEL }} --target bare

  #
  # Publish app to store
  # - App / Store release through AppCenter (with approval)
  # |-- Step 1: Setup production app build (approval needed)
  #   |-- Step 2a: iOS Release to app store (through AppCenter)
  #   |-- Step 2b: Android Release to play store (through AppCenter)
  #

  setup-prod-release:
    name: Setup production app build
    runs-on: macos-latest
    environment: production-build-setup
    timeout-minutes: 15
    steps:
      - name: Check out Git repository
        uses: actions/checkout@v3
      - name: Cache private assets
        uses: actions/cache@v3
        id: cache-private-assets
        with:
          path: |
            .private-assets
            assets/fonts
          key: private-assets
      - uses: webfactory/ssh-agent@v0.5.4
        with:
          ssh-private-key: ${{ secrets.CSS_ASSETS_SSH_PRIVATE_KEY }}
      - name: Install private assets if cache not present
        shell: bash
        run: .github/install_private_assets.sh
        if: steps.cache-private-assets.outputs.cache-hit != 'true'
      - name: Cache gems
        id: cache-gems
        uses: actions/cache@v3
        with:
          path: vendor/bundle
          key: gems-${{ hashFiles('**/Gemfile.lock') }}
      - name: Install bundler if cache not present
        run: gem install bundler
        if: steps.cache-gems.outputs.cache-hit != 'true'
      - name: Install gems if cache not present
        run: |
          bundle config path vendor/bundle clean 'true'
          bundle check || bundle install
        env:
          BUNDLE_JOBS: '4'
          BUNDLE_RETRY: '3'
        if: steps.cache-gems.outputs.cache-hit != 'true'
      - name: Set up Node environment
        uses: actions/setup-node@v2
      - name: Cache node modules
        uses: actions/cache@v3
        id: cache-node-modules
        with:
          path: node_modules
          key: yarn-${{ hashFiles('yarn.lock') }}
      - name: Install node modules if cache not present
        run: yarn install --immutable
        if: steps.cache-node-modules.outputs.cache-hit != 'true'

  prod-ios-release:
    name: Production iOS app build and release
    needs: setup-prod-release
    runs-on: macos-latest
    environment: production-release
    timeout-minutes: 30
    steps:
      - name: Check out Git repository
        uses: actions/checkout@v3
      - name: Restore private assets from cache
        uses: actions/cache@v3
        with:
          path: |
            .private-assets
            assets/fonts
          key: private-assets
      - name: Restore gems from cache
        uses: actions/cache@v3
        with:
          path: vendor/bundle
          key: gems-${{ hashFiles('**/Gemfile.lock') }}
      - name: Restore node modules from cache
        uses: actions/cache@v3
        with:
          path: node_modules
          key: yarn-${{ hashFiles('yarn.lock') }}
      - name: Prepare deploy environment
        run: |
          bundle update fastlane
          bundle exec fastlane update_plugins
      - uses: webfactory/ssh-agent@v0.5.4
        with:
          ssh-private-key: ${{ secrets.IOS_CERTS_SSH_PRIVATE_KEY }}
      - name: Make deploy environment
        shell: bash
        run: .github/make_deploy_env.sh
        env:
          RELEASE_TYPE: ${{ secrets.RELEASE_TYPE }}
          NAME: ${{ secrets.NAME }}
          # ... other env vars
      - run: yarn generate:git-hash
      - name: Pod clean and install
        run: |
          cd ios && pod install --clean-install
      - name: Generate fastlane certificates
        run: yarn fastlane:ios:certificates
      - name: Fastlane ios release
        run: yarn fastlane:ios:release env:$RELEASE_TYPE
        env:
          RELEASE_TYPE: ${{ secrets.RELEASE_TYPE }}
      - name: Upload build
        uses: actions/upload-artifact@v3
        with:
          name: production-ios-build
          path: ./build

  prod-android-release:
    name: Production Android app build and release
    needs: setup-prod-release
    runs-on: macos-latest
    environment: production-release
    timeout-minutes: 30
    steps:
      - name: Check out Git repository
        uses: actions/checkout@v3
      - name: Restore private assets from cache
        uses: actions/cache@v3
        with:
          path: |
            .private-assets
            assets/fonts
          key: private-assets
      - name: Restore gems from cache
        uses: actions/cache@v3
        with:
          path: vendor/bundle
          key: gems-${{ hashFiles('**/Gemfile.lock') }}
      - name: Restore node modules from cache
        uses: actions/cache@v3
        with:
          path: node_modules
          key: yarn-${{ hashFiles('yarn.lock') }}
      - name: Bundle RN Android assets
        run: npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res
      - name: Clean up after bundling
        run: rm -rf ./android/app/src/main/res/drawable-* && rm -rf ./android/app/src/main/res/raw
      - name: Prepare deploy environment
        run: |
          bundle update fastlane
          bundle exec fastlane update_plugins
      - name: Make deploy environment
        shell: bash
        run: .github/make_deploy_env.sh
        env:
          RELEASE_TYPE: ${{ secrets.RELEASE_TYPE }}
          NAME: ${{ secrets.NAME }}
          # ... other env vars
      - run: yarn generate:git-hash
      - run: |
          bundle config path vendor/bundle clean 'true'
          bundle install && cat android/gradle.properties
      - name: Fastlane android release
        run: ENVFILE=.env bundle exec fastlane android release env:$RELEASE_TYPE
        env:
          RELEASE_TYPE: ${{ secrets.RELEASE_TYPE }}
      - name: Upload build
        uses: actions/upload-artifact@v3
        with:
          name: production-android-build
          path: ./android/app/build/outputs

Web Mentions

Tweet about this post and have it show up here!

© 2016-2022 Julia Tan · Powered by Next JS.