Chapter 8 — GitHub Actions
GitHub Actions runs CI/CD workflows defined as YAML files in `.github/workflows/`. Every `push` or `pull_request` can trigger automated tests, builds, lints, deployments.
What you'll learn
- 1Write a basic workflow file: triggers, jobs, steps.
- 2Use the most common triggers (`push`, `pull_request`).
- 3Run tests across a matrix (multiple OS / Node versions).
- 4Cache dependencies between runs.
- 5Upload and download artifacts.
- 6Use secrets safely.
Core Concepts
1) Workflow anatomy
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Hello, Actions!"| Top-level key | What |
|---|---|
| `name` | Workflow display name |
| `on` | Trigger(s) |
| `jobs` | One or more parallel job groups |
| `steps` | Sequential actions inside a job |
2) Common triggers
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 9 * * 1" # every Monday 9am UTC
workflow_dispatch: # manual button3) Matrix builds
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}Runs `3 × 3 = 9` parallel jobs.
4) Caching
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}The cache restores from this key (or a prefix match) — huge speedup.
5) Artifacts
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/Download from the Actions UI; or use `actions/download-artifact` in a later job.
6) Secrets
In repo settings → Secrets and variables → Actions:
env:
API_KEY: ${{ secrets.API_KEY }}Never echo secrets in plain output — GitHub masks them but redaction is best-effort.
Examples
Example 1 — `ex01_hello.yml`: push triggers "Hello"
**Output**
=== workflow run ===
Run echo "Hello, Actions!"
Hello, Actions!Key: minimal viable workflow. Save under `.github/workflows/hello.yml` and push.
Example 2 — `ex02_test.yml`: Node project with `npm test`
**Output**
=== workflow run ===
checkout → setup-node@v4 → npm ci → npm test
all greenKey: `actions/setup-node` provisions Node; `npm ci` is the CI-friendly install.
Example 3 — `ex03_matrix.yml`: OS × Node matrix
**Output**
=== workflow run ===
ubuntu+18 ✓ ubuntu+20 ✓ ubuntu+22 ✓
macos+18 ✓ macos+20 ✓ macos+22 ✓
windows+18 ✓ windows+20 ✓ windows+22 ✓Key: catch portability bugs early; cost is parallel runner minutes.
Example 4 — `ex04_cache.yml`: cache `node_modules`
**Output**
=== workflow run ===
cache hit → install skipped (10s instead of 60s)Key: cache the install — biggest CI win for most projects.
Common mistakes
- **Workflow file in the wrong path** — must be `.github/workflows/*.yml`, not `.github/*.yml`.
- **Secrets in `if`** — Secrets are not available to `if:` expressions; use them in steps.
- **Forgetting `actions/checkout`** — your workflow runs against an empty workspace otherwise.
- **Matrix without `fail-fast: false`** — one fail aborts the rest; you lose visibility.
- **Long-running jobs** — Actions has timeouts; split or shorten.
Recap
- A workflow is YAML in `.github/workflows/`.
- Triggers say "when"; jobs say "what"; steps say "how".
- Matrix gives breadth; cache gives speed; artifacts give continuity.
- Secrets are encrypted; treat them with care anyway.
Try it
mkdir -p .github/workflows
cp src/ex01_hello.yml .github/workflows/hello.yml
git add . && git commit -m "ci: hello world workflow"
git push # check the Actions tab💻 Examples
Runnable examples — see the output yourself.
name: Hello World
on:
push:
branches: [main]
workflow_dispatch:
jobs:
greet:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Print environment info
run: |
echo "=== Hello, GitHub Actions! ==="
echo "Repository : ${{ github.repository }}"
echo "Branch : ${{ github.ref_name }}"
echo "Actor : ${{ github.actor }}"
echo "Commit SHA : ${{ github.sha }}"
echo "Runner OS : ${{ runner.os }}"
- name: List files
run: ls -la
=== workflow run ===
Run echo "Hello, Actions!"
Hello, Actions!name: Node.js CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint --if-present
- name: Run tests
run: npm test --if-present
- name: Build
run: npm run build --if-present
- name: Report status
if: always()
run: |
echo "Job status: ${{ job.status }}"
=== workflow run ===
checkout → setup-node@v4 → npm ci → npm test
all greenname: Matrix Build
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test on ${{ matrix.os }} / Node ${{ matrix.node-version }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
exclude:
- os: windows-latest
node-version: 18
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Show environment
run: |
node --version
npm --version
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test --if-present
summary:
name: Matrix Summary
needs: test
runs-on: ubuntu-latest
if: always()
steps:
- name: Print result
run: echo "All matrix jobs completed with status ${{ needs.test.result }}"
=== workflow run ===
ubuntu+18 ✓ ubuntu+20 ✓ ubuntu+22 ✓
macos+18 ✓ macos+20 ✓ macos+22 ✓
windows+18 ✓ windows+20 ✓ windows+22 ✓name: Build with Cache and Artifacts
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js with npm cache
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test --if-present
- name: Build
run: npm run build --if-present
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ github.sha }}
path: |
dist/
!dist/**/*.map
retention-days: 7
if-no-files-found: warn
deploy:
name: Deploy to production
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist-${{ github.sha }}
path: dist/
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
TARGET_HOST: ${{ secrets.TARGET_HOST }}
run: |
echo "Deploying to $TARGET_HOST..."
echo "Token length: ${#DEPLOY_TOKEN} chars"
echo "Deploy complete!"
=== workflow run ===
cache hit → install skipped (10s instead of 60s)📝 Exercises
Try them yourself first, then open the solution to compare.
Problem 1 (hw01.yml)
Goal: Write a workflow `ci.yml` that runs on `push` to `main` and any `pull_request`. The job should: checkout, set up Node 20, run `npm ci` and `npm test`.
- Filename: hw01.yml
▶Toggle solution
name: CI Report
on:
push:
branches: [main]
workflow_dispatch:
jobs:
report:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate report
run: |
{
echo "=== Build Report ==="
echo "Date: $(date)"
echo ""
echo "=== System Info ==="
uname -a
echo ""
echo "=== Recent Commits ==="
git log --oneline -5
} > report.txt
cat report.txt
- name: Upload report artifact
uses: actions/upload-artifact@v4
with:
name: ci-report
path: report.txt
retention-days: 3
Problem 2 (hw02.yml)
Goal: Extend the above to a matrix across `[ubuntu-latest, macos-latest]` × Node `[18, 20]`, with caching of `~/.npm`.
- Filename: hw02.yml
▶Toggle solution
name: Python Matrix CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python-version: ['3.10', '3.11', '3.12']
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then
pip install -r requirements.txt
else
echo "requirements.txt none — "
fi
- name: Run tests
run: |
if python -m pytest --version 2>/dev/null; then
python -m pytest
else
echo "pytest none — "
fi
- name: Show Python version
run: python --version
All lecture materials and example code are openly available on GitHub.
View on GitHub ↗