Part 6: GitHub Actions I — CI/CD Pipeline Fundamentals
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.
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 aconcurrencyblock cancels the older PR run immediately while letting builds onmainfinish normally. - Set a job timeout (
timeout-minutes): If you don't settimeout-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. Settimeout-minutes: 10or15on 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 onmain, butmainwill 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 setretention-days: 7on 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-minutesandpermissions: contents: read: Don't let a hung test burn 6 hours of runner time, and keepGITHUB_TOKENread-only unless a job actually needs to write. - Install with
--frozen-lockfileornpm 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-gateso you can add, remove, or rename matrix jobs freely inside your.ymlfile.
Series Navigation: ← Previous: Part 5: GitHub Pages, Gists, and Developer Portfolios | Next: Part 7: GitHub Actions II — Advanced Automation & Custom Runners → | All Articles