Understanding GitHub Actions
A pipeline written in YAML, paid for by GitHub.
The mental model behind workflows, jobs and steps — and the handful of gotchas that turn a "simple" CI file into a half-day debug.
Workflow → jobs → steps.
A workflow is one YAML file under .github/workflows/. Each workflow has one or more jobs; each job runs on a runner (a Linux/Windows/Mac VM); each job has one or more steps. Steps run sequentially within a job; jobs run in parallel by default unless needs: chains them. The defining unit of isolation is the job: separate jobs get separate VMs, separate caches, separate filesystems.
Triggers — the most-confused part.
on: push fires on every push to any branch. on: pull_request fires when a PR is opened or updated, and runs against the merge commit (not the branch HEAD). on: workflow_dispatch adds a manual "Run workflow" button. The most common bug: filtering with on: push: branches: [main] means the workflow runs only when main is pushed — pull requests targeting main don't trigger it. Use both push and pull_request with appropriate filters if you want it to fire in both shapes.
actions/checkout is step 1.
A fresh runner has no code on it. The first step in almost every workflow is uses: actions/checkout@v4, which clones the repository at the relevant commit. By default it clones depth 1 (no history) — if your build needs git history (changelog generators, conventional-commit tools, semantic-release), set with: { fetch-depth: 0 }. Forgetting this is a perennial cause of "works on my machine, fails in CI" bugs.
A worked Node workflow.
The minimum viable Node CI: name: ci
on: [push, pull_request]
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
Five steps: clone, install Node with built-in npm cache, install deps, test. The cache hint to setup-node automatically restores ~/.npm based on lockfile hash — no separate cache action needed.
Minimum CI shape
checkout + setup-node + install + test
Four steps cover 80 % of small projects.
actions/checkout + actions/setup-node + npm ci + npm test
= Working CI in ~10 lines
Caching beyond setup-* actions.
For caches that setup-node/setup-python/etc. don't manage, use actions/cache@v4 directly. The key is what determines a cache hit; almost always it should incorporate the lockfile's hash, plus the OS, plus the framework version. Misspecifying the key is the most common cache bug: too restrictive and you never get a hit; too loose and you restore stale state. The hierarchical fallback (restore-keys) lets you recover from a near-miss.
Secrets and permissions.
Repository secrets are environment variables available to workflow runs via ${{ secrets.NAME }}. Pull requests from forks do not get secrets by default — a critical security default that some workflows accidentally bypass with pull_request_target. Permissions are scoped via the workflow- level permissions block — default to contents: read and only grant write where needed. The principle of least privilege applies the same here as anywhere else.
Matrix builds.
To run the same job across Node 18, 20 and 22 — or across Ubuntu, macOS and Windows — use a matrix: strategy: { matrix: { node: [18, 20, 22] } } and reference ${{ matrix.node }} in your steps. Each combination runs as a separate parallel job; failing one doesn't necessarily fail the others (set fail-fast: false if you want them all to run regardless).
What Actions isn't suited for.
Actions runners are general-purpose VMs with limited disk and bandwidth; they're not ideal for long-running jobs (60-minute hard cap on Linux Free tier), heavy GPU work, or workloads with consistent enterprise SLAs. For those, self-hosted runners are available — you get the same workflow syntax against your own hardware. The free tier is generous for typical project CI; once you're paying for compute or running private repos, the cost model becomes worth understanding.