1. All Blogs 2. About Me 3. Contact Me 4. Visit My Website 5. Visit My Venture

Part 6: GitHub Actions I — CI/CD Pipeline Fundamentals

font_size 100%
font
contrast
listen

Part 6: GitHub Actions I — CI/CD Pipeline Fundamentals

In Part 5, we used a simple workflow to deploy a static site. Now let's look closely at GitHub Actions for continuous integration.

Slow CI pipelines hurt productivity more than almost anything else. When a pull request takes 25 minutes to run tests because dependencies are downloaded from scratch on every push, or when three pushes in two minutes queue up three duplicate test runs, developers start looking for ways around the checks. With a few small changes to your workflow YAML, you can usually cut CI times in half.

GitHub Actions CI Pipeline: Triggers, Matrix Fan-Out and Caching

1. Triggers and Canceling Redundant Runs (concurrency)

Every workflow file in .github/workflows/*.yml starts with an on: block (push, pull_request, merge_group, schedule, or workflow_dispatch). By default, each Job in a workflow spins up a fresh virtual machine (ubuntu-latest, macos-14, windows-latest) and runs in parallel, while Steps inside a job run one after another on that same machine.

Two settings belong at the top of almost every CI workflow:

  • Cancel outdated PR runs (concurrency): If you push a commit to a PR, spot a typo 30 seconds later, and push again, there is no reason to keep running tests on the first commit. Adding a concurrency block cancels the older PR run immediately while letting builds on main finish normally.
  • Set a job timeout (timeout-minutes): If you don't set timeout-minutes, a hung network call or deadlocked test will sit there for 6 hours (360 minutes) before GitHub kills it, burning through your monthly runner quota. Set timeout-minutes: 10 or 15 on every job.

2. Testing Across Versions with strategy.matrix

Instead of copy-pasting the same job for Ubuntu and macOS or Node 20 and Node 22, use strategy.matrix. GitHub expands the combinations and runs them in parallel.

One important flag to add is fail-fast: false. By default, fail-fast is true, which means if your test fails on Node 20 + Ubuntu, GitHub immediately cancels the Node 22 and macOS jobs that are mid-run. Setting fail-fast: false lets all matrix jobs finish so you can see right away whether the bug affects one specific environment or all of them.

3. Caching Dependencies vs. Uploading Artifacts

It is easy to mix up Caches (actions/cache@v4) and Artifacts (actions/upload-artifact@v4):

  • Dependency Caching (actions/cache@v4): Saves downloaded package directories (~/.pnpm-store, ~/.cache/pip, ~/.cargo/registry) so the next workflow run doesn't have to download them again. Cache keys should always hash your lockfile (runner.os-pnpm-hashFiles('**/pnpm-lock.yaml')). Note that feature branches can read caches created on main, but main will never read a cache written by a feature branch—which prevents a PR from poisoning the cache for everyone else.
  • Workflow Artifacts (actions/upload-artifact@v4): Saves files produced during a run (like a compiled binary, a coverage report, or Playwright failure screenshots) so another job in the same workflow can download them, or so you can download them from the UI. Always set retention-days: 7 on test artifacts so you don't fill up your storage quota keeping crash logs for 90 days.

4. The "Single Gate Job" Pattern for Branch Protection

When you use matrix jobs, do not require individual matrix job names like test (ubuntu-latest, 20) in your branch protection rules. The day you upgrade your matrix from Node 20 to Node 24, every open PR will get stuck waiting for a test (ubuntu-latest, 20) check that no longer exists.

Instead, add a tiny final job (ci-status-gate) with needs: [lint, test] and if: always() that fails if any upstream job failed. Then require only ci-status-gate in your repository ruleset. You can change your matrix however you want in the future without touching branch settings.

5. Walkthrough: A Fast Production .github/workflows/ci.yml

Here is a complete CI workflow putting all of these pieces together: read-only default permissions, automatic cancellation of superseded PR runs, lockfile caching, a matrix build, failure-only artifact uploads, and a single gate job at the end:

# .github/workflows/ci.yml
name: Continuous Integration

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

# Read-only GITHUB_TOKEN by default
permissions:
  contents: read

# Cancel older runs on the same PR branch, but never on main or merge_group
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm run lint && pnpm run typecheck

  matrix-test:
    runs-on: ${{ matrix.os }}
    timeout-minutes: 15
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-14]
        node: [20, 22]
        exclude:
          - os: macos-14
            node: 20
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: "pnpm"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run tests
        run: |
          pnpm test --coverage
          echo "### Passed: ${{ matrix.os }} / Node ${{ matrix.node }}" >> $GITHUB_STEP_SUMMARY

      - name: Upload test logs on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: crash-logs-${{ matrix.os }}-node-${{ matrix.node }}
          path: ./test-results/
          retention-days: 7

  # Require only this job in your branch protection ruleset
  ci-status-gate:
    name: CI Required Gate
    if: always()
    needs: [lint-and-typecheck, matrix-test]
    runs-on: ubuntu-latest
    steps:
      - name: Check upstream job results
        run: |
          if [[ "${{ needs.lint-and-typecheck.result }}" != "success" ]] || \
             [[ "${{ needs.matrix-test.result }}" != "success" ]]; then
            echo "One or more CI jobs failed or were cancelled."
            exit 1
          fi
          echo "All CI checks passed."

Quick Checklist

  • Always set timeout-minutes and permissions: contents: read: Don't let a hung test burn 6 hours of runner time, and keep GITHUB_TOKEN read-only unless a job actually needs to write.
  • Install with --frozen-lockfile or npm ci: Never let CI silently resolve newer dependency versions than what you tested locally.
  • Gate branch protection on a single aggregator job: Require ci-status-gate so you can add, remove, or rename matrix jobs freely inside your .yml file.

Series Navigation: ← Previous: Part 5: GitHub Pages, Gists, and Developer Portfolios | Next: Part 7: GitHub Actions II — Advanced Automation & Custom Runners → | All Articles

← Back to all articles