Pipeline CI pour projets Node.js avec GitHub Actions
1. Aperçu général
À chaque fois que quelqu’un pousse du code ou ouvre une pull request, tu dois savoir immédiatement si le code construit encore et si les tests passent toujours. Tout cela doit se faire automatiquement, indépendamment de la machine de chacun. Cet article te guide pour construire un pipeline CI pour un projet Node.js en utilisant GitHub Actions. Après l’avoir lu, tu auras un workflow qui installe les dépendances avec cache, lance le linting, exécute les tests sur plusieurs versions de Node, génère des rapports de couverture et annule automatiquement les exécutions obsolètes.
2. Préparation du projet exemple
On utilise un petit module qui calcule le total d’une commande. D’abord le 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"
}
}
Le projet utilise le test runner inclus de Node (node:test), donc pas besoin d’installer Jest ou Mocha. "type": "module" permet d’utiliser la syntaxe import/export. Le script test:coverage utilise deux reporters à la fois. Un reporter affiche le résultat dans la console. L’autre reporter écrit le fichier lcov.info pour le conserver pour plus tard.
La logique métier se trouve dans src/order.js :
export function calculateTotal(items, discountPercent = 0) {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError('discountPercent doit être dans la plage 0–100');
}
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return Math.round(subtotal * (100 - discountPercent)) / 100;
}
Cette fonction ajoute price * quantity pour chaque article, puis soustrait la remise. Si le pourcentage de remise sort de la plage 0–100, la fonction lève une RangeError.
Le test correspondant se trouve dans test/order.test.js :
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateTotal } from '../src/order.js';
test('calculer le total de la commande avec remise', () => {
const items = [
{ price: 100000, quantity: 2 },
{ price: 50000, quantity: 1 },
];
assert.equal(calculateTotal(items, 10), 225000);
});
test('lever une erreur quand la remise n\'est pas valide', () => {
assert.throws(() => calculateTotal([], 120), RangeError);
});
La commande node --test trouve automatiquement les fichiers avec l’extension .test.js. Testons en local :
npm install --save-dev eslint @eslint/js
npm test
✔ calculer le total de la commande avec remise (0.9ms)
✔ lever une erreur quand la remise n'est pas valide (0.3ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
ESLint 9 utilise une configuration flat. Tu crées un fichier eslint.config.js à la racine :
import js from '@eslint/js';
export default [js.configs.recommended];
Enfin, commit le fichier package-lock.json. Comme tu le verras dans la section suivante, CI en a besoin.
3. Premier workflow CI
GitHub Actions lit les fichiers YAML dans le répertoire .github/workflows/. Tu crées le fichier .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
Chaque partie du fichier fonctionne comme suit :
on: le workflow s’exécute lors d’un push surmainet quand il y a une pull request sur n’importe quelle branche.runs-on: ubuntu-latest: le job s’exécute sur une nouvelle machine virtuelle Linux fournie par GitHub.actions/checkout@v4: clone le code source dans la machine virtuelle.actions/setup-node@v4: installe la version 22 de Node. L’optioncache: npmconserve le répertoire cache de npm, utilisant un hash depackage-lock.jsoncomme clé. Lors des exécutions suivantes, les packages seront récupérés depuis le cache plutôt que téléchargés à nouveau depuis le registre.npm ci: installe les dépendances exactement comme dans le lockfile.npm test: exécute les tests. Si la commande retourne un code de sortie autre que 0, le job échoue et la pull request est marquée en rouge.
Après avoir poussé ce fichier, tu vas dans l’onglet Actions du repository pour voir les logs. Lors de la deuxième exécution, l’étape setup-node imprimera une ligne similaire à :
Cache restored successfully
Cache restored from key: node-cache-Linux-x64-npm-3f9a1c...
Note : N’utilise pas
npm installdans CI.npm installpeut mettre à jour le lockfile et installer une version différente de celle de ta machine.npm cisupprimenode_modules, installe exactement selon le lockfile, et signale une erreur immédiatement sipackage.jsonetpackage-lock.jsonne correspondent pas. C’est grâce à ce comportement que tu détectes rapidement si quelqu’un a oublié de commiter le lockfile.
4. Exécuter les tests sur plusieurs versions de Node
Ta bibliothèque ou ton service peut avoir besoin de s’exécuter sur plusieurs versions de Node. La stratégie matrix t’aide à dupliquer le job pour chaque version :
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 créera deux jobs en parallèle, nommés test (22) et test (24). L’expression ${{ matrix.node-version }} est remplacée par la valeur correspondante dans chaque job.
Par défaut, fail-fast est true : un seul job en échec suffit pour annuler les autres. Définis fail-fast: false pour voir le tableau complet. Par exemple, tu sais que l’erreur ne se produit que sur Node 24, tandis que Node 22 est toujours passant.
Tu peux étendre la matrix à plusieurs systèmes d’exploitation :
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [22, 24]
runs-on: ${{ matrix.os }}
Cette configuration crée 2 × 2 = 4 jobs.
Note : Chaque dimension que tu ajoutes à la matrix multiplie le nombre de jobs. Pour un service qui ne se déploie que sur Linux et Node 24, une grande matrix ne fait que gaspiller des minutes de CI sans bénéfice. Utilise la matrix quand tu supportes vraiment plusieurs environnements, par exemple si tu publies un package npm.
Si le projet n’utilise qu’une version de Node, fais en sorte que CI et ta machine de développement lisent depuis une source unique. Tu crées un fichier .nvmrc contenant 24, puis tu déclares :
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
5. Séparer le job lint et les dépendances entre les jobs
Le linting est plus rapide que les tests et n’a pas besoin de s’exécuter sur chaque version de Node. Par conséquent, sépare le linting en un job distinct et fais en sorte que le job test attende que le linting passe :
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 fait que le job test commence seulement quand lint réussit. Supposons que quelqu’un laisse une variable inutilisée :
const unusedTax = 0.1;
Le job lint échoue alors avec le message :
/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)
Le job test ne s’exécutera pas, tu économises donc le temps de toute la matrix.
Tu dois réfléchir au compromis ici. Utiliser needs rend le temps total plus long quand tout passe, car les deux jobs s’exécutent séquentiellement. Si tu privilégies une rétroaction rapide, supprime needs pour que le linting et les tests s’exécutent en parallèle.
6. Rapport de couverture et sauvegarde des artifacts
Tous les fichiers générés sur la machine virtuelle sont perdus quand le job se termine. Pour conserver le rapport de couverture, tu utilises 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
Le script test:coverage imprime un tableau de couverture directement dans le log du job :
ℹ 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
La condition if: always() assure que l’artifact est toujours téléchargé même quand les tests échouent. C’est exactement quand tu as besoin du rapport. Le fichier lcov.info apparaît à la fin de la page de résumé de l’exécution du workflow et sera supprimé après 7 jours.
Si tu veux que CI échoue quand la couverture baisse trop, à partir de Node 22.8, tu peux ajouter un flag de seuil au script :
node --test --experimental-test-coverage --test-coverage-lines=80
Cette commande retourne le code de sortie 1 quand le pourcentage de lignes couvertes est inférieur à 80%.
7. Optimiser le workflow
Trois configurations ci-dessous devraient être présentes dans la plupart des workflows CI. Tu les places au niveau le plus élevé du fichier :
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
Le sens de chaque configuration :
paths-ignore: un commit qui ne modifie que README ou la documentation ne déclenchera pas CI.permissions: contents: read: réduit les permissions duGITHUB_TOKENau niveau lecture uniquement. Si une dépendance malveillante exécute un script dans CI, elle ne pourra pas pousser du code ou modifier une release.concurrency: quand tu pousses successivement trois commits vers la même pull request, seule la dernière exécution est conservée. Les exécutions plus anciennes sont annulées, car leur résultat n’a plus de valeur.
De plus, tu devrais ajouter timeout-minutes à chaque job :
test:
runs-on: ubuntu-latest
timeout-minutes: 10
Par défaut, un job peut s’exécuter jusqu’à 6 heures. Un seul test qui se bloque, par exemple parce qu’on a oublié de fermer une connexion à la base de données, peut épuiser tout ton quota de minutes.
Note :
paths-ignorea un piège quand combiné avec la branch protection. Supposons que tu imposes que le checktestdoit passer avant de merger. Une pull request qui ne modifie que le fichier.mdne lancera pas le workflow, donc ce check reste “Expected — Waiting”. À ce stade, tu as deux choix : supprimerpaths-ignore, ou supprimer l’obligation pour ce check.
8. Erreurs courantes
Lockfile manquant. Si package-lock.json n’est pas commité, ou s’il est listé dans .gitignore, l’étape setup-node signalera une erreur :
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
La solution est de commiter le lockfile. Si le projet utilise pnpm ou yarn, change la valeur cache pour le bon gestionnaire de packages.
Les tests s’exécutent en mode watch. Certains outils, comme Vitest ou Jest quand configurés en mode watch, attendront les changements de fichier indéfiniment. GitHub Actions définit automatiquement la variable d’environnement CI=true, et la plupart des outils s’en fient pour désactiver watch. Malgré cela, tu devrais appeler la commande d’exécution une fois de manière explicite, par exemple vitest run.
Les secrets ne sont pas disponibles dans les pull requests depuis une fork. Pour les événements pull_request depuis un repository forké, les secrets ne seront pas transmis au workflow. C’est un mécanisme de sécurité intentionnel. Les tests qui ont besoin de vraies clés API échoueront dans ce cas. Mock les services externes dans les tests unitaires. Ne bascule pas vers pull_request_target pour “éteindre l’incendie”, car cet événement s’exécute avec les permissions complètes sur le code non revu.
Différences entre les systèmes d’exploitation. Les tests qui passent sur macOS mais échouent sur Ubuntu sont souvent dus à la distinction entre majuscules et minuscules dans les chemins. Par exemple, import './Order.js' alors que le fichier s’appelle vraiment order.js. Le système de fichiers de Linux distingue les majuscules des minuscules, CI détecte donc cette erreur avant qu’elle n’arrive en production.
Quand tu n’as pas besoin de tout ce pipeline. Pour un script interne de seulement quelques dizaines de lignes, un seul job npm ci && npm test suffit. Tu ne devrais ajouter une matrix, la couverture et des jobs séparés que quand le projet grandit, ou quand plusieurs personnes y contribuent.
9. Conclusion
Dans cet article, tu as construit un pipeline CI complet pour un projet Node.js avec GitHub Actions. Le pipeline utilise npm ci avec le cache de setup-node pour installer les dépendances rapidement et de manière fiable. La stratégie matrix aide à vérifier sur plusieurs versions de Node. Le job lint est séparé et lié au job test avec needs. Le rapport de couverture est préservé avec des artifacts. Enfin, le workflow est optimisé avec concurrency, permissions et timeout-minutes.
Le principe fondamental est de garder CI rapide et fiable. Un pipeline qui prend quinze minutes, ou qui échoue aléatoirement de temps en temps, incite progressivement toute l’équipe à ignorer son résultat. Commence par le workflow minimaliste à la section 3, puis ajoute des éléments au fur et à mesure que le projet en a vraiment besoin.