Reusable workflows

Our final advanced use case we would like to cover is the use of reusable workflows.

Suppose you're in a monorepo and have the following sub-projects:

  1. web

  2. admin

  3. api

All of them require the exact same CI pipeline of running unit tests and linting that we introduced in Basics of Github Actions. If you copy-pasted the same workflow file three times, it might work, but this means that if one changes, everything needs to change. While some might argue that as the application expands, this flexibility is required to avoid a tight coupling to one type of workflow. However, for the sake of simplicity, let's suppose that this duplication is fundamentally bad for this use case. How do we go about reconciling this?

Well, this is where reusable workflows come in. They allow you to effectively define a "common workflow" that can be shared and reused by other workflows as steps. Essentially, what you're creating is custom actions that have not been properly published.

The official documentation goes into the nitty gritty of the limitations and access of reusable workflows, so we will not cover it in this section. Instead, we will focus on setting up a very rudimentary reusable workflow for the above scenario.

So let's suppose that the original workflow looks like this:

# .github/workflows/web_ci.yml
name: CI/CD Pipeline
on: [pull_request, workflow_dispatch]
jobs:
  linting:
    runs-on: ubuntu-latest
    steps:
      - name: Fetch repository
        uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'yarn'
      - name: Install dependencies
        run: |
          yarn
      - name: Lint code
        run: |
          yarn lint

  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Fetch repository
        uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'yarn'
      - name: Install dependencies
        run: |
          yarn
      - name: Run unit tests
        run: |
          NODE_ENV=production yarn test

You realize that the job steps are exactly identical, apart from the folder that these commands are being run in. We can generalize these as inputs to the reusable workflow!

# .github/workflows/reusable-ci.yml
name: Reusable CI Workflow
on:
  workflow_call:
    inputs:
      workdir:
        description: 'Working directory'
        required: true
        type: string
jobs:
  linting:
    runs-on: ubuntu-latest
    steps:
      - name: Fetch repository
        uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'yarn'
          cache-dependency-path: "${{ inputs.workdir }}/yarn.lock"
      - name: Install dependencies
        working-directory: ${{ inputs.workdir }}
        run: |
          yarn
      - name: Lint code
        working-directory: ${{ inputs.workdir }}
        run: |
          yarn lint

  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Fetch repository
        uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'yarn'
          cache-dependency-path: "${{ inputs.workdir }}/yarn.lock"
      - name: Install dependencies
        working-directory: ${{ inputs.workdir }}
        run: |
          yarn
      - name: Run unit tests
        working-directory: ${{ inputs.workdir }}
        run: |
          NODE_ENV=production yarn test

Essentially, the key things that had to alter were:

  1. Changing the trigger event type to workflow_call, indicating it's a reusable workflow

  2. Specifying the inputs that the reusable workflow requires, such as the workdir since that is the only thing that changes across variations of this CI workflow

  3. Specifying the cache-dependency-path in the actions/setup-node@v4 action as we need to use the yarn.lock files specific to each sub-project

  4. Specifying the working-directory of each step to point to the given sub-project directory

This is all we really need to create the reusable workflow. Then, we can update our original ci.yml with the following:

# .github/workflows/web_ci.yml
name: CI/CD Pipeline
on: [pull_request, workflow_dispatch]
jobs:
  ci:
    uses: <org name/username>/<repo name>/.github/workflows/reusable-ci.yml@main
    with:
      workdir: web

In fact, we can even inline every sub-project's CI into the same workflow:

# .github/workflows/ci.yml
name: CI/CD Pipeline
on: [pull_request, workflow_dispatch]
jobs:
  web_ci:
    uses: <org name/username>/<repo name>/.github/workflows/reusable-ci.yml@main
    with:
      workdir: web
  admin_ci:
    uses: <org name/username>/<repo name>/.github/workflows/reusable-ci.yml@main
    with:
      workdir: admin
  api_ci:
    uses: <org name/username>/<repo name>/.github/workflows/reusable-ci.yml@main
    with:
      workdir: api

Incredible, we've managed to greatly simplify our CI workflow by using reusable workflows!

Last updated