Skip to main content

Build & Test Workflows

A good CI workflow catches bugs before they reach the main branch. In this lesson, you will build production-quality workflows that test code across multiple environments, cache dependencies for speed, and report results clearly.

A Node.js CI Workflow

Here is a complete CI workflow for a Node.js project:

name: CI

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

jobs:
  lint:
    name: Lint & Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm tsc --noEmit

  test:
    name: Test
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile
      - run: pnpm build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

Matrix Builds

Test your code across multiple Node.js versions and operating systems simultaneously:

jobs:
  test:
    name: Test (Node ${{ matrix.node-version }}, ${{ matrix.os }})
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, windows-latest]
      fail-fast: false  # Don't cancel other jobs if one fails

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile
      - run: pnpm test

This creates 6 parallel jobs (3 Node versions x 2 operating systems). The fail-fast: false option ensures all combinations are tested even if one fails.

Dependency Caching

Caching dependencies speeds up your pipeline dramatically:

steps:
  - uses: actions/checkout@v4

  - uses: pnpm/action-setup@v4
    with:
      version: 9

  - uses: actions/setup-node@v4
    with:
      node-version: "20"
      cache: "pnpm"  # Built-in pnpm caching

  - run: pnpm install --frozen-lockfile

For more control, use the cache action directly:

steps:
  - name: Cache pnpm store
    uses: actions/cache@v4
    with:
      path: |
        ~/.pnpm-store
        node_modules
      key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
      restore-keys: |
        ${{ runner.os }}-pnpm-

The cache key is based on the lockfile hash. When dependencies change, a new cache is created.

Testing with Service Containers

If your tests need a database or cache, use service containers:

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      - run: npm install
      - name: Run tests
        env:
          DATABASE_URL: postgres://test:testpass@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379
        run: npm test

Service containers start before your steps run and are automatically cleaned up when the job finishes.

Code Coverage

Track test coverage and fail the build if it drops below a threshold:

steps:
  - run: pnpm test -- --coverage --coverageReporters=text --coverageReporters=lcov

  - name: Check coverage threshold
    run: |
      COVERAGE=$(pnpm test -- --coverage --coverageReporters=json-summary \
        | grep -o '"pct":[0-9.]*' | head -1 | grep -o '[0-9.]*')
      echo "Coverage: $COVERAGE%"
      if (( $(echo "$COVERAGE < 80" | bc -l) )); then
        echo "Coverage is below 80%!"
        exit 1
      fi

Pull Request Checks

Configure your workflow to block merging if checks fail. In your repository settings:

  1. Go to Settings > Branches > Branch protection rules
  2. Add a rule for main
  3. Check Require status checks to pass before merging
  4. Select your workflow jobs (e.g., "lint", "test", "build")

Now PRs cannot be merged until all checks pass.

Status Badges

Add a badge to your README showing the workflow status:

![CI](https://github.com/username/repo/actions/workflows/ci.yml/badge.svg)

This shows a green "passing" or red "failing" badge that updates automatically.

Workflow Optimization Tips

1. Run independent jobs in parallel:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [...]

  test:
    runs-on: ubuntu-latest
    steps: [...]

  build:
    runs-on: ubuntu-latest
    needs: [lint, test]  # Only build after lint AND test pass
    steps: [...]

2. Skip unnecessary runs with path filters:

on:
  push:
    paths:
      - "src/**"
      - "tests/**"
      - "package.json"
      - "pnpm-lock.yaml"

3. Cancel in-progress runs when new commits are pushed:

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

4. Set timeouts to prevent stuck jobs:

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps: [...]

A Python CI Example

CI is not limited to Node.js. Here is a Python workflow:

name: Python CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install pytest pytest-cov flake8

      - name: Lint with flake8
        run: flake8 src/ tests/

      - name: Test with pytest
        run: pytest --cov=src tests/

Summary

You built CI workflows that lint, test, and build code automatically. You learned matrix builds for testing across multiple environments, dependency caching for speed, service containers for integration tests, and concurrency controls for efficiency. In the next lesson, you will add deployment workflows to ship your code to staging and production.