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:
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
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: CIon:# runs on pushes to the main branchpush:branches: [main]# also runs inside pull requests that target the main branchpull_request:branches: [main]jobs:build:# uses a Ubuntu Docker image (like a virtual machine)runs-on: ubuntu-lateststeps:- uses: actions/checkout@v2- uses: actions/setup-node@v2with: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 runuses: cypress-io/github-action@v2with: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:
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.
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.
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: