Go back

Moving husky pre-commit hook into Github Actions

I've always loved the idea of automating stuff. And recently, I've created GitHub workflows that ran successfully, after hundreds of trials.

In the Frontend repo of Gitsecure, I had already included husky to help us catch Typescript errors before the build script runs and we deploy to Vercel. That sounds awesome, to be honest. And since then, we've been able to catch errors before we even commit them, leading to lesser build errors.

Take a look at the snippet below. It simply runs this — "prebuild-commit": "tsc --noEmit --incremental" — command from our package.json file

.husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
 
yarn prebuild-commit

If you're trying to use Husky for the first time, consider going through their documentation

Drawbacks

For a while, this kept us going. But, there were limitations. One that was quite prevalent was the delay in committing a change. This particular issue affected me a lot because the PC I was using then was slow, and this factor impacted the duration of the checks.

Sometimes, I'd try committing a change, and my PC would just go off because of its poor battery. Normally, committing that change wouldn't take less than 5 seconds. But, since Husky is still running in the background, it takes a very long time.

This even led to a lot of complications for me. I remember a time I accidentally corrupted my git history. I couldn't perform any git operation, till I cleared it and started afresh.

So, now, imagine these types of frustrations when you have a large team with a handful of Engineers.

Enforcing a uniform code style

Although, the prettier extension on vscode does a good job of keeping all files formatted. But, we also have to consider cases where this extension isn't installed on the machine of someone on the team.

To avoid this, from the get-go, a prettier config had been included in the codebase, even before I started maintaining it. So, that's a good thing. To make prettier a part of our pre-commit process, I modified the format command to do what the name implies — format the specified files.

package.json
  "format": "prettier --write .",
  "prebuild-commit": "tsc --noEmit --incremental"

I went ahead and modified the pre-commit file to accommodate the prettier rules. It became this:

.husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
 
yarn prebuild-commit && yarn format

I soon ran into an error. Every time the pre-commit hook runs, prettier formats all the files, excluding those in .prettierignore. Whenever it encounters any file that doesn't match the presets, it makes those changes.

But Git isn't aware of those changes. So we'd end up making a commit and creating new changes. Two things led to this:

  • The execution order of the commands in .husky/pre-commit was wrong in this context. The prebuild script runs before formatting which shouldn't be so. What we want is to format our files, and then check for TS errors.

    .husky/pre-commit
    #!/usr/bin/env sh
    . "$(dirname -- "$0")/_/husky.sh"
     
    yarn format && yarn prebuild-commit
  • Even at that, git still isn't aware of the changes we just made. To fix that, I modified the command like so:

    package.json
      "format": "prettier --write . && git add --all",

To make everything more uniform, I included a .editorconfig file with rules guiding how code is authored in the codebase. Now we're good to go!

Automating this process

As I mentioned earlier. This process can be sometimes slow for other people who might be in the same situation I was in. And to even improve the experience of devs on the team, it is better to move this process into a CI environment.

Fortunately, this project resides on GitHub, which means we have access to Actions. But, before doing that, I knew that Husky works in a local first context, and I had no idea how to go about moving this check into a Action.

But, I read somewhere that GitHub attaches a $CI variable to actions or scripts running in a Continuous Integration environment. With this, I went on to modify the pre-commit file to run based on that condition with the Bourne shell syntax.

if [ -n "$CI" ]; then
  yarn format && yarn prebuild-commit
else
  echo "Skipping pre-commit checks in local environment."
fi

With this, the check will only run in a CI environment, a GitHub Action in our case.

Now, that this is settled. I went ahead to create a workflow in the ./github/workflows/ directory like so with one job including its required steps like so:

./github/workflows/pre-commit.yml
name: Prettier format and TS Error checks
 
on:
  pull_request:
    branches:
      - dev
      - main
 
jobs:
  pre-build-checks:
    runs-on: ubuntu-latest
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
 
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
 
      - name: Install dependencies
        run: yarn
 
      - name: Compile for errors
        run: .husky/pre-commit

The workflow will only be triggered when there's a pull request to the main or dev branches. The important part of the pre-build-checks job is the "Compile for errors" steps. Notice how it runs the script in the .husky directory.

Disabling Vercel's auto-deploy

One thing I noticed after getting the pre-commit workflow to run was that the Vercel build ran without the context of this workflow. So even if, and when the checks pass or fail, the auto-deploy from Vercel still runs, nonetheless.

That's not what I want. So I headed to Vercel's doc and found out that we can disable the auto-deploy by setting a config in the vercel.json file. See what it looks like below:

vercel.json
{
  "git": {
    "deploymentEnabled": false
  }
}

Yes, you can also selectively disable auto-deploy for different branches. More on that here

Managing deployments with a GitHub Action

With that out of the way. I had to figure out how to take control of the deployment process with a workflow since my original plan was to make sure this build was dependent on the completion of the pre-commit checks.

This guide from David Myers was super helpful in setting up a vercel deployment with Actions. You should give it a read.

When I went through the guide, I found out that his examples were not hinged on the completion status of another workflow. His were dependent on workflow_dispatch and pull_request events.

To implement what I wanted, I had to use the workflow_run to watch for the completion of the pre-commit-check workflow by passing its name and setting its type to completed.

So my workflow file became this:

deploy.yml
name: deploy
 
on:
  push:
    branches:
      - main
  workflow_run:
    workflows: ['Prettier and TS Checks']
    types:
      - completed
 
env:
  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
 
jobs:
  deploy:
    runs-on: ubuntu-latest
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
 
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: yarn
 
      - name: Install dependencies
        run: yarn install --immutable
 
      - name: Vercel Pull (production)
        run: yarn vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
 
      - name: Vercel Build (production)
        run: yarn vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
 
      - name: Vercel Deploy (production)
        run: yarn vercel deploy --prod --prebuilt --token=${{ secrets.VERCEL_TOKEN }}

You can learn more about events that trigger workflows here

What's next?

Well... I too, do not know. But, I'm pleased at the idea of not having to do this on my PC again. It is the problem of Actions now.