DevOps

Building a CI Pipeline With GitHub Actions That Doesn't Waste Your Time

Most CI pipelines start fast and slowly become 20-minute bottlenecks. Here's a setup that stays fast as the project grows.

Milo
3 min read
Building a CI Pipeline With GitHub Actions That Doesn't Waste Your Time

The default GitHub Actions templates work fine on day one. But a basic Node.js CI workflow that takes 2 minutes on a fresh project can balloon to 15–20 minutes once you add linting, testing, type checking, and a build step — all running one after another.

Here’s the structure I use to keep CI fast as the project grows.

The Core Idea: Parallelize Everything

Instead of one job running each step serially, split your pipeline into parallel jobs that only depend on each other when they actually need to.

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - uses: actions/cache/save@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}

  lint:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}
      - run: npm run lint

  typecheck:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}
      - run: npm run typecheck

  test:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}
      - run: npm test

  build:
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}
      - run: npm run build

Lint, typecheck, and test all run simultaneously. The build only runs if all three pass. Wall time drops from the sum of all steps to roughly the duration of the slowest parallel job — typically cutting total CI time by 60% or more.

Caching Matters More Than You Think

npm ci is often the single slowest step in any Node.js pipeline. Caching node_modules keyed to package-lock.json means subsequent runs skip the install entirely when dependencies haven’t changed.

This alone saves 30–90 seconds per run. Across a team pushing dozens of PRs a day, that compounds fast.

One subtle detail: use actions/cache/save and actions/cache/restore separately (not the combined actions/cache) so the install job writes the cache and parallel jobs only read it. This avoids race conditions and unnecessary re-saves.

Don’t Run Everything on Every Push

Use path filters to skip jobs that can’t be affected by the change:

on:
  push:
    paths:
      - 'src/**'
      - 'tests/**'
      - 'package*.json'
      - '.github/workflows/**'

Documentation changes, README updates, and config tweaks don’t need a full test suite. If your repo has multiple apps or packages, you can go further with path-based matrix strategies that only test what changed.

Fail Fast, Notify Fast

Add concurrency to cancel redundant runs when you push again to the same PR:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

This prevents queueing up stale runs when you’re iterating quickly on a branch. Combined with parallelization, it means you almost always see results within a few minutes of your latest push.

The Result

With this structure, CI runs take about 3 minutes on a project with full linting, type checking, 400+ tests, and a production build. That’s fast enough to wait for — which means bugs get caught before merge, not after.

Written by

Milo

Developer