The previous lesson ended with a promise that now has to be kept: "what is left is for you to do it". For six modules you have read Reservalia's ci.yml line by line, you have seen how an artifact is promoted by digest and how a rollback happens in four minutes. All of that was somebody else's pipeline. From here on the pipeline is yours: you write it, you break it on purpose, you fix it and you break it again until you understand why each thing fails. This first lab builds from scratch a project called Mini-Reservalia —a reduced, self-contained version of Reservalia, with the same appointments and availability domain but with no database, no AWS and no production dependencies— and puts its first continuous integration workflow on top of it, in four increments you can watch run one by one. By the end you will have a repository on GitHub where no change can reach main unless the tests are green, and you will have proved it by trying to get round it.
The project you create here is not thrown away: lessons 07-02 to 07-06 extend it. Take the code in this first part seriously, because it is the substrate for the whole module.
Contents
- Objective, prerequisites and starting point
- Preparing the machine and checking the tools
- Building Mini-Reservalia from scratch
- Initialising the repository and pushing it to GitHub
- Increment 1: the minimal workflow that just runs
- Increment 2: install, check and test
- Increment 3: two jobs in parallel with
needs - Increment 4: dependency cache and measuring its effect
- Breaking it on purpose and reading the red
- The real flow: branch, pull request, checks, merge
- Protecting
mainand checking that it blocks - The status badge in the README
- Final verification
- Common Mistakes and Tips
- Exercises
- Conclusion
- Objective, prerequisites and starting point
Objective. By the end of this lesson you will have a GitHub repository with a working Node.js project and a GitHub Actions workflow that, on every push and every pull request, installs dependencies reproducibly, runs static analysis and the tests in parallel, and blocks the merge to main if anything fails.
Prerequisites.
| Requirement | Why | How to check it |
|---|---|---|
| Node.js 20.6 or later | The project uses the native test runner (node:test) and ESM |
node --version |
| npm 10 or later | npm ci and lockfile v3 |
npm --version |
| Git 2.30 or later | Branches, remotes | git --version |
| A free GitHub account | Actions includes free minutes on public repositories | Go to github.com |
| Docker (optional) | Only used from 07-03 onwards | docker --version |
Starting point. An empty directory. There is nothing beforehand: this is kilometre zero.
Cost. Zero. GitHub Actions is free with unlimited minutes for public repositories. If you make the repository private you will draw on the monthly free allowance of the Free plan, which is far more than enough for this module. Recommendation: make it public.
The real equivalent in Reservalia. Reservalia's repository is private, with Actions running on larger hosted runners for the test jobs. Nothing you do here changes conceptually: what changes is the bill.
- Preparing the machine and checking the tools
Before writing a single line, check the environment. An uncomfortable proportion of real-world "the pipeline doesn't work for me" cases are really "I have Node 16 locally and 20 in CI".
# Step 0: environment check
node --version # expected: v20.6.0 or later (v22.x is fine too)
npm --version # expected: 10.x or later
git --version # expected: 2.30 or laterWhat you should see: three versions that meet the minimums. If node --version shows v18.x or lower, install a recent version (with nvm install 20 && nvm use 20, with the official installer or with your system's package manager). The native test runner has existed since Node 18, but the --test-shard option we will use in 07-02 requires Node 20.6+.
Set up your Git identity as well if you have not already, because commits with no author cause problems when computing metrics later on:
git config --global user.name "Your Name"
git config --global user.email "[email protected]"Optionally, install the GitHub CLI (gh). It is not mandatory —everything can be done from the web— but it shortens the steps considerably, and in 07-04 we will use it to compute the DORA metrics:
gh --version
gh auth login # choose GitHub.com > HTTPS > authenticate with browser
gh auth status # expected: "Logged in to github.com as <your-username>"
- Building Mini-Reservalia from scratch
Mini-Reservalia solves Reservalia's central problem —given a set of opening hours and some appointments already booked, which slots are still free?— with zero production dependencies. That constraint is deliberate: a project with no dependencies starts in milliseconds, can run on any runner and keeps the focus on the pipeline, which is what we are learning.
Create the structure:
3.1 package.json
{
"name": "mini-reservalia",
"version": "0.1.0",
"private": true,
"description": "Reduced version of Reservalia for the CI/CD practical module",
"type": "module",
"engines": {
"node": ">=20.6.0"
},
"scripts": {
"lint": "eslint .",
"test": "node --test test/",
"build": "node scripts/build.js",
"start": "node src/server.js"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"eslint": "^9.14.0"
}
}Four decisions that matter:
"type": "module": we use ESM (import/export), just like the real Reservalia."engines": documents the minimum version. It does not enforce it on its own, butnpm ciwarns about it and in 07-02 we will use it as the reference for the matrix.- The four scripts (
lint,test,build,start) are the contract between the project and the pipeline. This is what 06-07 called "logic in scripts and thin YAML": the workflow will not know how linting is done, only thatnpm run lintexists. If tomorrow you swap ESLint for something else,ci.ymldoes not get touched. - Zero production dependencies:
dependenciesdoes not even appear.
3.2 src/availability.js
This is the heart of the domain. Read it carefully, because in 07-02 you will write its edge cases.
// src/availability.js
// Free slot calculation for Mini-Reservalia.
// No dependencies: just arithmetic on minutes since midnight.
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
/**
* Converts "HH:MM" into minutes since midnight.
* @param {string} hhmm time in 24 h format
* @returns {number} minutes (0..1439)
*/
export function toMinutes(hhmm) {
if (typeof hhmm !== 'string' || !TIME_PATTERN.test(hhmm)) {
throw new TypeError(`Invalid time: ${JSON.stringify(hhmm)}. Expected "HH:MM" in 24 h format.`);
}
const [hours, minutes] = hhmm.split(':').map(Number);
return hours * 60 + minutes;
}
/**
* Converts minutes since midnight into "HH:MM".
* @param {number} minutes
* @returns {string}
*/
export function toTime(minutes) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
/**
* Normalises the appointment list: converts to minutes, discards degenerate ones,
* sorts and MERGES the overlaps. Without that merge, two appointments that step on
* each other would produce phantom slots.
*/
function normaliseBusy(appointments) {
return appointments
.map((appointment) => ({ start: toMinutes(appointment.start), end: toMinutes(appointment.end) }))
.filter((appointment) => appointment.end > appointment.start) // an appointment of zero or negative length takes up nothing
.sort((a, b) => a.start - b.start)
.reduce((merged, appointment) => {
const last = merged[merged.length - 1];
if (last && appointment.start <= last.end) {
last.end = Math.max(last.end, appointment.end); // they overlap or touch: merge them
} else {
merged.push({ ...appointment });
}
return merged;
}, []);
}
/** Slices the interval [from, to) into slots of `duration` minutes. */
function sliceIntoSlots(target, from, to, duration) {
for (let start = from; start + duration <= to; start += duration) {
target.push({ start: toTime(start), end: toTime(start + duration) });
}
}
/**
* Calculates the free slots of a day.
*
* @param {{start: string, end: string}[]|{start: string, end: string}} openingHours
* One or more opening blocks. Several blocks = split opening hours.
* @param {{start: string, end: string}[]} appointments Appointments already booked.
* @param {number} durationMin Service duration, in minutes.
* @returns {{start: string, end: string}[]} free slots, in chronological order.
*/
export function calculateSlots(openingHours, appointments = [], durationMin = 30) {
if (!Number.isInteger(durationMin) || durationMin <= 0) {
throw new RangeError(`The duration must be a positive whole number of minutes; received: ${durationMin}`);
}
const blocks = Array.isArray(openingHours) ? openingHours : [openingHours];
const busy = normaliseBusy(appointments);
const slots = [];
for (const block of blocks) {
const opening = toMinutes(block.start);
const closing = toMinutes(block.end);
if (closing <= opening) {
throw new RangeError(`Invalid block: ${block.start}-${block.end}. Closing must come after opening.`);
}
let cursor = opening;
for (const appointment of busy) {
if (appointment.end <= cursor || appointment.start >= closing) continue; // outside this block
sliceIntoSlots(slots, cursor, Math.min(appointment.start, closing), durationMin);
cursor = Math.max(cursor, appointment.end); // an appointment crossing closing time trims the block
if (cursor >= closing) break;
}
if (cursor < closing) sliceIntoSlots(slots, cursor, closing, durationMin);
}
return slots;
}Three details that are a common source of real bugs and are worth seeing now, because in 07-02 you will turn them into tests:
| Case | Behaviour | Why |
|---|---|---|
| Two overlapping appointments (10:00-11:00 and 10:30-11:30) | They merge into a single busy period 10:00-11:30 | Otherwise the algorithm would "see" a slot between them |
| An appointment starting before closing and ending after it (13:45-14:30 with closing at 14:00) | Trims the block up to closing time | The cursor moves to 14:30, later than closing; it leaves the loop |
| Split opening hours (09:00-14:00 and 16:00-20:00) | The slots never cross the break | Each block is sliced independently |
3.3 src/server.js
An HTTP server with no dependencies and two routes: /health (which the pipeline will use as a smoke test in 07-03) and /api/slots.
// src/server.js
// Minimal HTTP server for Mini-Reservalia. No external dependencies.
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { calculateSlots } from './availability.js';
export const VERSION = process.env.APP_VERSION ?? 'dev';
export const PORT = Number(process.env.PORT ?? 3000);
/** Default opening hours for the demo business: morning and afternoon. */
export const DEFAULT_OPENING_HOURS = [
{ start: '09:00', end: '14:00' },
{ start: '16:00', end: '20:00' },
];
/** In-memory diary. In 07-02 it is replaced by a persistence layer. */
export const DEMO_SCHEDULE = new Map([
['2026-03-02', [{ start: '10:00', end: '10:30' }, { start: '17:00', end: '18:00' }]],
['2026-03-03', [{ start: '09:00', end: '12:00' }]],
]);
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function respondJson(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(text),
});
res.end(text);
}
/**
* Creates the server. It is exported as a function so that the tests can
* start it on an ephemeral port without touching environment variables.
*/
export function createServer({ schedule = DEMO_SCHEDULE, openingHours = DEFAULT_OPENING_HOURS } = {}) {
return http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
if (req.method === 'GET' && url.pathname === '/health') {
return respondJson(res, 200, {
status: 'ok',
version: VERSION,
uptimeSec: Math.round(process.uptime()),
});
}
if (req.method === 'GET' && url.pathname === '/api/slots') {
const date = url.searchParams.get('date');
const duration = Number(url.searchParams.get('duration') ?? 30);
if (!date || !DATE_PATTERN.test(date)) {
return respondJson(res, 400, { error: 'The "date" parameter is required, in YYYY-MM-DD format' });
}
if (!Number.isInteger(duration) || duration <= 0) {
return respondJson(res, 400, { error: 'The "duration" parameter must be a positive whole number of minutes' });
}
try {
const appointments = schedule.get(date) ?? [];
const slots = calculateSlots(openingHours, appointments, duration);
return respondJson(res, 200, { date, duration, total: slots.length, slots });
} catch (error) {
return respondJson(res, 400, { error: error.message });
}
}
return respondJson(res, 404, { error: 'Route not found' });
});
}
// Only starts if run directly (node src/server.js).
// When imported from a test, no port is opened.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
createServer().listen(PORT, () => {
console.log(`Mini-Reservalia ${VERSION} listening on http://localhost:${PORT}`);
});
}The guard at the end (process.argv[1] === fileURLToPath(import.meta.url)) is the ESM equivalent of the classic if __name__ == "__main__". Without it, any test importing the module would open a port and the test process would never finish: a classic and bewildering CI hang.
3.4 test/availability.test.js
// test/availability.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { calculateSlots, toMinutes, toTime } from '../src/availability.js';
const MORNING = { start: '09:00', end: '12:00' };
test('toMinutes converts valid times', () => {
assert.equal(toMinutes('00:00'), 0);
assert.equal(toMinutes('09:30'), 570);
assert.equal(toMinutes('23:59'), 1439);
});
test('toMinutes rejects invalid formats', () => {
assert.throws(() => toMinutes('9:00'), TypeError);
assert.throws(() => toMinutes('25:00'), TypeError);
assert.throws(() => toMinutes(900), TypeError);
});
test('toTime is the inverse of toMinutes', () => {
for (const time of ['00:00', '07:05', '13:45', '23:59']) {
assert.equal(toTime(toMinutes(time)), time);
}
});
test('a day with no appointments is sliced end to end', () => {
const slots = calculateSlots(MORNING, [], 60);
assert.deepEqual(slots, [
{ start: '09:00', end: '10:00' },
{ start: '10:00', end: '11:00' },
{ start: '11:00', end: '12:00' },
]);
});
test('an appointment splits the day into two blocks', () => {
const slots = calculateSlots(MORNING, [{ start: '10:00', end: '11:00' }], 60);
assert.deepEqual(slots, [
{ start: '09:00', end: '10:00' },
{ start: '11:00', end: '12:00' },
]);
});
test('the leftover remainder does not produce a short slot', () => {
// From 09:00 to 12:00 with 50 min slots, 3 fit (up to 11:30) and 30 min are left over.
const slots = calculateSlots(MORNING, [], 50);
assert.equal(slots.length, 3);
assert.equal(slots.at(-1).end, '11:30');
});
test('an invalid duration is a programming error, not an empty result', () => {
assert.throws(() => calculateSlots(MORNING, [], 0), RangeError);
assert.throws(() => calculateSlots(MORNING, [], 12.5), RangeError);
});Six tests is not many: in 07-02 we will climb to the three layers of the pyramid with edge cases. For a first pipeline it is enough to have a real signal that can turn red.
3.5 scripts/build.js
// scripts/build.js
// Mini-Reservalia "build": copies src/ into dist/ and stamps the version.
// It is deliberately trivial, but it plays the role of the real build: it produces
// an identifiable artifact and fails if something cannot be resolved.
import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
import { execSync } from 'node:child_process';
const TARGET = new URL('../dist/', import.meta.url);
function currentCommit() {
if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA;
try {
return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
} catch {
return 'unknown';
}
}
await rm(TARGET, { recursive: true, force: true });
await mkdir(TARGET, { recursive: true });
await cp(new URL('../src/', import.meta.url), new URL('src/', TARGET), { recursive: true });
const stamp = {
name: 'mini-reservalia',
version: process.env.npm_package_version ?? '0.0.0',
commit: currentCommit(),
builtAt: new Date().toISOString(),
};
await writeFile(new URL('version.json', TARGET), `${JSON.stringify(stamp, null, 2)}\n`);
// Smoke check of the build itself: if the module does not import, we fail here
// and not in production.
await import('../dist/src/server.js');
console.log(`Build OK -> dist/ (commit ${stamp.commit.slice(0, 7)})`);Notice the await import(...) at the end: it is a verification of the artifact inside the build itself. If someone leaves a broken import, the build fails in 200 ms instead of the smoke test discovering it three stages later.
3.6 eslint.config.js
// eslint.config.js (flat config, ESLint 9)
import js from '@eslint/js';
export default [
{ ignores: ['dist/**', 'node_modules/**', 'coverage/**'] },
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
globals: {
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
URL: 'readonly',
fetch: 'readonly',
setTimeout: 'readonly',
},
},
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-console': 'off',
eqeqeq: ['error', 'always'],
},
},
];3.7 .gitignore
node_modules/ and dist/ out of the repository, of course. And .env from day one: 07-05 will explain in detail what a committed secret costs, but prevention starts here.
3.8 Checking that everything works locally
npm install # generates package-lock.json
npm run lint # expected: no output (all clean)
npm test # expected: 7 tests green
npm run build # expected: "Build OK -> dist/ (commit ...)"
npm start # starts on http://localhost:3000What you should see when running npm test:
▶ toMinutes converts valid times ✔ toMinutes converts valid times (0.9ms) ... ℹ tests 7 ℹ suites 0 ℹ pass 7 ℹ fail 0
And with the server running, in another terminal:
curl -s http://localhost:3000/health
# {"status":"ok","version":"dev","uptimeSec":12}
curl -s "http://localhost:3000/api/slots?date=2026-03-02&duration=60"
# {"date":"2026-03-02","duration":60,"total":6,"slots":[{"start":"09:00","end":"10:00"}, ...]}If this works on your machine, you already have the hard part: a project that knows how to verify itself with a single command. The pipeline is only going to run those commands on a machine that is not yours.
- Initialising the repository and pushing it to GitHub
git init -b main
git add .
git commit -m "feat: Mini-Reservalia with slot calculation and HTTP server"With the GitHub CLI, in one command:
What you should see: https://github.com/<your-username>/mini-reservalia and, when you open it, the files you have just created.
Without gh: create the empty repository from the web (no README, no .gitignore, no licence, to avoid a divergent history) and then:
git remote add origin https://github.com/<your-username>/mini-reservalia.git
git push -u origin mainCheck that package-lock.json is in the repository. It is the requirement for the reproducible build from 02-03: without a lockfile, npm ci does not work and every pipeline run could install different versions.
- Increment 1: the minimal workflow that just runs
We are going to build ci.yml in four steps, watching each one run. The temptation is to write the whole workflow in one go; resist it. When an 80-line workflow fails on the first attempt, you do not know which of the 80 lines is to blame.
File .github/workflows/ci.yml — version 1:
# .github/workflows/ci.yml - INCREMENT 1
# Goal: check that the workflow triggers and that the runner works.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
hello:
name: Runner check
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v4
- name: Show the environment
run: |
echo "Runner: $RUNNER_OS"
echo "Branch: $GITHUB_REF_NAME"
echo "Commit: $GITHUB_SHA"
node --version
npm --version
ls -laWhat you should see. Go to the Actions tab of your repository. A run called "ci: minimal runner check workflow" should appear, with the CI workflow. Open it, go into the Runner check job and expand the "Show the environment" step. You will see something like:
Runner: Linux Branch: main Commit: 8f3c1e2... v20.18.0 10.8.2 total 48 drwxr-xr-x 5 runner docker 4096 ... -rw-r--r-- 1 runner docker 612 package.json
Three things you have just learnt empirically and that no amount of reading replaces:
- The runner does not have your code by default. If you remove the
actions/checkout@v4step,ls -lacomes back almost empty. Try it if you like. - Node is already installed on the
ubuntu-latestrunner, but at whatever version GitHub decides. That is why increment 2 pins the version explicitly. - The context travels in environment variables:
GITHUB_SHA,GITHUB_REF_NAMEand dozens more, exactly as we saw in 06-06.
Expected duration: between 5 and 15 seconds. Make a note of it; we will use it as a reference.
- Increment 2: install, check and test
Now the pipeline does real work.
# .github/workflows/ci.yml - INCREMENT 2
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
verify:
name: Verify
runs-on: ubuntu-latest
steps:
- name: Check out the code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Static analysis
run: npm run lint
- name: Tests
run: npm test
- name: Build
run: npm run buildWhat you should see. The run now takes about 35-60 seconds. Every step green. Pay particular attention to the log of "Install dependencies":
And to the one for "Tests":
Why npm ci and not npm install. We saw this in 02-03, but now you can verify it: npm ci deletes node_modules, installs exactly what package-lock.json says and fails if the lockfile does not match package.json. npm install can update the lockfile silently, which means the pipeline would be testing a different dependency tree from the one you tested. Check it for yourself:
# Locally, edit package.json and bump the eslint version to "^9.99.0" WITHOUT touching the lock
npm ci
# npm error `npm ci` can only install packages when your package.json and
# npm error package-lock.json are in sync.Undo that change before carrying on.
- Increment 3: two jobs in parallel with
needs
needsA single job is a single file queue: if the lint takes 20 seconds, the tests wait 20 seconds. Worse still: if the lint fails, you do not see the test results, so you fix the lint, wait again and discover there is a broken test too. Two round trips where there should have been one.
04-01 called this "feedback in a single pass". Let us split things up.
# .github/workflows/ci.yml - INCREMENT 3
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# If two pushes arrive in a row on the same branch, cancel the earlier one:
# nobody needs the result of a commit that has already been superseded.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: ESLint
run: npm run lint
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Unit tests
run: npm test
build:
name: Build
runs-on: ubuntu-latest
# It only builds if quality AND tests have passed: there is no point
# spending time building something we already know is broken.
needs: [quality, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Build the artifact
run: npm run build
- name: Publish dist/ as an artifact
uses: actions/upload-artifact@v4
with:
name: dist-${{ github.sha }}
path: dist/
retention-days: 7git add .github/workflows/ci.yml
git commit -m "ci: split quality and test in parallel, build after both"
git pushWhat you should see. In the run view, GitHub draws the graph: Quality and Tests side by side, and Build to their right with the dependency arrows. The first two start at the same time. At the end, at the bottom of the run page, the Artifacts section appears with a downloadable dist-<sha>.
What you have gained:
| One job (increment 2) | Three jobs (increment 3) | |
|---|---|---|
| Feedback if the lint fails | Lint only | Lint and tests at once |
| Wall-clock time | Sum of everything | Maximum of the parallel branches + build |
| Cost in minutes | Lower (one machine) | Higher (three machines) |
| Artifact | Stays on the runner | Downloadable for 7 days |
It is the classic trade-off: parallelising reduces wall-clock time and increases minute consumption. In public repositories the minutes are free, so the decision is obvious; in a private one with hundreds of daily runs it needs thinking about. Note as well that npm ci is repeated three times —once per job, because each job is a clean machine—. That is what increment 4 attacks.
- Increment 4: dependency cache and measuring its effect
actions/setup-node knows how to cache the npm directory. A two-line change:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # <-- added
cache-dependency-path: package-lock.jsonApply it in all three jobs. The complete file, the version that closes this lesson:
# .github/workflows/ci.yml - FINAL VERSION FOR 07-01
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Least privilege: this workflow only needs to read the code.
# 07-05 goes deeper into this; it goes in now so as not to pick up the bad habit.
permissions:
contents: read
jobs:
quality:
name: Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- run: npm ci
- name: ESLint
run: npm run lint
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- run: npm ci
- name: Unit tests
run: npm test
build:
name: Build
runs-on: ubuntu-latest
needs: [quality, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- run: npm ci
- name: Build the artifact
run: npm run build
- name: Publish dist/ as an artifact
uses: actions/upload-artifact@v4
with:
name: dist-${{ github.sha }}
path: dist/
retention-days: 7How to measure the effect properly. The first run with the cache is slower, because it has to save it. The honest measurement compares the second run with the cache against the baseline:
- Note the time of the
npm cistep from increment 3 (no cache). It is right there in the log:Run npm ci ... 8s. - Push the change. First run: in the
setup-nodelog you will seeCache not found for input keys: node-cache-Linux-npm-...and at the endCache saved with key: .... - Make a second, trivial push (change the README). Now the log will say
Cache restored from key: node-cache-Linux-npm-<hash>.
Typical table for a project this size:
| Measurement | npm ci |
Total run |
|---|---|---|
| No cache | 7-9 s | ~45 s |
| With cache (first time) | 7-9 s + saving | ~50 s |
| With cache (from the second run on) | 2-3 s | ~30 s |
Five seconds per job does not sound like much. Multiply it by three jobs, by 40 runs a day and by 250 working days: that is roughly 40 machine hours a year on a project with no production dependencies. At Reservalia, with a node_modules of hundreds of megabytes, the cache saves minutes per run, not seconds. 04-04 quantified it; now you have seen it.
An important detail about the cache key. cache-dependency-path: package-lock.json makes the key include the hash of the lockfile. When you change a dependency, the key changes and everything is downloaded again: that is exactly what you want. A cache whose key does not depend on the lockfile is a cache that serves stale dependencies, and that is the "false green" 04-04 talked about.
- Breaking it on purpose and reading the red
A pipeline you have never seen fail is not a pipeline: it is decoration. Let us break it.
Edit src/availability.js and change a single line inside sliceIntoSlots:
function sliceIntoSlots(target, from, to, duration) {
- for (let start = from; start + duration <= to; start += duration) {
+ for (let start = from; start < to; start += duration) {
target.push({ start: toTime(start), end: toTime(start + duration) });
}
}It is a realistic bug: a final slot is now generated that sticks out past closing time. A business would take bookings at 11:30 when it closes at 12:00 and the service lasts 50 minutes.
What you should see locally:
✖ the leftover remainder does not produce a short slot (1.2ms) AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 4 !== 3 ℹ tests 7 ℹ pass 6 ℹ fail 1
Push it anyway, because that is what we want to observe:
Now open the pull request:
gh pr create --title "Slice up to the end of the block" --body "Change in the slicing loop." --base mainWhat you should see in the PR:
- A checks box at the bottom of the conversation with three entries:
Quality,Tests,Build. Qualitygreen (ESLint does not detect logical bugs: it only looks at the shape of the code; this is exactly the warning from 02-05).Testsred, with the cross.Buildgrey, marked as skipped: it never got to run because itsneedswas not satisfied. You have saved a job.- The merge button greyed out with the message "Some checks were not successful".
Click Details on the red check. GitHub takes you to the log of the failed step, already expanded and with the error line highlighted. On top of that, in the PR's Files changed tab a red annotation appears over the line of the test file: Node's test runner emits the failure in a format Actions recognises, so the error is shown on the code, not just in the log.
Now fix it inside the same PR:
git checkout src/availability.js # revert the change
npm test # green locally
git commit -am "revert: restore the correct slicing"
git pushWhat you should see: the PR re-runs the checks automatically (thanks to the pull_request trigger, which also fires on synchronize), all three turn green and the merge button becomes enabled. Merge it:
You have just walked the full cycle: branch → PR → red → diagnosis → fix → green → merge. It is the cycle a team repeats twenty times a day.
- The real flow: branch, pull request, checks, merge
Let us formalise what you have just done, because the order matters and there is one step almost everybody skips.
flowchart LR
A["git checkout -b branch"] --> B["Small change<br/>+ test"]
B --> C["npm test LOCALLY"]
C -->|red| B
C -->|green| D["push + pull request"]
D --> E["CI runs<br/>quality / test / build"]
E -->|red| F["Read the log,<br/>reproduce locally"]
F --> B
E -->|green| G["Human review"]
G --> H["Merge to main"]
H --> I["CI on main"]
The step people skip is npm test locally before the push. Using CI as a command interpreter —pushing to see whether it passes— turns a 3-second cycle into a 3-minute one and fills the history with commits called "fix CI", "really fix CI", "now yes". Rule of thumb: if you cannot run the command the pipeline is going to run, the pipeline is badly designed. That is why the four package.json scripts are the contract.
- Protecting
main and checking that it blocks
main and checking that it blocksSo far the checks are informational: nothing stops you merging when red, or pushing straight to main. Let us close that door, which is the "stop the line" of 02-01 turned into configuration.
From the web: Settings → Rules → Rulesets → New ruleset → New branch ruleset.
- Name:
protect-main - Enforcement status:
Active - Target branches: Add target → Include default branch
- Tick Require a pull request before merging (with
Required approvals: 0if you work alone; on a team, 1). - Tick Require status checks to pass, then search for and add
Quality,TestsandBuild. Also tick Require branches to be up to date before merging. - Tick Block force pushes.
With gh, in a single command (it creates the file and applies it):
cat > /tmp/ruleset.json <<'JSON'
{
"name": "protect-main",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false
} },
{ "type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "Quality" },
{ "context": "Tests" },
{ "context": "Build" }
]
} }
]
}
JSON
gh api --method POST "repos/{owner}/{repo}/rulesets" --input /tmp/ruleset.jsonChecking that it fails when it should fail. A protection you have never tried to violate is one you do not know exists. Two tests:
Test A — direct push to main:
What you should see:
remote: error: GH013: Repository rule violations found for refs/heads/main. remote: - Changes must be made through a pull request. ! [remote rejected] main -> main (push declined due to repository rule violations)
Undo the local commit: git reset --hard origin/main.
Test B — merging a red PR:
Break the test again (change assert.equal(slots.length, 3) to 4 in test/availability.test.js), commit, push, open the PR and try to merge:
What you should see:
or, from the web, the merge button disabled with "Required statuses must pass before merging". Close the PR and delete the branch:
This negative check —that the system prevents what it is supposed to prevent— is as important as the positive one, and it is the one almost nobody does. A badly written required check (for example, with the name test instead of Tests) stays eternally "pending" and blocks everything; or, worse, if you configure it on a job that can be skipped, it lets anything through.
The detail that costs everybody half an hour: the name of the required check is the job's
name:, not the job key in the YAML. Ourtest:job is calledTestsbecause it hasname: Tests. If you add the check with the wrong name, GitHub waits for it forever and the PR is never mergeable. If it happens to you, the list of exact names is in the checks box of any recent PR.
- The status badge in the README
Create README.md:
# Mini-Reservalia [](https://github.com/<your-username>/mini-reservalia/actions/workflows/ci.yml) Reduced version of Reservalia for the practical module of the CI/CD course. Calculates the free slots of a day from a set of opening hours and the appointments already booked. ## Usage
npm ci npm test npm start # http://localhost:3000
## Endpoints | Method | Route | Description | |---|---|---| | GET | `/health` | Service status and deployed version | | GET | `/api/slots?date=YYYY-MM-DD&duration=30` | Free slots for the day | ## Pipeline | Stage | What it does | Breaks the build | |---|---|---| | Quality | ESLint over all the code | Yes | | Tests | `node --test` | Yes | | Build | `dist/` stamped with the commit | Yes |
Push it through a PR (you can no longer push to main, precisely):
git checkout -b docs-readme
git add README.md
git commit -m "docs: README with CI badge"
git push -u origin docs-readme
gh pr create --fill
# ...wait for the checks to pass...
gh pr merge --squash --delete-branchWhat you should see: on the repository front page, a green badge saying CI: passing. The ?branch=main matters: without it, the badge reflects the latest run of any branch, so a broken experiment on a personal branch would turn the badge red and it would stop meaning anything.
- Final verification
Work through this list. Everything must hold before moving on to 07-02.
| # | Check | How to verify it | Expected result |
|---|---|---|---|
| 1 | The project works locally | npm ci && npm test && npm run build |
7 tests green, dist/ created |
| 2 | The server responds | npm start and curl localhost:3000/health |
{"status":"ok",...} |
| 3 | The workflow triggers on push | Actions tab after a push to a branch | A new run |
| 4 | The workflow triggers on PR | Open a PR | Three checks in the conversation |
| 5 | The jobs run in parallel | Run graph | Quality and Tests at the same level |
| 6 | The cache works | setup-node log on the 2nd run |
Cache restored from key: ... |
| 7 | The pipeline turns red | Break a test and push | Tests check red, Build skipped |
| 8 | main rejects a direct push |
git push from main |
GH013: Repository rule violations |
| 9 | A red PR cannot be merged | Merge button | Disabled |
| 10 | The artifact is available | Artifacts section of the run | dist-<sha> downloadable |
| 11 | The badge is green | Repository front page | CI: passing |
The three in bold are the ones that really prove the pipeline is worth something. A pipeline you have only ever seen green is an untested hypothesis.
Common Mistakes and Tips
Symptom: the workflow does not appear in the Actions tab after the push.
Cause: the file path is wrong. It must be exactly .github/workflows/ci.yml, at the root of the repository. github/workflows/, .github/workflow/ or .github/actions/ will not do.
Fix: git ls-files .github must show .github/workflows/ci.yml. If it does not, move the file and push again.
Symptom: Error: Dependencies lock file is not found in /home/runner/work/....
Cause: you have put cache: 'npm' in setup-node but package-lock.json is not committed (probably because of an overly aggressive .gitignore).
Fix: check that package-lock.json is not in .gitignore, run npm install to generate it and commit it. The lockfile always goes into the repository.
Symptom: npm error code EUSAGE — npm ci can only install packages when your package.json and package-lock.json are in sync.
Cause: you edited package.json by hand without regenerating the lockfile.
Fix: locally, npm install (which does update the lock) and commit both files together. A package.json and a lock that do not match are the number one cause of "it works on my machine".
Symptom: the test job hangs until the 6-hour timeout.
Classic cause in Node: some module imported by the tests opens a port or a timer and the process never ends. That is why src/server.js has the process.argv[1] === fileURLToPath(import.meta.url) guard.
Fix: add timeout-minutes: 10 to every job —it should be a reflex— and investigate locally with node --test --test-reporter=spec test/. If the process does not end, the culprit is an open resource.
Symptom: the required check stays at Expected — Waiting for status to be reported forever.
Cause: the check name configured in the ruleset does not match the job's name:, or the job does not run in that context (for example, you have a paths filter that skips it).
Fix: copy the exact names from the checks box of a recent PR. And beware of paths filters: a required job that gets skipped by filters blocks the PR indefinitely; the usual solution is a "permanently green" job that always runs and that the others depend on.
Symptom: two runs of the same branch, the first cancelled with "Canceling since a higher priority waiting request exists".
Cause: it is not an error. It is your concurrency block with cancel-in-progress: true doing its job.
Tip: do not put this in the deployment workflow with cancel-in-progress: true; cancelling a deployment halfway can leave the system in an inconsistent state. In CI it is fine; in CD, 07-03 will use cancel-in-progress: false.
Symptom: ESLint fails with Parsing error: 'import' and 'export' may appear only with 'sourceType: module'.
Cause: "type": "module" is missing from package.json, or sourceType: 'module' from eslint.config.js.
Fix: both must be there. In this project, both of them.
Hygiene tip: small, frequent commits. 02-07 justified it in terms of continuous integration; here you will notice it immediately: when the pipeline turns red after a 400-line commit, the diagnosis is archaeology; after a 20-line one, it is obvious.
Exercises
Exercise 1: a formatting check job with --check
Add to the pipeline a formatting check with Prettier that fails if the code is not formatted, plus a format script that fixes it. The job must be called Format and run in parallel with Quality and Tests. Check that it fails by unformatting a file on purpose.
Exercise 2: do not run the pipeline when only documentation changes
A change to README.md does not need to run three jobs. Configure the workflow to skip changes that only touch Markdown, without breaking branch protection. Think it through: if the required checks do not run, the PR is blocked forever. Explain in a YAML comment why your solution does not fall into that trap.
Exercise 3: a readable run summary
Make the Build job write a table into $GITHUB_STEP_SUMMARY with the commit, the version, the size of dist/ and the number of tests run, so that it is visible on the run's front page without opening any log.
Solutions
Solution 1.
.prettierrc.json:
.prettierignore:
In package.json, two new scripts:
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"test": "node --test test/",
"build": "node scripts/build.js",
"start": "node src/server.js"
}Run npm run format once to normalise the whole project and commit the result in its own commit (an isolated "formatting commit", so that it does not pollute future diffs). Then, the job:
format:
name: Format
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- run: npm ci
- name: Check formatting
run: npm run format:checkAnd add format to the needs of the build job: needs: [quality, format, test].
Checking that it fails: put odd spacing into src/server.js and push. You will see:
Checking formatting... [warn] src/server.js [warn] Code style issues found in the above file. Run Prettier with --write to fix. Error: Process completed with exit code 1.
The difference between --write and --check is exactly the difference between a development tool and a quality gate: the pipeline never modifies the code, it only reports. A pipeline that autoformats and commits generates phantom commits, triggers new runs and can end up in a loop.
Solution 2.
The trap: if you add paths-ignore: ['**.md'] to the trigger, then on a PR that only touches Markdown the jobs do not run, the required checks never report and the PR is stuck at Expected forever.
There are two correct solutions. The simple and robust one:
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- id: filter
# We compare against the PR base (or against the previous commit on push).
run: |
BASE="${{ github.event.pull_request.base.sha || github.event.before }}"
if git diff --name-only "$BASE" HEAD | grep -qvE '\.(md|txt)$|^docs/'; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Documentation only: the heavy checks are skipped." >> "$GITHUB_STEP_SUMMARY"
fi
test:
name: Tests # <-- the required check ALWAYS exists
needs: changes
runs-on: ubuntu-latest
steps:
- name: Skipped because it is documentation only
if: needs.changes.outputs.code != 'true'
run: echo "No code changes; nothing to test."
- uses: actions/checkout@v4
if: needs.changes.outputs.code == 'true'
- uses: actions/setup-node@v4
if: needs.changes.outputs.code == 'true'
with: { node-version: '20', cache: 'npm' }
- run: npm ci
if: needs.changes.outputs.code == 'true'
- run: npm test
if: needs.changes.outputs.code == 'true'The key point, and what has to go in the YAML comment: the required job always runs and always reports green; what gets skipped are its expensive steps. A job that exists and does nothing costs ~5 seconds and keeps branch protection working. A job that does not exist blocks the PR forever.
The advanced alternative —and what Reservalia does— is the one from 04-04: paths on the trigger plus an aggregator job ci-ok with if: always() that evaluates the result of the others and is the only required check.
Solution 3.
Add this to the build job, after building:
- name: Run summary
run: |
SIZE=$(du -sh dist | cut -f1)
VER=$(node -p "require('./dist/version.json').version")
COM=$(node -p "require('./dist/version.json').commit.slice(0,7)")
N_TESTS=$(npm test 2>&1 | grep -oP '(?<=^# pass )\d+' || echo "?")
{
echo "## Artifact built"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Commit | \`$COM\` |"
echo "| Version | $VER |"
echo "| Size of dist/ | $SIZE |"
echo "| Tests green | $N_TESTS |"
echo "| Branch | \`${{ github.ref_name }}\` |"
echo "| Author | @${{ github.actor }} |"
} >> "$GITHUB_STEP_SUMMARY"A better version avoids re-running the tests: make the test job write the number into an output and consume it here with needs.test.outputs.tests. Re-running the suite just to count it is the kind of waste 04-04 called "duplicated work for convenience".
$GITHUB_STEP_SUMMARY is Markdown that gets rendered on the run's front page. It is the most underrated tool in GitHub Actions: it turns a pipeline into something a tech lead can read in ten seconds without opening a log. We will use it heavily in 07-02 (coverage) and in 07-04 (DORA metrics).
Optional Challenge
Make the pipeline run also against Node 22 without duplicating the job, using a two-entry matrix. It is a preview of 07-02, so if you get it right you already have half an exercise done. Hint: strategy.matrix.node: [20, 22] and node-version: ${{ matrix.node }}. And a warning: when you use a matrix, the check names change to Tests (20) and Tests (22), so you will have to update the branch protection ruleset or your PR will be stuck waiting for a check called Tests that no longer exists. That discovery is worth more than the exercise.
What You Have Built
A GitHub repository with:
- Mini-Reservalia: a Node.js project with a real domain (slot calculation), an HTTP server with
/healthand/api/slots, tests, lint and build, all with no production dependencies. - A three-job
ci.ymlwith parallelisation,concurrency, a dependency cache and artifact publishing, built in four increments you have watched run. - A real gate:
mainprotected, with three required checks, verified from the positive side and the negative one. - The experience of seeing it red, diagnosing it from the log and the annotation, and fixing it inside the same PR.
Conclusion
What you have just put together is, in miniature, the prepare → quality/test → build stage of the Reservalia ci.yml you read in 02-02. The difference is that now you know why every line is there, because you have seen what happens when it is missing: without checkout the runner is empty, without setup-node the version is whatever comes up, without a lockfile the installation is not reproducible, without needs you build code you already know is broken, without a cache everything is reinstalled three times, and without branch protection none of the above obliges anyone to do anything.
That pipeline, however, has a weakness that does not show up in green: its signal is weak. Seven unit tests over a pure function say nothing about whether the /api/slots endpoint responds correctly, nor whether the server starts, nor what percentage of the code is actually being exercised. A green pipeline with insufficient tests gives exactly the same feeling of safety as a good one, and that is its peculiar danger —the "false green" of 04-04—.
In 07-02 we attack precisely that: we will add a persistence layer to Mini-Reservalia, write the three layers of the testing pyramid on top of it (unit tests with genuine edge cases, integration tests against the real persistence, and an end-to-end test against the running server), measure coverage and publish it in the run summary, put a threshold in place that breaks the build, run the suite across a matrix of Node versions and in two parallel shards, and —the most instructive part— manufacture a flaky test on purpose to watch it fail intermittently and apply the quarantine policy to it. Do not close the repository: we carry on right here.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
