CI für Node.js-Projekte mit GitHub Actions
1. Übersicht
Jedes Mal, wenn jemand Code pushed oder einen Pull Request öffnet, musst du sofort wissen, ob der Code noch gebaut werden kann und die Tests noch bestehen. Dies muss automatisch ablaufen, unabhängig vom Computer jedes Einzelnen. Dieser Artikel zeigt dir, wie du eine CI-Pipeline für Node.js-Projekte mit GitHub Actions aufbaust. Nach dem Lesen hast du einen Workflow, der Dependencies mit Caching installiert, Linting durchführt, Tests mit mehreren Node-Versionen ausführt, Coverage-Reports erstellt und veraltete Runs automatisch abbricht.
2. Vorbereitung des Beispielprojekts
Wir verwenden ein kleines Modul zur Berechnung der Gesamtsumme einer Bestellung. Zunächst die package.json:
{
"name": "order-service",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"lint": "eslint src test",
"test": "node --test",
"test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info"
}
}
Das Projekt verwendet den integrierten Test Runner von Node (node:test), daher musst du Jest oder Mocha nicht installieren. "type": "module" ermöglicht die Verwendung der import/export-Syntax. Das test:coverage-Skript verwendet zwei Reporter gleichzeitig. Ein Reporter gibt die Ergebnisse in der Konsole aus. Der andere Reporter schreibt die Datei lcov.info, um sie später zu speichern.
Die Geschäftslogik befindet sich in src/order.js:
export function calculateTotal(items, discountPercent = 0) {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError('discountPercent muss im Bereich 0–100 liegen');
}
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return Math.round(subtotal * (100 - discountPercent)) / 100;
}
Diese Funktion addiert price * quantity für jeden Artikel und zieht dann den Rabatt ab. Wenn der Rabatt nicht im Bereich 0–100 liegt, wirft die Funktion einen RangeError.
Die entsprechenden Tests befinden sich in test/order.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateTotal } from '../src/order.js';
test('Bestellsumme mit Rabatt berechnen', () => {
const items = [
{ price: 100000, quantity: 2 },
{ price: 50000, quantity: 1 },
];
assert.equal(calculateTotal(items, 10), 225000);
});
test('Fehler werfen wenn Rabatt ungültig ist', () => {
assert.throws(() => calculateTotal([], 120), RangeError);
});
Der Befehl node --test findet automatisch Dateien mit der Endung .test.js. Führe es lokal aus:
npm install --save-dev eslint @eslint/js
npm test
✔ Bestellsumme mit Rabatt berechnen (0.9ms)
✔ Fehler werfen wenn Rabatt ungültig ist (0.3ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
ESLint 9 verwendet eine Flat-Config. Erstelle eine Datei eslint.config.js im Wurzelverzeichnis:
import js from '@eslint/js';
export default [js.configs.recommended];
Abschließend committe auch die package-lock.json-Datei. Wie du später sehen wirst, benötigt die CI diese Datei.
3. Erster CI-Workflow
GitHub Actions liest YAML-Dateien aus dem Verzeichnis .github/workflows/. Erstelle eine Datei .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
Die einzelnen Teile der Datei funktionieren wie folgt:
on: Der Workflow wird ausgeführt, wenn Code inmaingepusht wird und wenn ein Pull Request auf jeden Branch erfolgt.runs-on: ubuntu-latest: Der Job wird auf einer neuen Linux-VM ausgeführt, die GitHub bereitstellt.actions/checkout@v4: klont den Quellcode auf die VM.actions/setup-node@v4: installiert Node 22. Die Optioncache: npmspeichert das npm-Cache-Verzeichnis und verwendet den Hash vonpackage-lock.jsonals Schlüssel. Bei nachfolgenden Läufen werden die Pakete aus dem Cache abgerufen statt erneut vom Registry heruntergeladen.npm ci: installiert Dependencies exakt wie in der Lockfile angegeben.npm test: führt Tests aus. Wenn der Befehl einen Exit-Code ungleich 0 zurückgibt, schlägt der Job fehl und der Pull Request wird rot markiert.
Nach dem Pushen dieser Datei gehe zur Registerkarte Actions des Repositorys, um die Logs zu sehen. Beim zweiten Lauf gibt der setup-node-Schritt eine Zeile wie diese aus:
Cache restored successfully
Cache restored from key: node-cache-Linux-x64-npm-3f9a1c...
Hinweis: Verwende
npm installnicht in CI.npm installkann die Lockfile aktualisieren und andere Versionen installieren als auf deinem Computer.npm cilöschtnode_modules, installiert exakt nach der Lockfile und gibt sofort einen Fehler aus, wennpackage.jsonundpackage-lock.jsonnicht übereinstimmen. Genau durch dieses Verhalten erkennst du früh, wenn jemand vergessen hat, die Lockfile zu committen.
4. Tests mit mehreren Node-Versionen ausführen
Deine Bibliothek oder Service muss möglicherweise auf mehreren Node-Versionen laufen. Die matrix-Strategie hilft dir, Jobs für jede Version zu duplizieren:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
GitHub erstellt zwei parallele Jobs namens test (22) und test (24). Der Ausdruck ${{ matrix.node-version }} wird durch den entsprechenden Wert in jedem Job ersetzt.
Standardmäßig ist fail-fast true: Wenn ein Job fehlschlägt, werden die übrigen Jobs sofort abgebrochen. Mit fail-fast: false siehst du das vollständige Bild. Beispielsweise erkennst du, ob der Fehler nur auf Node 24 auftritt, während Node 22 noch funktioniert.
Du kannst die Matrix auf mehrere Betriebssysteme erweitern:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [22, 24]
runs-on: ${{ matrix.os }}
Diese Konfiguration erstellt 2 × 2 = 4 Jobs.
Hinweis: Jede Dimension, die du zur Matrix hinzufügst, erhöht die Anzahl der Jobs. Für einen Service, der nur auf Linux und Node 24 bereitgestellt wird, verschwenden große Matrizen nur CI-Minuten ohne Nutzen. Verwende matrix nur, wenn du wirklich mehrere Umgebungen unterstützt, zum Beispiel wenn du ein npm-Paket veröffentlichst.
Wenn das Projekt nur eine Node-Version verwendet, stelle sicher, dass CI und dein Dev-Computer dieselbe Quelle nutzen. Erstelle eine .nvmrc-Datei mit dem Inhalt 24 und deklariere:
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
5. Linting und Abhängigkeiten zwischen Jobs trennen
Linting ist viel schneller als Testing und muss nicht für jede Node-Version erneut ausgeführt werden. Daher sollte Linting in einen separaten Job aufgeteilt werden, und der Test-Job sollte warten, bis Linting erfolgreich ist:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
test:
needs: lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
needs: lint sorgt dafür, dass der test-Job nur startet, wenn lint erfolgreich war. Angenommen, jemand hinterlässt eine ungenutzte Variable:
const unusedTax = 0.1;
Dann schlägt der lint-Job mit der Meldung fehl:
/home/runner/work/order-service/src/order.js
1:7 error 'unusedTax' is assigned a value but never used no-unused-vars
✖ 1 problem (1 error, 0 warnings)
Der test-Job wird nicht ausgeführt, sodass du Zeit für die gesamte Matrix sparst.
Du musst hier eine Abwägung treffen. Die Verwendung von needs verlängert die Gesamtzeit, wenn alles erfolgreich ist, weil beide Jobs nacheinander laufen. Wenn dir schnelle Rückmeldung wichtiger ist, lasse needs weg, damit lint und test parallel laufen können.
6. Coverage-Reports und Speichern von Artifacts
Alle Dateien, die auf der VM erstellt werden, gehen verloren, wenn der Job endet. Um Coverage-Reports zu speichern, verwendest du actions/upload-artifact:
coverage:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: lcov.info
retention-days: 7
Das test:coverage-Skript gibt eine Coverage-Tabelle direkt im Log des Jobs aus:
ℹ start of coverage report
ℹ file | line % | branch % | funcs %
ℹ src/order.js | 100.00 | 100.00 | 100.00
ℹ all files | 100.00 | 100.00 | 100.00
ℹ end of coverage report
Die Bedingung if: always() stellt sicher, dass das Artifact hochgeladen wird, auch wenn Tests fehlschlagen. Dies ist genau der Moment, in dem du den Report am meisten brauchst. Die Datei lcov.info erscheint am Ende der Zusammenfassungsseite des Workflow-Laufs und wird nach 7 Tagen gelöscht.
Wenn du möchtest, dass CI fehlschlägt, wenn die Coverage zu niedrig ist, kannst du ab Node 22.8 ein Threshold-Flag zum Skript hinzufügen:
node --test --experimental-test-coverage --test-coverage-lines=80
Dieser Befehl gibt Exit-Code 1 zurück, wenn die Zeilenabdeckung unter 80% liegt.
7. Workflow optimieren
Die folgenden drei Einstellungen sollten in den meisten CI-Workflows vorhanden sein. Platziere sie auf der obersten Ebene der Datei:
name: CI
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
paths-ignore:
- '**.md'
- 'docs/**'
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Die Bedeutung jeder Einstellung:
paths-ignore: Commits, die nur README oder Dokumentation ändern, lösen CI nicht aus.permissions: contents: read: Schränkt die Berechtigungen desGITHUB_TOKENauf Lesezugriff ein. Wenn eine bösartige Abhängigkeit ein Skript in CI ausführt, kann sie dennoch keinen Code pushen oder Releases ändern.concurrency: Wenn du nacheinander drei Commits auf denselben Pull Request pushst, behält nur der neueste Lauf bei. Ältere Läufe werden abgebrochen, da ihre Ergebnisse nicht mehr relevant sind.
Außerdem solltest du timeout-minutes für jeden Job hinzufügen:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
Standardmäßig kann ein Job bis zu 6 Stunden laufen. Nur ein blockierter Test, z. B. wenn man vergisst, eine Datenbankverbindung zu schließen, kann dein gesamtes Minuten-Budget aufbrauchen.
Hinweis:
paths-ignorehat eine Fallgrube, wenn es mit Branch-Protection kombiniert wird. Angenommen, du machst den Checktesterforderlich, um zu mergen. Ein Pull Request, der nur.md-Dateien ändert, führt den Workflow nicht aus, sodass dieser Check für immer im Status “Expected — Waiting” verbleibt. Dann hast du zwei Möglichkeiten: Entfernepaths-ignore, oder entferne die Anforderung für diesen Check.
8. Häufige Fehler
Lockfile fehlt. Wenn package-lock.json nicht committet wird oder in .gitignore aufgeführt ist, gibt der setup-node-Schritt einen Fehler aus:
Error: Dependencies lock file is not found in /home/runner/work/order-service/order-service.
Supported file patterns: package-lock.json,npm-shrinkwrap.json,yarn.lock
Die Lösung ist, die Lockfile zu committen. Wenn das Projekt pnpm oder yarn verwendet, ändere den cache-Wert für den richtigen Package-Manager.
Tests laufen im Watch-Modus. Einige Tools wie Vitest oder Jest warten im Watch-Modus auf immer weitere Dateiänderungen. GitHub Actions setzt automatisch die Umgebungsvariable CI=true, und die meisten Tools verwenden diese Variable, um den Watch-Modus zu deaktivieren. Dennoch solltest du den Befehl explizit ausführen, z. B. vitest run.
Secrets fehlen bei Pull Requests von Forks. Bei einem pull_request-Event aus einem geforkelten Repository werden Secrets nicht an den Workflow übergeben. Dies ist eine absichtliche Sicherheitsmaßnahme. Tests, die echte API-Schlüssel benötigen, schlagen in diesem Fall fehl. Mock externe Dienste in Unit-Tests. Schalte nicht auf pull_request_target um, um das zu “reparieren”, da dieses Event mit vollem Zugriff auf nicht überprüftem Code ausgeführt wird.
Unterschiede zwischen Betriebssystemen. Tests, die auf macOS bestehen aber auf Ubuntu fehlschlagen, sind oft auf Unterschiede bei Groß-/Kleinschreibung in Pfaden zurückzuführen. Beispiel: import './Order.js' während die Datei tatsächlich order.js heißt. Das Linux-Dateisystem unterscheidet Groß- und Kleinschreibung, sodass CI diesen Fehler erkennt, bevor er in die Produktion geht.
Wann braucht man diese ganze Pipeline nicht. Für ein internes Skript mit nur wenigen Dutzend Zeilen reicht ein einzelner Job npm ci && npm test aus. Du solltest nur dann matrix, coverage und separate Jobs hinzufügen, wenn das Projekt wächst oder wenn mehrere Personen daran beitragen.
9. Fazit
In diesem Artikel hast du eine vollständige CI-Pipeline für Node.js-Projekte mit GitHub Actions aufgebaut. Die Pipeline verwendet npm ci zusammen mit dem Cache von setup-node, um Dependencies schnell und zuverlässig zu installieren. Die matrix-Strategie hilft beim Testen mit mehreren Node-Versionen. Der Lint-Job wird separiert und mit dem Test-Job über needs verknüpft. Coverage-Reports werden mit Artifacts gespeichert. Schließlich wird der Workflow durch concurrency, permissions und timeout-minutes optimiert.
Das Kernsystem ist es, CI schnell und zuverlässig zu halten. Eine Pipeline, die 15 Minuten dauert oder gelegentlich zufällig fehlschlägt, wird dazu führen, dass das ganze Team sie ignoriert. Beginne mit dem minimalen Workflow in Abschnitt 3 und füge dann Teile hinzu, wenn das Projekt diese wirklich braucht.