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
Calling it
From any other workflow in the same repo:
jobs:
ci:
uses: ./.github/workflows/reusable-workflow.yml
with: { node-version: "20" }
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" }
Notes on the details
-
on: workflow_callis what makes the file callable. It has nopushorpull_requesttrigger, 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-directoryapplies to everyrun:step, sonpm ciandnpm testrun inside the chosen package. -
cache-dependency-pathpoints the npm cache at that package's lockfile. Without it,setup-nodelooks for a lockfile at the repo root and fails or caches the wrong thing in a monorepo. -
npm test --if-presentskips packages with notestscript instead of failing. -
permissions: contents: readgives 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
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)