Build and Test an Application
Lint, test and build an app on every pull request with GitHub Actions: setup-node, npm ci, a Node.js version matrix, service containers, Python and Go.
The core of CI is simple: every change runs the same lint, test and build commands, on a clean machine, before anyone merges it. If the commands pass on your laptop but fail in CI, CI has found a difference between the two, such as a missing dependency in the lockfile or a different Node.js version, and that difference would have reached production.
In this part you add the example app to the repository, run its commands locally, and write a CI workflow that runs them on every pull request and on every push to main, on three Node.js versions.
Prerequisites
- The repository from Part 2.
- Node.js 24, or 22.13 and newer in the 22 line, on your computer. ESLint 10 needs one of those.
The Example App
The app is a one-page command cheatsheet. A JSON file lists commands, a render function turns them into HTML, and a build script writes the site to dist/. It has no runtime dependencies; ESLint is the only development dependency.
package.json defines the three commands CI will run. npm test uses the test runner built into Node.js, and engines records the Node.js versions that ESLint 10 supports:
{
"name": "cheatsheet",
"version": "1.0.0",
"description": "A one-page command cheatsheet, tested, built and deployed with GitHub Actions",
"private": true,
"type": "module",
"engines": {
"node": "^22.13.0 || >=24"
},
"scripts": {
"lint": "eslint .",
"test": "node --test",
"build": "node scripts/build.js"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"eslint": "10.13.0",
"globals": "17.13.0"
}
}
src/render.js escapes every value it puts into the page, so a command such as <script> shows up as text:
// Renders the command list into a single HTML page.
export function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
// Returns [[topic, commands], ...] with topics and commands in alphabetical order.
export function groupByTopic(commands) {
const groups = new Map();
for (const command of commands) {
if (!groups.has(command.topic)) {
groups.set(command.topic, []);
}
groups.get(command.topic).push(command);
}
return [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([topic, list]) => [topic, list.sort((a, b) => a.cmd.localeCompare(b.cmd))]);
}
export function renderPage(commands, { title, version, commit }) {
const sections = groupByTopic(commands)
.map(
([topic, list]) => `<section>
<h2>${escapeHtml(topic)}</h2>
<dl>
${list.map((c) => `<dt><code>${escapeHtml(c.cmd)}</code></dt><dd>${escapeHtml(c.description)}</dd>`).join('\n')}
</dl>
</section>`
)
.join('\n');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
</head>
<body>
<h1>${escapeHtml(title)}</h1>
${sections}
<footer>Version ${escapeHtml(version)}, commit ${escapeHtml(commit.slice(0, 7))}</footer>
</body>
</html>
`;
}
scripts/build.js reads the title from SITE_TITLE and the commit from GITHUB_SHA, which GitHub sets in every job. Besides the page, it writes version.json, which the deployment checks in Part 6 use:
// Builds the site into dist/: index.html plus version.json for smoke tests.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { renderPage } from '../src/render.js';
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
const commands = JSON.parse(await readFile('src/commands.json', 'utf8'));
const title = process.env.SITE_TITLE || 'Command Cheatsheet';
const commit = process.env.GITHUB_SHA || 'local';
await mkdir('dist', { recursive: true });
await writeFile('dist/index.html', renderPage(commands, { title, version: pkg.version, commit }));
await writeFile(
'dist/version.json',
JSON.stringify({ version: pkg.version, commit }, null, 2) + '\n'
);
console.log(
`Built dist/ with ${commands.length} commands (version ${pkg.version}, commit ${commit.slice(0, 7)})`
);
The tests in test/render.test.js cover escaping, sorting and the footer:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { escapeHtml, groupByTopic, renderPage } from '../src/render.js';
const commands = [
{ topic: 'git', cmd: 'git status', description: 'Show changed files' },
{ topic: 'docker', cmd: 'docker ps', description: 'List running containers' },
{ topic: 'git', cmd: 'git log --oneline', description: 'Short history' },
];
test('escapeHtml escapes markup characters', () => {
assert.equal(
escapeHtml('<a href="x">&\'</a>'),
'<a href="x">&'</a>'
);
});
test('groupByTopic sorts topics and commands', () => {
const groups = groupByTopic(commands);
assert.deepEqual(
groups.map(([topic]) => topic),
['docker', 'git']
);
assert.deepEqual(
groups[1][1].map((c) => c.cmd),
['git log --oneline', 'git status']
);
});
test('renderPage shows the title, version and short commit', () => {
const html = renderPage(commands, {
title: 'Cheatsheet',
version: '1.0.0',
commit: '0123456789abcdef',
});
assert.match(html, /<title>Cheatsheet<\/title>/);
assert.match(html, /Version 1\.0\.0, commit 0123456/);
});
test('renderPage escapes command text', () => {
const html = renderPage([{ topic: 'x', cmd: '<script>alert(1)</script>', description: 'bad' }], {
title: 't',
version: '1',
commit: 'abc',
});
assert.ok(!html.includes('<script>'));
});
eslint.config.js turns on ESLint's recommended rules for Node.js code:
import js from '@eslint/js';
import globals from 'globals';
export default [
{ ignores: ['dist/'] },
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: globals.node,
},
},
];
src/commands.json holds the data, twelve entries like these (the companion repository has the full file as of this part):
[
{ "topic": "git", "cmd": "git status", "description": "Show changed and untracked files" },
{ "topic": "gh", "cmd": "gh run list", "description": "List recent workflow runs" }
]
Finally, .gitignore keeps installed packages and build output out of Git:
node_modules/
dist/
test-results.xml
Run It Locally First
Install the development dependencies, then run the three commands. npm install also writes package-lock.json, which pins the exact version of every package; commit it, because CI installs from it.
ESLint printed nothing, which means no problems. The local build has no GITHUB_SHA, so the commit is local.
The CI Workflow
Add .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
node: [22, 24, 26]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
build:
needs: [lint, test]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run build
- name: Check the build output
run: |
test -s dist/index.html
cat dist/version.json
What each part does:
- Triggers.
pull_requestruns CI for every pull request, before the merge.pushtomainruns it again after the merge, on the code as merged. - Three jobs.
lintandtestrun in parallel;buildwaits for both withneeds: [lint, test]. Splitting them shows at a glance which kind of check failed. actions/setup-nodeinstalls the requested Node.js version and puts it on thePATH.cache: npmcaches npm's download cache between runs; Part 4 looks at how.npm ci, notnpm install.npm ciinstalls exactly whatpackage-lock.jsonsays, deletes any existingnode_modulesfirst, and fails ifpackage.jsonand the lockfile disagree.npm installmay update the lockfile, so CI would test something you never committed.- The matrix.
strategy.matrix.node: [22, 24, 26]runs thetestjob three times, once per value, and${{ matrix.node }}passes the value to setup-node. Node.js 22 and 24 are long-term support releases; Node.js 26 is the current release and is scheduled to become LTS at the end of October 2026. Node.js 20 reached end of life in April 2026. fail-fast: false. By default, the first failing matrix job cancels the others. Turning that off shows you whether a failure happens on every version or only one.
Commit everything, including package-lock.json, and push.
The First CI Run
The five jobs ran on five runners. test (26) was the slowest: the runner image does not include Node.js 26, so setup-node downloaded it. The log shows where each job got Node.js from:
Two details matter here. First, node-version: 24 got 24.21.0, the version preinstalled on the image, although 24.22.1 was already released; add check-latest: true if you need the newest patch release, at the cost of a download. Second, preinstalled versions change when GitHub updates the image (the default Node.js on Ubuntu 24.04 changed from 20 to 22 in May 2026), which is why the workflow asks for versions explicitly instead of using whatever node is on the PATH. You can also keep the version in a file with node-version-file: .nvmrc.
Other Languages
The pattern is the same in every language: check out, install the toolchain with a setup-* action and its built-in cache, install dependencies from a lockfile, run the checks. These versions of the same workflow were checked with actionlint but not run in the companion repository:
setup-go caches the module and build caches by default, keyed on go.sum. For a project that builds a container image, build and push it with Docker's actions; the Docker guide's production part has a complete workflow.
Databases and Other Services
Integration tests often need a real database. A job can start service containers next to it. This job (not part of the example app) runs a Postgres 18 container, waits until it accepts connections, and points the tests at it:
name: Integration tests
on:
pull_request:
permissions:
contents: read
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
The password is fine in the file because the container exists only for the length of the job and is not reachable from outside the runner. Service containers need a Linux runner; they do not work on Windows or macOS runners.
Common Mistakes
npm installin CI. Usenpm ci(orpnpm install --frozen-lockfile,yarn install --immutable), so CI installs exactly the committed lockfile.- No lockfile in the repository.
cache: npmthen fails with "Dependencies lock file is not found", and every run may install different versions. - Relying on preinstalled tools. Images change every week, and
ubuntu-latestitself moves to Ubuntu 26.04 in October and November 2026. Ask for the versions you need. - Testing only the newest version. If users run Node.js 22, test on 22.
- Different commands locally and in CI. Keep the logic in
package.jsonscripts (or a Makefile) and have CI call them, so a developer can run exactly what CI runs.
Summary
- CI runs the same lint, test and build commands as your laptop, on a clean runner, for every pull request and every push to
main. actions/setup-nodewith an explicitnode-versionandcache: npm, thennpm ci, gives a repeatable install.- A matrix runs one job definition for several versions;
fail-fast: falsekeeps all results. - Other languages follow the same pattern with
setup-python,setup-goandsetup-java, and service containers provide databases for integration tests.
In Part 4 you look at what the npm cache did in these runs, and pass the built site from the build job to other jobs as an artifact.
Keep Learning
Tags
Found an issue?