Skip to content

Lab: Build a Node.js CI Pipeline

Lesson 3 of 4Intermediate4 min readHands-On Git & GitHub Labs · CI/CD LabsVerified: Node.js 24.19.0, npm 11.17.0
Time20 minutes
LevelIntermediate
You needNode.js 22+ locally, and a GitHub repository to push to

A Node.js package with a handful of tests. CI was set up once by someone who has since left; it runs npm install and occasionally fails with a dependency version nobody has locally. The team wants a pipeline they understand, using nothing more than Node’s built-in test runner.

Build a CI workflow that installs from the lockfile with npm ci, runs node --test, caches node_modules correctly, and fails the way you expect when a test breaks — having watched each of those happen locally first.

Terminal window
mkdir -p /tmp/lab-nodeci/src /tmp/lab-nodeci/test && cd /tmp/lab-nodeci
git init -q -b main .
git config user.email "lab@example.com"
git config user.name "Lab User"
cat > package.json <<'EOF'
{
"name": "calc",
"version": "0.1.0",
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"test": "node --test",
"lint": "node --check src/*.js"
}
}
EOF
cat > src/calc.js <<'EOF'
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) throw new RangeError('cannot divide by zero');
return a / b;
}
EOF
cat > test/calc.test.js <<'EOF'
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { add, divide } from '../src/calc.js';
test('add', () => {
assert.equal(add(2, 3), 5);
});
test('divide', () => {
assert.equal(divide(6, 3), 2);
});
test('divide by zero throws', () => {
assert.throws(() => divide(1, 0), RangeError);
});
EOF
git add . && git commit -q -m "Node package with tests, no CI yet"

No dependencies, no lockfile — deliberately. The first task is to discover why that matters.

  1. Try npm ci. It is the command CI should use. Run it:

    Terminal window
    npm ci

    It refuses. Read why, then create the lockfile without installing anything:

    Terminal window
    npm install --package-lock-only
    git add package-lock.json && git commit -q -m "Add lockfile"
    npm ci
  2. Run the tests locally:

    Terminal window
    npm test

    Three pass. Note the summary lines — tests 3, pass 3, fail 0.

  3. Break one and watch the exit code. CI depends on it:

    Terminal window
    sed -i 's/assert.equal(add(2, 3), 5)/assert.equal(add(2, 3), 6)/' test/calc.test.js
    npm test; echo "exit: $?"
    sed -i 's/assert.equal(add(2, 3), 6)/assert.equal(add(2, 3), 5)/' test/calc.test.js
  4. Write the workflow:

    Terminal window
    mkdir -p .github/workflows
    cat > .github/workflows/ci.yml <<'EOF'
    name: Node CI
    on:
    pull_request:
    push:
    branches: [main]
    permissions:
    contents: read
    jobs:
    test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
    - uses: actions/checkout@v7
    - uses: actions/setup-node@v7
    with:
    node-version-file: package.json
    cache: npm
    - run: npm ci
    - run: npm run lint
    - run: npm test
    EOF
    git add . && git commit -q -m "Add CI"

    Two things to notice: node-version-file: package.json reads the engines field, so the version is declared once; and cache: npm keys the cache on package-lock.json.

  5. Push, open a pull request with the broken test from step 3, then fix it. Confirm the failure in the Actions log is the same assertion you saw locally, and that the fixed push goes green.

npm ci succeeds locally after the lockfile exists. npm test exits 0 with three passing tests and exits 1 with the broken one. On GitHub the first PR run is red with the same AssertionError, the second is green, and the second run’s setup-node step reports a cache hit.

Step 1. npm ci requires package-lock.json and installs exactly what it lists. npm install resolves versions fresh and may update the lockfile — which is the non-reproducibility the scenario describes.

Step 3. node --test sets a non-zero exit code when any test fails. That exit code is what turns a red test into a red check; a test runner that always exits 0 makes CI decorative.

Step 4. With no dependencies, npm ci creates no node_modules at all. That is fine here, and it becomes important in the Docker lab.

Step 1, before the lockfile — npm ci refuses:

npm error code EUSAGE
npm error
npm error The `npm ci` command can only install with an existing package-lock.json or
npm error npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or
npm error later to generate a package-lock.json file, then try again.

Step 2:

✔ add (3.519251ms)
✔ divide (0.670535ms)
✔ divide by zero throws (1.650768ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0

Step 3, broken:

AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
actual: 5,
expected: 6,
exit: 1

The workflow in step 4 is complete as written. A version matrix is a three-line addition, exactly as in the Python lab:

strategy:
matrix:
node-version: [22, 24]
steps:
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: npm

npm ci is the contract. It installs what the lockfile says, deletes any existing node_modules first, and never writes the lockfile. Every machine that runs it — every developer, every CI job — gets identical trees. npm install makes no such promise.

The lockfile is source. It is committed, reviewed and diffed like any other file. A pull request that changes it is a dependency change and should be read as one.

One version declaration. engines.node in package.json is read by setup-node through node-version-file, so upgrading Node is one edit in one place, and npm warns local developers running something older.

The built-in runner is enough. node --test needs no dependency, reports in TAP-compatible output, and exits non-zero on failure. Reach for a framework when you need its features, not by default.

npm ci fails with a lockfile mismatch. package.json and package-lock.json disagree, usually because someone edited package.json by hand. Run npm install once locally, commit the updated lockfile.

Cache never hits. cache: npm hashes package-lock.json. If the file is gitignored or missing, there is nothing to key on.

Tests pass locally, fail in CI with a module resolution error. "type": "module" and the .js extensions in imports must match; CI’s Node version may be stricter than yours. The engines field plus node-version-file keeps them aligned.

Terminal window
cd /tmp && rm -rf lab-nodeci

Build a Docker CI pipeline — multi-stage builds, the context you did not mean to send, and a test stage that silently never ran.

Choose a learning pathA sequenced route through the curriculum for wherever you are now.