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 userun:
if there’s a script command you want to run instead (e.g.yarn test:coverage
kicks off our Jest tests as defined in ourpackage.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
If this helped you in anyway, I’d love to hear about it. If you have any suggestions on how I might improve this post, please do also let me know. I'm at https://bionicjulia.com, Twitter and Instagram.
Top comments (0)