Build the mental model
This is the first workflow in the tutorial you could paste into a real repository and leave there. Every piece has appeared before alone; a project lesson shows how they constrain one another.
Start with the trigger. Running on both push to main and pull_request means the same checks guard a change before it merges and confirm the result afterwards, which is what makes a branch protection rule trustworthy. The concurrency block cancels superseded runs on the same branch, so a busy pull request does not queue five obsolete runs.
Next, the shape of the graph. Lint, typecheck and test have no dependency on each other, so they are three separate jobs with no needs, running in parallel. That is deliberate. One job running all three in sequence takes as long as the sum, and a lint failure hides the test results you wanted. Separate jobs also produce separate check runs, so the pull request page tells you which thing broke.
Because every job gets a fresh runner, node_modules cannot be handed from one job to the next; each job installs for itself. What makes that cheap is the cache. Keying ~/.npm on hashFiles('package-lock.json') means the key changes exactly when the dependency set changes, and the restore-keys prefix still gives a warm partial cache the first time it does.
Tests run under a matrix of Node versions because supporting a range is a promise you must keep continuously. fail-fast: false lets every version report instead of stopping at the first red one.
Finally, if: always() on the artifact upload. A failing test step would normally skip everything after it, which is precisely when you most want the report. And build carries needs: [lint, typecheck, test], so nothing is packaged until all three are green.
NODE CI JOB GRAPH
-----------------
push to main / pull_request into main
|
+-------------------+----------------------+
v v v
+---------------+ +---------------+ +---------------------+
| lint | | typecheck | | test (matrix) |
| node 20 | | node 20 | | node 18 / 20 / 22 |
| cache ~/.npm | | cache ~/.npm | | cache ~/.npm |
+---------------+ +---------------+ | fail-fast: false |
| | +---------------------+
| | |
| | +--> upload-artifact
| | | test-report-node-NN
| | | (if: always)
| | |
+-------------------+----------------------+
|
| needs: [lint, typecheck, test]
| all three green, or build is skipped
v
+-----------------+
| build |
| npm run build |
| upload dist/ |
+-----------------+Connect it to a real scenario
Save this as .github/workflows/ci.yml. There are four job definitions but six jobs actually run, because the matrix expands test into three. Lint, typecheck and all three test jobs start at the same time; none of them waits for anything.
Every job repeats the same four steps: checkout, setup-node, restore the cache, npm ci. The repetition looks wasteful and is unavoidable, because each job is a separate runner with an empty disk. The detail worth real attention is the cache key. The test job puts node-version into the key. Native modules compile against a specific Node version, so sharing one key across the whole matrix can hand Node 18 a binary that was built for Node 22, producing a failure that has nothing to do with your code.
The test step writes its report into reports/, and the next step uploads that folder with if: always(). Each matrix leg needs a distinct artifact name; upload-artifact@v4 rejects a second upload under a name that already exists in the run, which would fail the job.
The production stakes live in the build job. needs: [lint, typecheck, test] is a gate. If any upstream job fails, build is not failed, it is skipped, so a broken commit never produces a downloadable artifact at all. Wire these check names into a branch protection rule and the workflow stops being advice and becomes a rule: the merge button turns grey until the pipeline is green.
Try the working example
name: Node CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
- run: npm ci
- run: npm run lint
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
- run: npm ci
- run: npm run typecheck
test:
name: Test on Node ${{ matrix.node-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ['18', '20', '22']
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node${{ matrix.node-version }}-
- run: npm ci
- name: Run tests
run: npm test -- --reporter=junit --outputFile=reports/junit.xml
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report-node-${{ matrix.node-version }}
path: reports/
retention-days: 7
if-no-files-found: warn
build:
name: Build
needs: [lint, typecheck, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
- run: npm ci
- run: npm run build
- name: Upload build output
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
if-no-files-found: error
Every push and every pull request starts six jobs: lint, typecheck, and three test jobs for Node 18, 20 and 22, all beginning at once and running in parallel as runners become available. Each job restores the ~/.npm cache and runs npm ci. When the lockfile is unchanged the cache hits; when it changes the restore-keys prefix supplies a partial cache and a fresh cache entry is saved at the end of the run. Each test job uploads reports/ as an artifact regardless of the test result, under a distinct name per matrix leg: test-report-node-18, -20 and -22. Because fail-fast is false, one failing Node version does not stop the other two, so a single run tells you exactly which versions are affected. The build job starts only after lint, typecheck and all three test jobs succeed. If any of them fails, build is not failed but skipped, and no dist artifact is produced at all. The run as a whole is marked failed whenever any job fails, and because each job is its own check run, the pull request page shows immediately which part broke rather than one opaque red mark.5-minute try-it
Put this workflow in a repository and extend it twice. First, widen the matrix across operating systems: add os: [ubuntu-latest, windows-latest] and set runs-on: ${{ matrix.os }}. Notice that the cache key already contains runner.os, so nothing else needs changing. Second, change the build artifact's retention-days from 7 to 1 and think about why a test report and a build output might deserve different lifetimes. Then deliberately break one test and push. Confirm two things on the run page: that build shows as skipped rather than failed, and that the test report artifact is still downloadable even though the job that produced it went red.
One important caution
Caching the node_modules folder itself under one key shared by the whole matrix. Native modules are compiled per Node version, so a Node 18 job can restore binaries built for Node 22 and crash in ways that look nothing like a dependency problem. Cache ~/.npm instead and let npm ci run in every job.
Forgetting if: always() on the artifact upload. When a test step fails, every later step in that job is skipped by default, so the run that most needed a test report is the one run that has none attached.
GitHub Docs - Building and testing Node.js — CI/CD with GitHub Actions