Continuous Integration Pipelines with GitHub Actions for (React) Developers

Johannes KettmannPublished on 

If you don’t have experience working with a professional dev team yet, you probably don’t know how collaboration among developers typically works. One of the most important parts is the Git workflow.

A very common workflow among professional teams is Trunk-Based Development. In a nutshell, it works like this:

  1. You work on a separate Git branch and push it to GitHub.
  2. You create a Pull Request where automated scripts check your code and your teammates review it.
  3. You merge the Pull Request to the main branch once it’s approved.

We covered this workflow in detail in a previous article. So if anything is unclear, I’d recommend reading it first (or have a look at my free course where you can learn and practice this workflow).

On this page, we’ll focus on the automated scripts in the second step. This is called the Continuous Integration pipeline and typically runs tools like a linter (e.g. ESLint), code formatter (e.g. Prettier), or tests.

In the video below I walk through the setup of a GitHub repository for a Next.js app that I build for my upcoming React Job Simulator. You can find a summary below the video. In a nutshell, you’ll learn

  • How to set up a Continuous Integration pipeline with GitHub Actions to automatically run ESLint, Prettier, and tests in each Pull Request.
  • How to prevent code from being merged to the main branch if it doesn’t pass the pipeline with branch protection.
  • How to use pre-commit hooks to run checks even before you can create a commit.

Continuous Integration with GitHub Actions

In the previous article, we set up the GitHub repository to use branch protection. This way we can enforce branches can only be merged to the main branch via a Pull Request that is approved by another teammate.

The approval process can be very valuable. But especially code reviews are also time-consuming. You don’t want to waste time complaining about details like code formatting. On top of that, it’s not feasible to test all the code by hand to ensure that the rest of the application still works as expected.

As you know, we have tools to assist us: TypeScript and ESLint to catch bugs, Prettier to format our code, and tests to make sure that our app works.

With the help of a Continuous Integration pipeline, we can run all these tools within our Pull Requests. This way we decrease the effort spent on code reviews and reduce the risk of introducing bugs. And that again helps to merge Pull Requests frequently (which is the whole meaning of Continuous Integration by the way).

There are many tools to build CI pipelines. The simplest option for repositories on GitHub is probably GitHub Actions. It’s as easy as creating a file called .github/workflows/main.yml in your repo.

For my project, the file looks like this:

name: CI
on:
# runs on pushes to the main branch
push:
branches: [main]
# also runs inside pull requests that target the main branch
pull_request:
branches: [main]
jobs:
build:
# uses a Ubuntu Docker image (like a virtual machine)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "14"
cache: "npm"
# install dependencies
- run: npm ci
# run ESLint & Prettier
- run: npm run lint
- run: npm run prettier
# verify that there are no build errors
- run: npm run build
# run tests with Cypress
- name: Cypress run
uses: cypress-io/github-action@v2
with:
start: npm start

The following happens whenever a commit is pushed to the main branch or to a pull request that targets the main branch:

  • A Ubuntu machine is spun up, the code from the repo checked out, and Node.js installed.
  • ESLint and Prettier are run to check for bugs and correct code format.
  • The project is built to verify that there are no TypeScript and build errors.
  • The Cypress tests are run to verify that the app behaves as expected.

In our Pull Request, we now have status checks (one to be precise).

In case something goes wrong and the CI pipeline fails we can inspect the details. You just click on the “Details” link at the very right of the status check.

Here is an example where I committed code that wasn’t formatted correctly. This looks just like the output of a normal terminal.

Status Checks: Prevent merges of Pull Requests that don’t pass the CI pipeline

At this point, we force everyone on the team to use Pull Requests and we have a CI pipeline that automatically checks our code. Unfortunately, a developer can still decide to merge a PR even though it didn’t pass the CI pipeline. Wouldn’t it be awesome if we could prevent that?

That’s where our branch protection rules from the previous article come in again. You can find an option “Require status checks to pass before merging” that we didn’t select before. Once our CI pipeline has run at least one time we can enable it and select the required CI jobs.

You simply edit the existing rules for the main branch, check the option, and select the job from the workflow file (in this case “build”).

Now the merge button inside a Pull Request is disabled whenever the CI pipeline fails.

Pre-commit hooks: validate your code before a commit

Once you start working with CI pipelines you realize that it takes a while to run them. It can be annoying to come back to a Pull Request after a few minutes only to realize that the CI failed because of a dumb ESLint error.

This is where pre-commit hooks come in handy. They allow you to automatically run scripts when you create a commit. If one of the scripts fails the commit is stopped.

Since the goal is to commit frequently I wouldn’t recommend running complex tasks in pre-commit hooks. For example, running a whole test suite on every commit quickly gets annoying. Pre-commit hooks are best suited for fast scripts like npm run lint or npm run prettier. Especially when they only check the staged files and not the whole repository.

The easiest way to set up pre-commit hooks that only run on staged files is by using lint-staged.

npx mrm@2 lint-staged

This will install Husky under the hood and set up some scripts to run before a commit. You can find them in the lint-staged section in your package.json file.

This is the package.json file for my project. I already adapted it to run on JavaScript and TypeScript files.

{
"scripts": { ... },
"dependencies": { ... },
"devDependencies": { ... },
"lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --cache --fix",
"*.{js,jsx,ts,tsx,css,md}": "prettier --write"
}
}

When you try to commit code that contains an ESLint error the pre-commit hook will complain now.

Note that it’s easy to skip the pre-commit hooks by using the git commit --no-verify option. So you can compare our setup to form validation in web apps:

  • The pre-commit hooks are the validation on the frontend. They give quick feedback but you can easily hack them.
  • The CI pipeline is the backend validation: It takes a bit longer to realize that something went wrong but you can’t simply work around them.
You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.