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
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 a
google-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.