Table of Contents

I had a run-in with GitHub Actions while migrating our work codebase from GitLab to GitHub. While it was fairly easy to setup, given its nascency, I could not find a lot on how to organize the code and configurations for it. Below are a few strategies/patterns I landed upon through my experiments.

I was quite surprised to learn that one could shove as many YAML configuration files in the .github/workflows/ folder. Unlike other CIs that read a named config file, GitHub Actions orchestrator reads all of them. I used this to setup a config for each environment that encoded the relationship between triggers such as branches/tags and the jobs for the environments.

For e.g.,

# .github/workflows/staging.yaml
on:
  push:
    branches:
      - main

env:
  SOME_SECRET: ${{ secrets.SOME_SECRET_STAGING }}

jobs:
  test:
    steps:
      - uses: actions/checkout@v2
      ...

  deploy:
    needs: [test]
    steps:
      - uses: actions/checkout@2
      # ...steps to deploy to staging...
# .github/workflows/production.yaml
on:
  push:
    tags:
      - prod-*

env:
  SOME_SECRET: ${{ secrets.SOME_SECRET_PRODUCTION }}

jobs:
  deploy:
    steps:
      - uses: actions/checkout@2
      # ...steps to deploy to production...

This setup tests and deploys to staging on commits pushed to the main branch and does the same for production when a tag that matches the pattern prod-* (e.g., prod-20200806-1420-big-feature-release) is pushed to the repository. Each file is entirely self contained and therefore very easy to onboards folks onto and debug.

Now that I was quite happy with how the configurations were organized, I wanted to factor out some of the repeated “code” from the configurations themselves. The most popular solution for this seemed to be using custom actions from their Marketplace or creating your own custom actions. If your use case is satisfied with what is on the Marketplace then I definitely recommend using that. If not, below is a list of ways ordered by what I find to be most simple to more complex ways of achieving code reuse.

strategy matrix

I have to confess, this is my most favourite way of factoring steps out. It feels declarative and is native to the GitHub Actions configuration format!

Say that you need to perform pretty much the same steps for a set of parameters such as versions of node, different folders in a monorepo, etc. For such a use case, we can define a strategy matrix that in turn will fan out the job out over those parameters. Let us look at a subset of a configuration as an example.

# ...name, triggers, etc...#

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        working-dir: [ui, gql-server]
        include:
          - working-dir: ui
            node-version: 14.x
          - working-dir: server
            node-version: 12.x

    defaults:
      run:
        working-directory: ${{ matrix.working-dir }}

    steps:
      - uses: actions/checkout@v2

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}
      
      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test
        
      - name: Lint CSS
        if: matrix.working-dir == 'ui'
        run: npm run lint:css       

This setup not only runs the shared steps (npm ci and npm test) for both the ui and gql-server folders in the monorepo for their versions of node, but also lets us run ui specific steps (npm run lint:css) in the ui folder!

Some usecases might not possible to factor out using the strategy matrix. In that case, we can do what folks have done for decades — factor out steps into an exectutable of sorts (e.g., a script). This is especially useful if you want the power of a general purpose programming language at your disposal. It is very straightforward to use as we can invoke the executable from any one of the steps in a job.

For e.g.,

# .github/workflows/test.sh

# some fancy bash shell script stuff here
npm ci
npm t
# etc
# .github/workflows/main.yml

# ...triggers, env, etc...

jobs:
  test:
    name: Test all the things
    runs-on: ubuntu-latest

    defaults:
      run:
        shell: bash # as our shell script is written for bash

    steps:
      - uses: actions/checkout@v2
      - name: Test it
        run: ./test.sh

Authoring custom actions

Most of my usecases were handled by the two approaches above so I only ever authored an action just to play with it. I created a JavaScript action and while the process isn't the most difficult, it is very boilerplate-y. But I guess that's the tradeoff we make for very wide reusability, platform support (e.g., install via Marketplace), etc. The documentation for authoring your own actions is fairly good so I will not be providing an example here.

Organizing configurations by environments in combination with job strategies plus the occasional script is my current goto for organizing code and configurations for GitHub Actions in my projects. It serves the purposes being easy to debug and easy to onboarding new colleagues/collaborators on.

🖖🏼