Chapter 24 — GitHub Actions & CI/CD
Every time code is pushed, a pull request is opened, or a release tag is created, there is useful work that could be done automatically: run the tests, check formatting, build a Docker image, deploy to staging. GitHub Actions is GitHub's built-in automation platform that makes this possible. It runs arbitrary code in response to GitHub events, without requiring any external CI service.
Core Concepts
Workflow
A workflow is a YAML file stored in .github/workflows/ that defines when to run and what to do. A repository can have many workflows, each triggered independently.
.github/
└── workflows/
├── ci.yml # run tests on every push and PR
├── release.yml # build and publish on tag push
└── codeql.yml # security analysis on schedule
Event (trigger)
Every workflow starts with an event — something that happens on GitHub. Common triggers:
on:
push: # any push to any branch
pull_request: # PR opened, synchronised, or reopened
push:
branches: [main] # push to main only
push:
tags: ['v*'] # push of a version tag
schedule:
- cron: '0 2 * * *' # daily at 02:00 UTC
workflow_dispatch: # manual trigger from GitHub UI
release:
types: [published] # when a GitHub Release is published
Job
A job is a set of steps that runs on a single runner. Jobs in the same workflow run in parallel by default; use needs: to sequence them.
Step
A step is a single task within a job — either a shell command (run:) or a call to a pre-built action (uses:).
Runner
A runner is the virtual machine that executes a job. GitHub provides hosted runners:
| Runner label | OS |
|---|---|
ubuntu-latest |
Ubuntu Linux (most common) |
windows-latest |
Windows Server |
macos-latest |
macOS |
You can also self-host runners on your own infrastructure.
Action
An action is a reusable unit of automation — a packaged step that can be called with uses:. Actions are distributed through the GitHub Marketplace or referenced directly from any public repository.
uses: actions/checkout@v4 # check out your code
uses: actions/setup-node@v4 # install Node.js
uses: docker/build-push-action@v5 # build and push a Docker image
Workflow File Anatomy
name: CI # display name in GitHub UI
on: # trigger(s)
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test: # job ID (arbitrary)
name: Run tests # display name
runs-on: ubuntu-latest # runner
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
Key points:
- uses: actions/checkout@v4 clones the repository into the runner's workspace — almost every workflow needs this as the first step.
- with: passes input parameters to an action.
- run: executes a shell command. Multi-line commands use |:
Common Workflow Patterns
CI on push and pull request
The most fundamental workflow: run tests on every push and every PR targeting main. A failing check blocks the PR from merging (when branch protection requires it — Chapter 21).
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
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
Matrix builds — test across multiple versions
The strategy.matrix key runs the same job multiple times with different parameter values:
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 }}
- run: npm ci && npm test
This produces three parallel jobs — one per Node.js version — giving you confidence that your code works across your supported range.
Deploy on tag push
Trigger a deployment workflow only when a version tag is pushed (see Chapter 15 for tag conventions):
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:${{ github.ref_name }} .
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image
run: docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}
Sequential jobs with dependencies
Use needs: to run jobs in sequence — deploy only runs if test and build both pass:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- run: npm run build
deploy:
runs-on: ubuntu-latest
needs: [test, build]
steps:
- name: Deploy to production
run: ./scripts/deploy.sh
Expressions and Contexts
GitHub Actions provides contexts — objects containing information about the run, the repository, and the event that triggered it. Access them with ${{ }} syntax:
| Expression | Value |
|---|---|
${{ github.sha }} |
Full commit SHA |
${{ github.ref_name }} |
Branch or tag name |
${{ github.actor }} |
Username that triggered the workflow |
${{ github.repository }} |
owner/repo |
${{ github.event_name }} |
Event that triggered the workflow |
${{ runner.os }} |
OS of the runner |
${{ secrets.MY_SECRET }} |
A repository secret (never printed in logs) |
${{ env.MY_VAR }} |
An environment variable |
Conditional steps
Use if: to run a step only under certain conditions:
- name: Deploy (main branch only)
if: github.ref == 'refs/heads/main'
run: ./deploy.sh
- name: Notify on failure
if: failure()
run: ./notify-slack.sh "Build failed"
Built-in status functions: success(), failure(), cancelled(), always().
Secrets and Environment Variables
Repository secrets
Store sensitive values (API keys, deploy tokens, passwords) as repository secrets in:
Settings → Secrets and variables → Actions → New repository secret
Access in a workflow:
Secrets are masked in logs — their values are never printed, even if you accidentally echo them.
GITHUB_TOKEN is a special secret automatically provided in every workflow. It is scoped to the repository and expires when the workflow finishes. Use it for GitHub API calls, pushing to the repository, or publishing packages.
Environment variables
Set variables at the workflow, job, or step level:
env:
NODE_ENV: production # workflow-level
jobs:
build:
env:
BUILD_DIR: dist # job-level
steps:
- run: npm run build
env:
VITE_API_URL: https://api.example.com # step-level
Caching Dependencies
Downloading and installing dependencies on every run is slow. The actions/cache action (or the cache: input on language setup actions) stores dependency directories between runs:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # caches node_modules based on package-lock.json hash
For other languages:
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
The cache key changes when requirements.txt changes, busting the cache when dependencies update.
Artifacts
Jobs can produce files (build outputs, test reports, coverage data) and pass them between jobs or make them downloadable via the Actions tab.
- name: Upload build output
uses: actions/upload-artifact@v4
with:
name: build-dist
path: dist/
# In a later job:
- name: Download build output
uses: actions/download-artifact@v4
with:
name: build-dist
Reusable Workflows and Composite Actions
Reusable workflows
A workflow can call another workflow file using workflow_call, avoiding duplication across repositories:
# Caller workflow
jobs:
call-shared-ci:
uses: org/shared-workflows/.github/workflows/ci.yml@main
with:
node-version: '20'
secrets: inherit
Composite actions
For step-level reuse within a repository, create a composite action in .github/actions/<name>/action.yml:
name: 'Setup and Test'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci && npm test
shell: bash
Viewing and Debugging Workflows
- Actions tab — lists every workflow run; click a run to see each job and its steps with full logs
- Re-run jobs — re-run failed jobs (with or without debug logging) from the UI
- Enable debug logging — set repository secret
ACTIONS_STEP_DEBUGtotruefor verbose runner output act— an open-source tool that runs GitHub Actions workflows locally for faster iteration
Summary
- A workflow is a
.github/workflows/*.ymlfile triggered by GitHub events (push,pull_request,schedule,workflow_dispatch, etc.). - A workflow contains jobs, each running on a runner; jobs contain steps (shell commands or
uses:action calls). actions/checkoutandactions/setup-*are the standard first steps in almost every job.- Matrix builds run a job across multiple parameter values (e.g. multiple language versions) in parallel.
- Secrets are encrypted variables injected at runtime;
GITHUB_TOKENis provided automatically in every workflow. - Cache dependency directories to speed up runs; artifacts pass files between jobs or preserve build outputs.
- Branch protection (Chapter 21) can require specific workflow checks to pass before a PR is mergeable, making CI enforcement automatic.
Further reading: GitHub Actions documentation · GitHub Actions Marketplace
Previous: Chapter 23 — Git Hooks · Next: Chapter 25 — GitHub Pages