βοΈ 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/checkoutandactions/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.
| Term | What it is |
|---|---|
| Workflow | The whole automated process, one YAML file in .github/workflows/ |
| Event | The trigger that starts a workflow β a push, a PR, a schedule |
| Job | A group of steps that run together on one fresh runner (VM) |
| Step | A single task inside a job β either a shell command or an action |
| Action | A reusable, shareable step (e.g. actions/checkout) |
| Runner | The 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.
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, withcache: '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:
| Trigger | Fires when⦠| Typical use |
|---|---|---|
push | Commits are pushed to a branch | Verify main stays green |
pull_request | A PR is opened or updated | Gate merges on passing checks |
schedule | A cron time is reached | Nightly security scans |
workflow_dispatch | Someone 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/
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
echoa 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(notnpm 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
echosecrets or paste them into logs - Don't reference actions with
@masteror@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/checkoutthenactions/setup-node - A matrix multiplies a job across versions and platforms in parallel
- Caching and parallel jobs joined by
needskeep pipelines fast - Secrets handle sensitive values; service containers provide a real database for integration tests
π Additional Resources
- GitHub Docs β Quickstart for GitHub Actions
- GitHub Docs β Workflow syntax reference
- GitHub Docs β Using a matrix for your jobs
- GitHub Docs β Using secrets in GitHub Actions
π 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.