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.
If your CI spins up Postgres, Redis, MySQL or MongoDB with Docker Compose, I also made a free generator that writes the healthcheck and the depends_on: condition: service_healthy block for you: https://8686702537545.gumroad.com/l/cumhuh (free, enter $0; it asks for an email). The snippets are starting points, so check them against your own setup.
Disclosure: I make ShipKit, which sells this pack.
