CI/CD with GitHub Actions: A Pipeline That Actually Catches Bugs

Build a practical GitHub Actions pipeline from scratch — linting, tests, caching, matrix builds, and safe deploys — with the gotchas that waste an afternoon if you don't know them.

Building & Shipping: CI/CD with GitHub Actions: A Pipeline That Actually Catches Bugs

What CI/CD is actually for

Continuous Integration isn’t about badges on your README. It’s about catching the broken thing before your teammate pulls it, and before it reaches a user. A good pipeline answers one question on every push: is main still shippable?

This guide builds that pipeline in GitHub Actions, starting minimal and adding only what earns its place.

The anatomy of a workflow

GitHub Actions runs workflows (YAML files in .github/workflows/), each containing jobs, each containing steps. Jobs run in parallel by default; steps run in sequence.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test

That’s a working pipeline. Every push to main and every PR runs your tests on a fresh Ubuntu machine. But it’s slower than it needs to be and does less than it should.

Add dependency caching (this one’s free speed)

npm ci re-downloads everything on every run. setup-node can cache the npm store:

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci

One gotcha that trips everyone: the cache: 'npm' option requires a lockfile (package-lock.json) committed to the repo. Without it, the step fails with a confusing “Dependencies lock file is not found” error. Commit your lockfile.

On a typical project this cuts install time from ~40s to ~8s. It’s the single highest-return line in the file.

Run lint and tests as separate jobs

Combining lint and test into one job means a lint failure hides test results (and vice versa). Split them so you see both outcomes at once:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npm test

They run in parallel, so splitting costs no wall-clock time — you just get two clear green/red signals instead of one muddy one.

Matrix builds: test across versions

If your library needs to work on multiple Node versions, a matrix runs the same job across each:

  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

fail-fast: false is the important part. By default, the moment one matrix cell fails, GitHub cancels the rest — so if Node 18 fails you never learn whether 20 and 22 also fail. Setting it false lets every version report independently, which is what you actually want when debugging a compatibility issue.

Gating merges on green

A pipeline nobody has to pass is decoration. In Settings → Branches → Branch protection rules, require status checks to pass before merging. Now a red pipeline physically blocks the merge button.

A subtle trap: the required check name must match the job name exactly. If you rename a job from test to unit-tests, the branch rule still waits for test — which no longer runs — and every PR hangs forever “waiting for status.” When that happens, update the branch protection rule to the new name.

Deploying safely

Here’s a deploy job that runs only after tests pass, and only on main:

  deploy:
    needs: [lint, test]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npm run build
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: npm run deploy

Two safety mechanisms:

  • needs: [lint, test] — the deploy waits for both to succeed. A failing test blocks the deploy.
  • if: condition — deploy runs only on a direct push to main, never on a PR (you don’t want every PR deploying to production).

Secrets: never in the YAML

Notice DEPLOY_TOKEN comes from secrets.DEPLOY_TOKEN, not a literal. Add secrets in Settings → Secrets and variables → Actions. They’re encrypted, masked in logs (GitHub replaces the value with *** if it ever appears in output), and never exposed to workflows triggered from forks.

A real risk to understand: a pull_request from a forked repo does not get access to your secrets, by design — otherwise anyone could open a PR that exfiltrates your tokens. If your deploy needs secrets, keep it on the push trigger to main, which only maintainers can do.

Failing fast and readably

Add these so failures are quick to diagnose:

      - name: Type check
        run: npm run typecheck
      - name: Tests with coverage
        run: npm test -- --coverage
      - name: Upload coverage on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

if: failure() uploads the coverage report only when something broke, so you can download and inspect it. Steps are skipped by default once a prior step fails, so you need the explicit condition to make cleanup or diagnostic steps run anyway.

The complete pipeline

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

  deploy:
    needs: [lint, test]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npm run build
      - env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: npm run deploy

Pin your action versions

actions/checkout@v4 pins to a major version. Avoid @main or @master on third-party actions — you’re executing someone else’s code on a machine that can touch your secrets, and @main means you run whatever they push next, including a compromised update. For high-security setups, pin to a full commit SHA:

      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

A SHA can’t be moved under you the way a tag can.

What to skip until you need it

  • Self-hosted runners — only when GitHub’s runners are too slow or can’t reach private infrastructure. They add maintenance and security burden.
  • Reusable workflows — worth it once you have 3+ repos sharing a pipeline, not before.
  • Complex conditional matrices — most projects never need them.

Start with lint + test + gated deploy. Add complexity when a real pain point demands it, not preemptively.

The mindset that matters

A pipeline is a contract about what “done” means. If it’s green, the code met the bar. That only works if the bar is real: tests that would actually fail on a regression, a lint config the team agreed on, a deploy that can’t run on red. A pipeline full of skipped tests and continue-on-error is worse than none, because it grants false confidence.

Keep it honest, keep it fast, and it becomes the thing that lets you ship on a Friday without dread.