This is the second post in our GitHub and GitHub Actions series. Start with Git fundamentals if you're joining partway through.
We actually used GitHub Actions ourselves, in a real production deploy, documented in detail in an earlier case study — this post steps back to explain the underlying concepts that workflow was built from, from first principles.
The basic shape of a workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm testEvery workflow breaks down into the same four concepts:
- A workflow is the whole file — one automated process, triggered by something.
ondefines the trigger. Here, any push tomain, and any pull request targetingmain— meaning this test suite runs both on direct pushes and, importantly, on every PR before it merges, catching a problem before it reachesmainrather than after.- A job (
test, here) is a set of steps that run together, on a single fresh virtual machine (runs-on: ubuntu-latest). A workflow can have multiple jobs, which by default run in parallel, not in sequence. - A step is one action within a job — either a reusable
uses:action (likeactions/checkout@v4, which pulls your repository's code onto the runner) or a plainrun:shell command.
Every run starts from nothing
The single most important thing to internalize about a GitHub Actions runner: it's a brand new, empty virtual machine, every single time. Nothing persists between runs unless you explicitly arrange for it to. This is precisely why actions/checkout@v4 has to be the first step in nearly every job — without it, there's no code on the runner at all, just a blank Ubuntu machine.
Run npm test
> my-app@1.0.0 test
> jest
PASS src/auth.test.ts
PASS src/utils.test.ts
Test Suites: 2 passed, 2 total
Tests: 14 passed, 14 total
A job's overall status is determined by its steps: any step that exits with a non-zero status fails the entire job, and — by default — every step after it in that job is skipped, showing as a failed run in the GitHub UI, directly on the commit or pull request that triggered it.
Speeding up repeat runs with caching
Reinstalling every npm dependency from scratch, on every single run, is slow and — for a real project with real dependencies — genuinely wasteful. cache: "npm" on the setup-node action (already shown above) handles the common case automatically, but it's worth understanding what it's actually doing underneath:
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-The cache key is derived from a hash of package-lock.json — meaning the cache is reused whenever dependencies haven't changed, and automatically invalidated (a new key, a fresh cache) the moment they have. This is the general pattern behind effective CI caching everywhere, not just for npm: key the cache on whatever file actually determines whether the cached content is still valid.
Running jobs in parallel — and controlling dependencies between them
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run buildlint and test here run simultaneously, on two separate runners — there's no dependency between them, so there's no reason to force one to wait for the other, and running them in parallel is what keeps overall CI time down as a project's test suite grows. build's needs: [lint, test] explicitly makes it wait for both to succeed first — the correct dependency here, since there's little value building an artifact from code that hasn't even passed linting or tests yet.
Viewing what actually happened
gh run list --limit 5
gh run watch <run-id>
gh run view <run-id> --logThe gh CLI (which we've used directly, in exactly this form, in our own deploy pipeline) gives you the same information as the GitHub web UI's Actions tab, without leaving the terminal — genuinely useful for scripting around CI status, or just for a faster feedback loop while actively debugging a failing workflow.
What to actually remember from this post
- A runner starts completely empty, every single run — nothing persists unless you explicitly cache or restore it.
on:defines what triggers a workflow — pushes, pull requests, schedules, and several other event types all follow the same pattern.- Jobs run in parallel by default;
needs:is how you express an actual dependency between them. - Cache keys should be derived from whatever file determines cache validity — a lockfile hash is the standard pattern for dependency caching specifically.
Next in the series: Building Reusable GitHub Actions Workflows and Composite Actions, where we cover how to avoid copy-pasting the same steps across every repository a team owns.
