Skip to main content

βš™οΈ GitHub Actions for CI

In the last lesson CI was an idea. Now it's a file. GitHub Actions lets you drop a small YAML file into your repository and, from that moment, every push spins up a fresh Linux machine that checks out your code, installs it, and runs your tests β€” automatically, for free, right next to your pull requests.

Week 12 · Day 5 (Friday: Continuous Integration) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the GitHub Actions model: workflow β†’ jobs β†’ steps, and where the YAML lives
  • Configure on: triggers for pushes, pull requests, schedules, and manual runs
  • Write a working CI workflow using actions/checkout and actions/setup-node
  • Test across versions and platforms with a matrix strategy
  • Speed up runs with dependency caching and run jobs in parallel with needs
  • Handle sensitive values safely with secrets and spin up a database with service containers

Estimated Time: 70 minutes

Practice: Author a multi-job CI workflow for a Node project and read the run logs.

In This Lesson

Why GitHub Actions

Plenty of CI platforms exist β€” Jenkins, CircleCI, GitLab CI, Travis. GitHub Actions won a huge share of the JavaScript world for one simple reason: it lives inside GitHub. There's no separate service to sign up for, no webhook to wire, no dashboard to babysit. You commit a YAML file to .github/workflows/, and GitHub notices it and runs it. The status shows up directly on your commits and pull requests.

On top of that it has a marketplace of thousands of reusable "actions" β€” pre-packaged steps like "check out my repo" or "install Node" β€” so most of a pipeline is assembled from tested building blocks rather than shell scripts you maintain yourself.

πŸ“– Free minutes

Public repositories get GitHub-hosted runners for free, and private repos get a monthly free allotment of minutes. For learning and open-source, the compute costs nothing β€” which is exactly why it's the perfect place to build your first pipeline.

The Mental Model

Five words unlock everything: workflow, event, job, step, action. Get these straight and every YAML file reads clearly.

TermWhat it is
WorkflowThe whole automated process, one YAML file in .github/workflows/
EventThe trigger that starts a workflow β€” a push, a PR, a schedule
JobA group of steps that run together on one fresh runner (VM)
StepA single task inside a job β€” either a shell command or an action
ActionA reusable, shareable step (e.g. actions/checkout)
RunnerThe virtual machine that executes one job

The nesting is strict: a workflow contains jobs, a job contains steps. Jobs run in parallel by default (each on its own runner); steps within a job run in order on the same runner.

graph TD A["Event: push or pull_request"] --> B["Workflow: ci.yml"] B --> C["Job: lint"] B --> D["Job: test"] C --> C1["Step: checkout"] C --> C2["Step: setup-node"] C --> C3["Step: run npm run lint"] D --> D1["Step: checkout"] D --> D2["Step: setup-node"] D --> D3["Step: run npm test"]
A workflow contains jobs, and each job contains ordered steps Workflow Job: build Step 1 Β· checkout Step 2 Β· setup-node Step 3 Β· npm test Job: deploy runs on its own runner, in parallel by default (use "needs:" to wait for build)
Jobs are isolated boxes on separate machines; steps are the ordered tasks inside one box.

Your First Workflow

Create the file .github/workflows/ci.yml. The path matters: GitHub only looks in .github/workflows/. Here's a complete, working CI workflow for a Node.js project, annotated line by line.

name: CI                           # shows up in the Actions tab

on:                                # WHEN to run
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:                              # WHAT to run
  build-and-test:
    runs-on: ubuntu-latest         # a fresh Ubuntu VM for this job
    steps:
      # 1. Clone the repo onto the runner
      - name: Check out code
        uses: actions/checkout@v4

      # 2. Install Node and enable npm caching
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      # 3. Install exactly what package-lock.json locks
      - name: Install dependencies
        run: npm ci

      # 4. Cheap checks first
      - name: Lint
        run: npm run lint

      # 5. Then the test suite
      - name: Test
        run: npm test

      # 6. Confirm it builds
      - name: Build
        run: npm run build

πŸ’‘ npm ci vs npm install

In CI, always use npm ci, not npm install. npm ci does a clean, exact install straight from package-lock.json β€” it's faster, and it fails loudly if the lockfile and package.json disagree. That reproducibility is exactly what you want on a build server.

The two most important actions here are near-universal:

  • actions/checkout@v4 β€” clones your repository onto the runner. Without it, the runner is an empty machine with no code. It's almost always step one.
  • actions/setup-node@v4 β€” installs a specific Node version and, with cache: 'npm', remembers your downloaded packages between runs so installs are fast.

What you'll see

βœ“ Check out code            2s
βœ“ Set up Node.js            4s
βœ“ Install dependencies     11s
βœ“ Lint                      3s
βœ“ Test                     18s
βœ“ Build                     9s
Build succeeded β€” a green check appears on your commit

Triggers: the on Block

The on: key decides when a workflow runs. This is where you tune a pipeline to fire on exactly the events you care about.

on:
  # Run when commits land on main
  push:
    branches: [ main ]

  # Run on PRs targeting main β€” the classic "check before merge"
  pull_request:
    branches: [ main ]

  # Run on a schedule (cron, in UTC): 1:00 AM daily
  schedule:
    - cron: '0 1 * * *'

  # Add a "Run workflow" button in the GitHub UI
  workflow_dispatch:
TriggerFires when…Typical use
pushCommits are pushed to a branchVerify main stays green
pull_requestA PR is opened or updatedGate merges on passing checks
scheduleA cron time is reachedNightly security scans
workflow_dispatchSomeone clicks "Run workflow"Manual deploys / one-offs

You can also narrow triggers by path, so a docs-only change doesn't burn a full test run:

on:
  push:
    branches: [ main ]
    paths:
      - 'src/**'
      - 'package.json'
      - 'package-lock.json'
      - '.github/workflows/**'

βœ… Branch protection ties it together

On its own a workflow just reports status. The real power comes from a branch protection rule (repo Settings β†’ Branches) that makes the CI check required. Now a pull request literally cannot be merged until the pipeline passes β€” the pipeline stops being advisory and starts being a gate.

Matrix Builds

Your library needs to work on Node 18, 20, and 22, on Linux and Windows. Writing six near-identical jobs would be miserable. A matrix generates them for you: define the axes, and GitHub runs every combination in parallel.

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false            # let all combos finish, don't cancel on first fail
      matrix:
        os: [ ubuntu-latest, windows-latest ]
        node-version: [ 18, 20, 22 ]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

That's 2 operating systems Γ— 3 Node versions = 6 jobs, all from one block. Each runs on its own fresh runner simultaneously, so testing six environments takes about as long as testing one.

⚠️ fail-fast: true or false?

By default fail-fast: true cancels every matrix job the instant one fails β€” great for saving minutes when you just want a quick pass/fail. But when you're debugging a version-specific bug, set it to false so you can see exactly which combinations broke instead of only the first one.

Caching & Parallel Jobs

Two levers keep pipelines fast: don't re-download what you already have, and don't run sequentially what could run at once.

Caching

The easiest win is already in your first workflow: cache: 'npm' on setup-node caches your package downloads keyed by package-lock.json. When the lockfile hasn't changed, installs restore from cache in seconds. For finer control you can cache any directory yourself:

- name: Cache npm downloads
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

The key is a fingerprint. When the lockfile changes, the hash changes, so you get a fresh cache; otherwise the exact key hits and restores instantly.

Parallel jobs with needs

Split independent work into separate jobs so they run at the same time, then use needs to enforce order only where it truly matters:

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

  build:
    needs: [ lint, test ]         # waits for BOTH to pass
    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: Save the build output
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
graph LR A["lint job"] --> C["build job"] B["test job"] --> C C --> D["upload artifact"]

lint and test run in parallel; build waits for both via needs, then saves its output as an artifact β€” a file bundle you can download from the run or hand to a later deploy job.

Secrets & Service Containers

Secrets

Pipelines often need a deploy token or an API key β€” values you must never commit to the repo. Store them in Settings β†’ Secrets and variables β†’ Actions, then read them through the secrets context. GitHub automatically masks them in logs.

- name: Deploy
  run: npm run deploy
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
    API_KEY: ${{ secrets.API_KEY }}

⚠️ Secret hygiene

  • Never echo a secret β€” even masked output can sometimes be reconstructed
  • Give a secret only to the specific job or step that needs it
  • Be cautious with untrusted third-party actions that run with access to your secrets
  • Rotate secrets on a schedule and immediately if one leaks

Service containers

Integration tests often need a real database. Rather than mocking one, GitHub can spin up a throwaway container alongside your job β€” a service container. Here's Postgres, ready on localhost:5432 for the length of the job:

jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        # wait until Postgres is actually accepting connections
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db

The options health-check is essential: without it your tests might start before Postgres is ready and fail with confusing connection errors. The --health-cmd makes the job wait until the database actually answers.

Practice & Quiz

πŸ‹οΈ Exercise 1: Fix the broken workflow

Goal: This workflow fails on every run with "npm: command not found" β€” and even if that were fixed, the tests can't find the source code. Spot both bugs and fix them.

name: CI
on:
  push:
    branches: [ main ]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Install dependencies
        run: npm ci
      - name: Test
        run: npm test
πŸ’‘ Hint

A fresh runner is empty. What two setup actions does every Node workflow need before it can run npm commands against your code?

βœ… Solution
name: CI
on:
  push:
    branches: [ main ]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code          # bug 2: no code on the runner
        uses: actions/checkout@v4
      - name: Set up Node.js          # bug 1: no npm on the runner
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Test
        run: npm test

The runner starts blank: you must checkout to get your files and setup-node to get the npm binary. These two actions open almost every Node workflow.

πŸ‹οΈ Exercise 2: Add a matrix

Goal: Take the fixed workflow above and make the test job run on Node 18, 20, and 22.

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

The single node-version is replaced by a matrix axis, and setup-node reads it with ${{ matrix.node-version }}. GitHub fans out three parallel jobs.

🎯 Quick Quiz

Question 1: Where must a GitHub Actions workflow file live?

Question 2: By default, how do the jobs in a workflow run relative to each other?

Question 3: How do you make one job wait for another to finish first?

Best Practices & Pitfalls

βœ… Do

  • Use npm ci (not npm install) for fast, reproducible installs from the lockfile
  • Pin actions to a major version like @v4 β€” or a full commit SHA for maximum supply-chain safety
  • Enable dependency caching so repeat runs finish in a fraction of the time
  • Split lint/test/build into parallel jobs and connect them with needs
  • Make the CI check required via branch protection so it actually gates merges

❌ Don't

  • Don't hard-code tokens or keys in the YAML β€” use secrets
  • Don't echo secrets or paste them into logs
  • Don't reference actions with @master or @latest β€” an upstream change could silently break or compromise your build
  • Don't forget the service-container health check, or tests race the database and fail intermittently

πŸ”’ Pinning actions

# Good β€” pinned to a major version, gets patches
- uses: actions/checkout@v4

# Best β€” pinned to an exact commit, fully reproducible
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

# Avoid β€” a moving target you don't control
- uses: actions/checkout@master

Pinning protects you from an action changing behavior β€” or being compromised β€” between your runs.

Summary

πŸŽ‰ Key Takeaways

  • A GitHub Actions workflow is a YAML file in .github/workflows/, structured as jobs β†’ steps
  • The on: block sets triggers: push, pull_request, schedule, workflow_dispatch
  • Nearly every Node job starts with actions/checkout then actions/setup-node
  • A matrix multiplies a job across versions and platforms in parallel
  • Caching and parallel jobs joined by needs keep pipelines fast
  • Secrets handle sensitive values; service containers provide a real database for integration tests

πŸ“š Additional Resources

πŸš€ What's Next?

You can now write a CI workflow that lints, tests, and builds across many environments. Next, Automated Testing Pipelines zooms out to the full test strategy inside a pipeline β€” running unit, integration, and end-to-end suites together, enforcing coverage gates, and wiring branch protection so nothing ships untested.

βš™οΈ Your robot teammate is on the clock

Every push now triggers a clean build and test run. Next we make that pipeline genuinely thorough.