DEV Community

Anthany Wingo
Anthany Wingo

Posted on

Keep GitHub Actions CI DRY with a reusable workflow

If you have several repos, or one monorepo with several packages, you probably have the same CI steps copy-pasted in many places: checkout, setup Node, install, test. When you need to change one of them, you edit every copy.

GitHub Actions lets one workflow call another through the workflow_call trigger. You write the CI once and call it with inputs.

The reusable workflow

Save this as .github/workflows/reusable-workflow.yml:

name: Reusable Node CI

on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: "20"
      working-directory:
        type: string
        default: "."

permissions:
  contents: read

jobs:
  ci:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
          cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
      - run: npm ci
      - run: npm test --if-present
Enter fullscreen mode Exit fullscreen mode

Calling it

From any other workflow in the same repo:

jobs:
  ci:
    uses: ./.github/workflows/reusable-workflow.yml
    with: { node-version: "20" }
Enter fullscreen mode Exit fullscreen mode

For a monorepo, call it once per package:

jobs:
  api:
    uses: ./.github/workflows/reusable-workflow.yml
    with: { working-directory: "packages/api" }
  web:
    uses: ./.github/workflows/reusable-workflow.yml
    with: { working-directory: "packages/web", node-version: "22" }
Enter fullscreen mode Exit fullscreen mode

Notes on the details

  • on: workflow_call is what makes the file callable. It has no push or pull_request trigger, so it never runs by itself; the caller decides when.
  • Typed inputs with defaults. Callers only pass what differs from the default.
  • defaults.run.working-directory applies to every run: step, so npm ci and npm test run inside the chosen package.
  • cache-dependency-path points the npm cache at that package's lockfile. Without it, setup-node looks for a lockfile at the repo root and fails or caches the wrong thing in a monorepo.
  • npm test --if-present skips packages with no test script instead of failing.
  • permissions: contents: read gives the job's token read-only access, which is all CI needs.

Going further: a cross-OS matrix

The same idea pairs well with a matrix. Lint once on Linux, then test only if lint passes:

  test:
    needs: lint
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false        # let every combo finish so you see all failures
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
        exclude:
          - os: macos-latest   # example: trim an expensive combo
            node: 18
Enter fullscreen mode Exit fullscreen mode

fail-fast: false matters here: without it, one failure cancels the rest and you only learn about one broken platform per push.


Both workflows come from ShipKit's GitHub Actions Starter Pack, which has 15 workflows (Node, Python and Go CI, Docker to GHCR, semver releases, deploys to Pages, Vercel and Fly, CodeQL and more) plus repo templates: https://8686702537545.gumroad.com/l/clhec

There's also a free sample with a Node CI workflow: https://8686702537545.gumroad.com/l/sqjvco

Last day: the pack is $9 with code SHIPKIT9 until tonight, Thursday, Oct 15 at 11:59 PM CT. After that it's $15.


Related: If your CI starts services with Docker Compose, Stop your app from starting before Postgres is ready: Compose healthchecks done right covers healthcheck and depends_on: condition: service_healthy so the app waits for the database.

The GitHub Actions Starter Pack ($15) has 15 commented GitHub Actions workflows (CI for Node/Python/Go, cross-OS matrix, releases, Docker to GHCR, Pages/Vercel/Fly deploys, CodeQL) plus a repo template set. It includes one reusable (workflow_call) workflow; deploys need your own tokens.

Disclosure: I make ShipKit, which sells this pack.

Top comments (0)