A bit of backstory when I first started developing React Native apps, I found there weren’t any good example of Gitlab CI files. So in this article, I will show you an example .gitlab-ci.yml file you can use with your React Native app. You can of course tweak and makes changes as required by your project.

CI/CD

Before we dive straight into the CI file itself, let’s do a quicker refresher on some basic concepts. Feel free to skip this section if you are already familiar with CI/CD, Git and Gitlab CI.

Continuous Integration (CI), is typically defined as making sure all code being integrated into a codebase works. It usually involves running a set of jobs referred to as a CI pipeline. Some jobs we may run include linting our code and running unit tests. This is usually done automatically using a tool such as Travis, Circle or even Gitlab.

One particularly useful use case for this is when others are adding new features to our codebase and we want to check it still works. We can create a CI pipeline that will run unit tests against the new code automatically when a pull request (GitHub) or merge request (Gitlab) is opened. This saves us a lot of time, rather than having to copy the new features/code and then run the tests our selves on our machine.

Continuous Delivery (CD), is typically an extension of CI to make sure that you can release new changes quickly. This means automating your release process, such that you can deploy your application at any point of time just by clicking on a button.

Continuous Deployment takes CD one step further by requiring no human intervention in deploying our application. You can read more about this here.

Git

Git is a version control system (VCS), it is heavily tied in with CI. In git, we can make “commits” which are snapshots of our project at its current state. We can later revert back to older commits and compare files between commits (and much more). Usually every commit we push to Gitlab triggers a CI pipeline run against that current commit. Git also has this concept of branches, where usually the master branch contains our production-ready code and the other branches have new features being worked on. When our feature branches are ready they are merged into the master branch. Usually, the CI pipeline needs to be successfully running (green ticks) before this can happen, however.

Gitlab CI

Gitlab CI, is defined as a YAML file. In the file, we define “jobs” which can do various different task. You can read more here. Full reference docs here, which details all the different parameters we can use. To use Gitlab CI within our projects is very straight forward, create a new file .gitlab-ci.yml in our project root and then define our jobs (we will see this a bit later in the article).

Example

https://docs.gitlab.com/ee/ci/introduction/

The image above shows an example of a workflow we may use. So we create a new branch for our feature called feature/add-x. We then create our commits (with our new code) and push them to Gitlab. Open a merge request, this triggers the CI pipeline (from the .gitlab-ci.yml) file. In this example, the pipeline fails, perhaps because a unit test failed. This causes the whole pipeline to fail.

We then fix our code so the unit tests pass and then create more commits and push them. This then triggers the pipeline to run again, this time it passes. Now our code is ready to be reviewed and merged into the main branch. After the code review, it will be merged onto the master (main) branch. Then we will trigger the deployment process, this can also be defined within our CI file.

.gitlab-ci.yml

Now onto the real meat and potatoes of this article, our .gitlab-ci.yml file for React Native apps. Taking a look at an example application. You can find the .gitlab-ci.yml and package.json in the appendix below or follow the link above. Now let’s take a look the .gitlab-ci.yml file.

setup

image: node:8

stages:
  - pre
  - test
  - publish
  - post

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - node_modules/

variables:
  DOCKER_DRIVER: overlay2

before_script:
  - yarn generate-dotenv
  - yarn

First, we specify an image, this is the default docker image we will use for our “jobs”. Unless a job specifies an image explicitly in its definition it will use this one. In this example, we will use node 8 it already has node, npm and yarn installed. We could probably upgrade this to node 10 or even node 12 (long term releases of node).

Next, we define all the stages of our pipeline, any jobs in the same stage will run in parallel (at the same time). If a job in an earlier stage fails the pipeline won’t carry on to the next and will stop running at the current stage. The stages defined first such as pre and test run before stages defined later such as publish. Each job must be given a stage.

Next, we define a cache, we will cache the node_modules for future jobs (in this pipeline). Gitlab CI injects some predefined environment variables, one of them being CI_COMMIT_REF_SLUG.

We then define a variable DOCKER_DRIVER: overlay2, this helps speed our docker containers a bit because by default it uses vfs which is slower learn more here.

Finally, we define before_script which will run before every job unless we specify a before_script within the jobs themselves. In this example, we install our node_modules using yarn and create a .env file, we need the .env file for a few our the jobs. The .env file is used by React Native to set configuration within the app.

{
  "name": "stegappasaurus",
  "scripts": {
    "generate-dotenv": "sh util/generate-dotenv.sh > .env"
  }
}

Where BUGSNAG_API_KEY and CAT_API_KEY are environment variables which are injected by Gitlab more information here.

#!/usr/bin/env bash

cat  << EOF
BUGSNAG_API_KEY=${BUGSNAG_API_KEY}
CAT_API_KEY=${CAT_API_KEY}
EOF

Where the generated .env file will look like.

BUGSNAG_API_KEY=1232541
CAT_API_KEY=abxc-71379991

jobs

Note: For the example application I am showing it has two branches production (main) and master.

pre

Now, let’s take a look at our jobs in the CI file. The first job is used to close issues automatically on Gitlab if there is an issue number in the git commit. It uses the following tool gitlab-auto-close-issue. Which provides a docker image which contains the script to auto-close your issues. It will also remove labels from the issue if you want such as “Doing”. This job is only run on the master branch of our project.

Since we don’t need to install any dependencies to run the job before_script: [] is an empty list, therefore the default before_script defined above won’t run in this job. Also since we define a docker image within the job we don’t use the default docker image node:8.

close:issue:
  image: registry.gitlab.com/gitlab-automation-toolkit/gitlab-auto-close-issue
  stage: pre
  before_script: []
  only:
    - master
  script:
    - apk add --no-cache --upgrade grep
    - ISSUE=$(echo $CI_COMMIT_MESSAGE | grep -oP "(?<=Fixes \#)[0-9]+" || echo '1')
    - gitlab_auto_close_issue --issue $ISSUE --remove-label "Doing" --remove-label "To Do"

The next job automatically creates a merge request (MR) if the commits are not being pushed to master or production branches. It will create an MR as WIP with a template we defined in the .gitlab folder. We also set the option --use-issue-name where if we have a branch called say feature/#211 where #211 is an issue number (for that project). It will take certain bits of information from that issue and set it on the MR such as labels. More information about the tool gitlab-auto-mr.

create:merge-request:
  image: registry.gitlab.com/gitlab-automation-toolkit/gitlab-auto-mr
  stage: pre
  before_script: []
  except:
    - production
    - master
    - tags
  script:
    - gitlab_auto_mr -t master -c WIP -d .gitlab/merge_request_templates/merge_request.md -r -s --use-issue-name

Where the template could look something like this.

# Description

<!-- please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

## Type

- [ ] Bug Fix
- [ ] Improvement
- [ ] New Feature

Fixes #<!-- Issue Number -->

test

This job called lint only runs on MRs not on the master branch i.e. it won’t run if create an MR from master to production. Hence the except clause. Finally we run the lint command which is defined in our package.json file as eslint src/**/*.{ts,tx,tsx}. This will run eslint against all of the code within our src folder.

lint:
  stage: test
  only:
    - merge_requests
  except:
    variables:
      - $CI_COMMIT_REF_NAME =~ /^master/
  script:
    - yarn lint

Then lint:code-formatter checks our code against prettier and see’s if it’s compliant with the code formatter.

lint:code-formatter:
  stage: test
  only:
    - merge_requests
  except:
    variables:
      - $CI_COMMIT_REF_NAME =~ /^master/
  script:
    - yarn code-formatter-check

Then we check all of our TS is valid, by running tsc --project . --noEmit --pretty --skipLibCheck. To make sure there aren’t any type mismatches.

lint:types:
  stage: test
  only:
    - merge_requests
  except:
    variables:
      - $CI_COMMIT_REF_NAME =~ /^master/
  script:
    - yarn types-check

We run our unit tests using jest (our test runner). We also use the --silent flag to hide various warnings raised by components we are testing. Like all the other jobs in this stage we only run this job in an MR.

tests:unit:
  stage: test
  only:
    - merge_requests
  except:
    variables:
      - $CI_COMMIT_REF_NAME =~ /^master/
  script:
    - yarn tests --silent

Finally, almost the same as the job above, except it only runs on the master branch it gets the code coverage from unit tests and stores the result using coverage (with some Regex). Where the coverage script is defined as jest --coverage in package.json. More information here. The code coverage can be shown on a badge, such as here.

tests:coverage:
  stage: test
  only:
    - master
  script:
    - yarn coverage --silent
  coverage: /All\sfiles.*?\s+(\d+.\d+)/

publish

Then on to our next stage. The job below actually publishes our app to the Play Store. It will only run when we’ve tagged one of our commits for release i.e. release/1.0.0. This will only be done on the production branch. We are also using another docker image which has Android and various our dependencies need for our React Native app.

I won’t do a massive deep dive into this job because I’ve already written an article about it here. But essentially what happens is we have various variables defined in our project in Gitlab such as our Keystore stored in base64 and the Keystore setting such as the username and password. To use the tool to auto-publish our app I need to have a play-store.json file and because my app uses react-native-firebaseI need agoogle-services.json` file.

I then generate a licenses.json file using the following command npm-license-crawler -onlyDirectDependencies --omitVersion -json src/data/licenses.json, there is a license view within my application which lists all of the main dependencies so I can properly credit those libraries this task generates that file.

I then generate a gradle.propeties file using sh util/generate-gradle-properties.sh > android/gradle.properties. Very similar to the .env the script we looked at above. Where the file looks something like:

#!/usr/bin/env bash

cat  << EOF
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xms1g
MYAPP_RELEASE_STORE_FILE=stegappasaurus.keystore
MYAPP_RELEASE_STORE_PASSWORD=${ANDROID_KEYSTORE_PASSWORD}
MYAPP_RELEASE_KEY_ALIAS=${ANDROID_KEYSTORE_ALIAS}
MYAPP_RELEASE_KEY_PASSWORD=${ANDROID_KEYSTORE_KEY_PASSWORD}
EOF

This means we can reference the variables for the keystore within our build.gradle files without needing to hardcode the values and once again this file is generated from CI variables stored on the project itself. For example the app/build.gradle I have the following defined.

android {
    signingConfigs {
        release {
            if (project.hasProperty("MYAPP_RELEASE_STORE_FILE")) {
                storeFile file(MYAPP_RELEASE_STORE_FILE)
                storePassword MYAPP_RELEASE_STORE_PASSWORD
                keyAlias MYAPP_RELEASE_KEY_ALIAS
                keyPassword MYAPP_RELEASE_KEY_PASSWORD
            }
        }
    }
}

We then publish the application using publish-package script which runs yarn run bundle && bash util/publish-package.sh. Where publish-package.sh looks like

#!/usr/bin/env bash

echo $CI_COMMIT_TAG

if [[ $CI_COMMIT_TAG == *"alpha"* ]]; then
    echo "Publishing Package: Alpha"
    cd android && ./gradlew publish --track alpha
elif [[ $CI_COMMIT_TAG == *"beta"* ]]; then
    echo "Publishing Package: Beta"
    cd android && ./gradlew publish --track beta
elif [[ $CI_COMMIT_TAG == *"release"* ]]; then
    echo "Publishing Package: Production"
    cd android && ./gradlew publish --track production
else
    echo "Publishing Package: Internal"
    cd android && ./gradlew publish --track internal
fi

If the git tag is release/1.0.0 then we will publish this directly onto the production track. It also check if the tag contains alpha or beta if so then we publish it to different tracks.

echo "Publishing Package: Production"
cd android && ./gradlew publish --track production

Finally we make the assets and build folders available as artifacts for jobs in future stages.

publish:android:package:
  stage: publish
  image: reactnativecommunity/react-native-android
  only:
    - /^release/.*$/
  script:
    - echo fs.inotify.max_user_watches=524288 | tee -a /etc/sysctl.conf && sysctl -p
    - cd android
    - base64 -d $ANDROID_KEYSTORE > app/stegappasaurus.keystore
    - export VERSION=$(cat app.json | jq -r .version)
    - cat $PLAY_STORE_JSON > app/play-store.json
    - cat $FIREBASE_GOOGLE_SERVICES_JSON > app/google-services.json
    - yarn generate-licenses
    - yarn generate-gradle-properties
    - yarn publish-package --no-daemon
  artifacts:
    paths:
      - ./android/app/build/
      - ./android/app/src/main/assets/

post

Onto our final stage, the first job creates a Gitlab release. This job is again only run on release tags, but only for “final” release hence the except clause. It won’t run if the git tag contains beta or alpha in its name. The gitlab-auto-release tool much like the other tools above is used to automate this part of the Gitlab workflow.

The script also can use CHANGELOG.md, if it follows keepachangelog format. It takes the changelog from that file and copies into our release. Only for the matching version name i.e. release/1.0.0, would look for 1.0.0 in our changelog file. You can find an example release created by this script here. Also if you specify a job name after the --artifacts argument it will link that jobs artifacts in this release (if it was run in the same pipeline as this job). In this example, we want to include our Android app build (APK/AAB).

create:gitlab:release:
  image: registry.gitlab.com/gitlab-automation-toolkit/gitlab-auto-release
  stage: post
  only:
    - /^release/.*$/
  except:
    variables:
      - $CI_COMMIT_TAG =~ /beta/
      - $CI_COMMIT_TAG =~ /alpha/
  before_script: []
  script:
    - gitlab_auto_release -c CHANGELOG.md -d "This was auto-generated by the gitlab-auto-release tool, https://gitlab.com/gitlab-automation-toolkit/gitlab-auto-release." --artifacts "publish:android:package"

Our final job in this stage again only runs on release tags. It publishes our source maps to Bugsnag. Which is a bug tracking tool. When our app is published to the Play store the JavaScript is minified and so Bugsnag cannot give us a proper stack trace without the source maps. We must “tag” each upload with a version, hence we look in app.json file for the current app version. This job requires artifacts from the previous android publishing job publish:android:package, hence we mark it a dependency in dependencies. We need access to the bundle generated in the assets folder from the previous job. Rather than repeat the same “actions” here to generate the files we need. To speed up our CI we will just “copy” the files into the job by using artifacts.

publish:bugsnag:soucemaps:
  stage: post
  only:
    - /^release/.*$/
  script:
    - apt update && apt install -y jq
    - export VERSION=$(cat app.json | jq -r .version)
    - curl https://upload.bugsnag.com/react-native-source-map -F apiKey=${BUGSNAG_API_KEY} -F appVersion=${VERSION} -F dev=false -F platform=android -F sourceMap=@android/app/src/main/assets/index.map -F bundle=@android/app/src/main/assets/index.bundle -F projectRoot=`pwd`
    - yarn run bugsnag-sourcemaps upload --api-key=${BUGSNAG_API_KEY} --app-version=${VERSION} --minifiedFile=android/app/build/generated/assets/react/release/index.android.bundle --source-map=android/app/build/generated/sourcemaps/react/release/index.android.bundle.map --minified-url=index.android.bundle --upload-sources
  dependencies:
    - publish:android:package

other

Finally, we have a Gitlab defined job called pages, where using Gitlab pages we will publish documentation for this application. It will publish a static website present in the public. The documentation is built using docz. By default, you can access pages at https://hmajid2301.gitlab.io/stegappasaurus, i.e. username.gitlab.io/project_name but I have a google domain and using a CNAME you can also view the website on https://stegappasaurus.haseebmajid.dev/.

Since this is a special job this job is run at the very end of our pipeline, also this job on runs on the master branch.

pages:
  only:
    - master
  before_script:
    - yarn
  script:
    - yarn docs-build
    - mv .docz/dist/* public/
  artifacts:
    paths:
      - public

Finally it’s done! That’s it! That is one example of a .gitlab-ci.yml file you can use to for your React Native projects.

Appendix