CI/CD : votre filet de sécurité automatique
Point de théorie · semaine 2 · à mettre en place sur votre Projet 1 avant la revue d'avancement n°1
Slides du cours
- deck3-cicd.pdfPDF — les 16 slides du point de théorie
Vous savez écrire du code. Ce chapitre vous apprend à laisser un robot le vérifier à chaque Pull Request et à le livrer en production à chaque merge, sans que personne n'ait à y penser. C'est la brique d'industrialisation la plus rentable que vous apprendrez cette année : une heure de mise en place, sept semaines de nuits tranquilles.
À la fin de ce chapitre, vous serez capables de :
- expliquer ce qu'un pipeline CI/CD fait, quand il s'exécute et sur quelle machine ;
- écrire des tests unitaires et d'intégration minimaux dans votre stack (Laravel, Django ou Node) ;
- configurer un workflow GitHub Actions qui lance ces tests à chaque PR, avec ou sans base de données ;
- brancher le déploiement automatique de votre hébergeur sur
main, protégé par la CI ; - diagnostiquer un run rouge sans paniquer.
Tout le code de ce chapitre est dans le repo d'exemple https://github.com/opmvpc/cicd26 : trois mini-API de messagerie identiques (Laravel, Django, Node), chacune avec ses tests et son workflow. Clonez-le, cassez-le, regardez ce qui se passe.
1. Le problème qu'on résout
« Ça marche sur ma machine »
Vendredi, 17h50. Votre binôme merge sa branche feature/notifications sur main. Sur son PC, tout fonctionne. Sauf qu'il a renommé une colonne en base sans mettre à jour la requête qui liste les messages, et que cette page-là, il ne l'a pas rouverte depuis mardi. main = production : l'hébergeur déploie. À 18h02, votre chat affiche une erreur 500 à quiconque ouvre une conversation. Vous le découvrez dimanche soir, en préparant votre démo de la revue d'avancement.
Ce scénario n'est pas une fiction : c'est la vie de tous les projets sans garde-fou. Le problème n'est pas que votre binôme est mauvais. Le problème est qu'un humain ne peut pas retester toute l'application à chaque modification. Un robot, si.
Plus un bug est trouvé tard, plus il coûte cher
| Où le bug est détecté | Qui le trouve | Ce que ça coûte |
|---|---|---|
| Sur votre machine, en codant | Vous | Quelques secondes, personne ne le saura |
| Dans la Pull Request, par la CI | Le robot | Quelques minutes : vous corrigez avant le merge |
| En production | Un utilisateur, ou le prof pendant la démo | Une soirée, un rollback dans l'urgence, une réputation |
La zone qu'on veut viser, c'est la deuxième ligne. C'est tout l'objet de l'intégration continue : attraper les bugs dans la PR, avant qu'ils touchent main.
Trois lettres, trois niveaux d'automatisation
- CI, intégration continue : à chaque push, une machine neutre récupère votre code, l'installe de zéro, lance le lint et les tests, et affiche vert ou rouge. « Intégration » parce qu'on vérifie que votre travail s'intègre au reste sans rien casser.
- CD, livraison continue (delivery) : si la CI est verte, une version déployable est préparée automatiquement. Un humain décide encore quand la mettre en production.
- CD, déploiement continu (deployment) : plus d'humain dans la boucle. Vert sur
main= en production dans les minutes qui suivent.
Dans ce cours, main est votre production et chaque merge déploie : vous faites du déploiement continu. Ça n'a rien d'effrayant si la CI est solide et si personne ne peut merger tant qu'elle est rouge. On y revient en partie 5.
Note
Le mot pipeline désigne l'enchaînement complet : push → lint → tests → build → déploiement. Chaque étape est un tuyau qui ne laisse passer que ce qui est correct.
2. Les tests, concrètement
Un pipeline sans tests vérifie que votre code s'installe et compile. C'est déjà utile, mais le vrai filet de sécurité, ce sont les tests. Pas besoin d'en écrire des centaines : quelques tests bien placés sur le cœur de votre application suffisent à éviter la catastrophe du vendredi.
La pyramide des tests
| Niveau | Ce qu'on teste | Vitesse | Exemple dans votre chat |
|---|---|---|---|
| Unitaire | Une fonction ou une classe isolée, sans HTTP ni base de données | Millisecondes | validateBody(" ") renvoie « Le message ne peut pas être vide » |
| Intégration | Plusieurs couches ensemble : requête HTTP → contrôleur → base de données → réponse | Dizaines de ms à secondes | POST /conversations/1/messages par un non-membre renvoie 403 |
| End-to-end (E2E) | L'application complète pilotée par un vrai navigateur, comme un humain | Secondes à minutes | Alice tape un message, clique « Envoyer », le message apparaît dans la liste |
Plus on monte, plus le test est réaliste et rassurant, mais plus il est lent, fragile et coûteux à maintenir. D'où la forme de pyramide : beaucoup d'unitaires, quelques tests d'intégration sur les routes critiques, une poignée d'E2E sur les parcours vitaux.
Quoi tester quand on a sept semaines ?
Le cœur du système, celui dont la panne vous ferait perdre des points à la démo :
- envoyer un message dans une conversation dont on est membre ;
- lire les messages d'une conversation ;
- les permissions : un non-membre ne lit pas et n'écrit pas ;
- la validation : message vide, trop long, mauvais type.
Ne testez pas les getters, les setters, ni ce que le framework teste déjà pour vous. Un test doit casser quand vous cassez quelque chose d'important, c'est son seul rôle.
À quoi ressemble un test dans votre stack
Le repo cicd26 implémente exactement la même règle métier trois fois : seul un membre d'une conversation peut y lire ou écrire, et un message ne peut pas être vide. Voici le même test d'intégration dans les trois stacks. Remarquez la structure identique : préparer, agir, vérifier.
Laravel avec Pest (tests/Feature/MessagesApiTest.php)
uses(RefreshDatabase::class); // base remise à zéro entre chaque test
beforeEach(function () {
$this->conversation = Conversation::create(['name' => 'général']);
$this->conversation->addMember('alice');
});
it('refuse un non-membre (403)', function () {
$this->withHeader('X-User', 'mallory')
->postJson("/conversations/{$this->conversation->id}/messages", ['body' => 'Coucou'])
->assertForbidden();
});
Django avec pytest (chat/tests/test_api.py)
pytestmark = pytest.mark.django_db # chaque test tourne dans une transaction annulée à la fin
@pytest.fixture
def conversation():
conv = Conversation.objects.create(name="général")
conv.members.add(Member.objects.create(username="alice"))
return conv
def test_un_non_membre_est_refuse(client, conversation):
res = client.post(f"/conversations/{conversation.id}/messages", {"body": "Coucou"},
content_type="application/json", headers={"X-User": "mallory"})
assert res.status_code == 403
Node/Express avec Vitest et supertest (tests/api.integration.test.js)
beforeEach(async () => {
app = createApp(createDb(":memory:")); // SQLite en mémoire : base neuve à chaque test
await request(app).post("/conversations").send({ name: "général", members: ["alice"] });
});
it("un non-membre est refusé (403)", async () => {
const res = await request(app)
.post("/conversations/1/messages")
.set("X-User", "mallory")
.send({ body: "Coucou" });
expect(res.status).toBe(403);
});
Et la commande qui lance tout, celle que la CI exécutera aussi :
| Stack | Commande |
|---|---|
| Laravel | php artisan test |
| Django | pytest |
| Node | npm test (qui appelle vitest run) |
Astuce
Un test isolé est un test qui repart d'une base vide. RefreshDatabase en Laravel, django_db en Django, une base SQLite :memory: en Node : c'est ce qui garantit que vos tests passent dans n'importe quel ordre, sur n'importe quelle machine, y compris celle de la CI.
Le lint et l'analyse statique : des tests que vous n'écrivez pas
Vos tests vérifient le comportement. Deux autres familles d'outils vérifient le code lui-même, sans l'exécuter :
- Le lint et le formatage vérifient le style : indentation, imports inutilisés, conventions de nommage. Pint pour Laravel, ruff pour Python, ESLint et Prettier pour JavaScript.
- L'analyse statique lit votre code comme un relecteur très pointilleux et y trouve des erreurs avant l'exécution : appel d'une méthode qui n'existe pas, variable qui peut être
nulllà où vous la déréférencez, type incompatible passé à une fonction. Elle ne remplace pas les tests, elle attrape une autre classe de bugs, gratuitement, sur tout le code, y compris celui que vous n'avez pas testé.
| Stack | Formatage / lint | Analyse statique |
|---|---|---|
| Laravel | Pint | PHPStan avec Larastan |
| Django | ruff (lint et format) | mypy, ou pyright |
| Node / TypeScript | ESLint, Prettier | TypeScript (tsc --noEmit) |
Ces outils tournent en quelques secondes et se placent dans un job à part de votre pipeline, en parallèle des tests. On les mettra en place proprement et on parlera de niveaux de sévérité, de règles et de configuration dans un prochain sous-chapitre. Pour l'instant, retenez qu'un job de lint est la première chose que vous ajouterez après vos tests.
3. GitHub Actions, anatomie
Ce qui se passe physiquement
Quand vous poussez du code, GitHub regarde s'il y a des fichiers dans le dossier .github/workflows/ de votre repo. Pour chaque workflow concerné par l'événement, il démarre une machine virtuelle vierge (par défaut Ubuntu), y clone votre code, exécute vos instructions dans l'ordre, note vert ou rouge, puis détruit la machine.
Trois conséquences très concrètes :
- Rien ne survit d'un run à l'autre. Ni vos
node_modules, ni votre base de données locale, ni votre fichier.env. Tout ce dont le job a besoin doit être décrit dans le YAML. - C'est un ordinateur neutre. S'il a réussi à installer et tester votre projet à partir de zéro, n'importe qui pourra le faire, y compris votre hébergeur. Si ça ne marche que sur votre machine, la CI vous le dira.
- Ça prend du temps et des ressources. Un run typique dure 2 à 5 minutes. C'est gratuit et illimité pour les repos publics. Pour les repos privés, le plan gratuit offre 2 000 minutes par mois, avec des multiplicateurs pour Windows et macOS. Raison de plus pour rester sur Ubuntu et pour garder votre repo public.
Le vocabulaire : workflow, job, step
name: Node CI # le nom affiché dans l'onglet Actions
on: [push, pull_request] # QUAND ça tourne (les déclencheurs)
jobs: # un workflow contient un ou plusieurs jobs
tests: # identifiant du job
runs-on: ubuntu-latest # SUR QUOI ça tourne : une VM Ubuntu
steps: # la to-do list, exécutée dans l'ordre, dans cette VM
- uses: actions/checkout@v7 # une action toute faite : cloner le repo
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci # une commande shell, comme dans votre terminal
- run: npm test
- Un workflow est un fichier YAML dans
.github/workflows/. Un repo peut en avoir plusieurs (un par stack, un pour le déploiement…). - Un job est une unité de travail qui tourne sur sa propre machine. Les jobs d'un même workflow tournent en parallèle par défaut.
- Un step est une étape d'un job. Les steps tournent en séquence dans la même machine et partagent son disque : ce que
npm ciinstalle,npm testle trouve. - Un step est soit
run:(une commande shell), soituses:(une action, c'est-à-dire un step tout fait publié par quelqu'un : cloner le repo, installer PHP, uploader un fichier…).
Astuce
Traduction honnête : les steps, c'est la to-do list dans une machine. Les jobs, ce sont plusieurs machines qui bossent en même temps. On parallélise ce qui est indépendant (lint et tests), on séquence ce qui dépend d'autre chose (déployer après les tests) avec needs:.
Quand est-ce que ça tourne ? Les déclencheurs
La clé on: décide des événements qui lancent le workflow. Les plus utiles :
| Déclencheur | Se lance quand… | Pourquoi vous en voulez |
|---|---|---|
pull_request |
Une PR est ouverte ou reçoit un nouveau commit | Vérifier le code avant le merge. C'est le cœur de la CI |
push sur main |
Un merge (ou un push direct) arrive sur main |
Vérifier main et déclencher le déploiement |
workflow_dispatch |
Vous cliquez sur « Run workflow » dans l'onglet Actions | Relancer à la main, tester le workflow sans pousser |
schedule |
Selon une expression cron | Vérifier chaque nuit que les dépendances n'ont pas cassé |
on: [push, pull_request] est la forme courte : elle réagit à tous les push sur toutes les branches. On peut aussi détailler chaque déclencheur avec ses options, pour ne réagir qu'à certaines branches (branches:) ou qu'aux changements dans certains dossiers (paths:). Ci-dessous, le workflow Laravel du repo cicd26 ne tourne que si le dossier laravel/ ou le workflow lui-même change, ce qui évite de faire tourner les tests Laravel quand on ne touche qu'au front :
on:
push:
branches: [main]
paths: ["laravel/**", ".github/workflows/laravel.yml"]
pull_request:
paths: ["laravel/**", ".github/workflows/laravel.yml"]
Attention
Ne mettez pas on: push tout nu. Chaque push sur chaque branche lancerait un run, puis la PR en lancerait un deuxième. Ciblez push sur main et pull_request pour le reste : deux runs utiles, zéro run inutile.
Sur quelle machine ? Les runners
runs-on: choisit la machine. ubuntu-latest est le bon choix par défaut : le moins cher, le plus rapide à démarrer, et c'est un Linux comme votre serveur de production. windows-latest et macos-latest existent, mais ils coûtent 2 et 10 fois plus de minutes et ne vous apportent rien pour une application web. Ces machines viennent avec beaucoup d'outils préinstallés (Git, Docker, Node, Python, PHP dans plusieurs versions), mais vous devez toujours choisir explicitement la version avec une action setup-* : c'est la garantie que la CI teste la même version que votre prod.
Parallèle, séquence, matrices
Trois mécanismes organisent vos jobs :
needs: impose un ordre. Le job qui porte needs: tests attend que tests soit vert avant de démarrer, et n'est pas lancé du tout si tests est rouge. On l'utilise pour ne pas payer un test E2E lent ou un déploiement quand les unitaires échouent déjà.
jobs:
tests:
runs-on: ubuntu-latest
steps: [...]
e2e:
needs: tests # attend le vert de tests
runs-on: ubuntu-latest
steps: [...]
strategy.matrix multiplie un job. Vous écrivez le job une fois, GitHub le lance une fois par combinaison, en parallèle. C'est la manière standard de tester plusieurs versions d'un langage, ou plusieurs bases de données.
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false # si Node 22 échoue, on veut quand même le verdict de 24 et 26
matrix:
node: [22, 24, 26]
steps:
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
Pourquoi tester trois versions de Node ? Parce que votre hébergeur n'a peut-être pas la même que vous. En septembre 2026, Node 24 est la LTS active, Node 22 est en maintenance et Node 26 devient LTS en octobre. Tester les trois coûte trois fois plus de minutes mais vous évite la surprise au déploiement.
concurrency annule les runs devenus inutiles. Si vous poussez trois commits en cinq minutes sur la même PR, seul le dernier compte. Sans cette clé, les trois runs tournent jusqu'au bout.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Secrets et variables d'environnement
Votre code a besoin de valeurs qui ne doivent pas être dans le repo : mot de passe de base de données, clé d'API, APP_KEY. La règle du cours est claire : jamais de .env commité. Dans la CI, trois cas :
- Les valeurs de test sans importance (
DB_PASSWORD: rootpour un MySQL jetable qui vit trois minutes) se mettent en clair dans le YAML, viaenv:. - Les vraies valeurs sensibles (un token de déploiement, une clé d'API tierce) se stockent dans Settings → Secrets and variables → Actions de votre repo, et se lisent avec
${{ secrets.NOM }}. GitHub les masque dans les logs. - Ce que votre framework attend dans un
.envse génère à la volée :cp .env.example .env && php artisan key:generatepour Laravel, par exemple. Votre.env.exampledoit donc rester à jour et complet : c'est lui que la CI lit.
env: # visible par tous les steps du job
DB_CONNECTION: mysql
DB_PASSWORD: root # valeur jetable : en clair, pas grave
steps:
- run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} # vraie valeur : un secret
Cache et artifacts
Le cache évite de retélécharger vos dépendances à chaque run. Les actions setup-node, setup-python le font pour vous avec un simple cache: npm ou cache: pip ; pour Composer on utilise actions/cache@v6 avec une clé basée sur le hash de composer.lock. Quand le fichier de lock change, le cache est invalidé. C'est la différence entre 40 secondes et 8 secondes d'installation.
Les artifacts sont des fichiers produits par un job que vous voulez garder après la destruction de la machine : un rapport de tests, une couverture de code, les captures d'écran d'un test E2E qui a échoué. actions/upload-artifact@v7 les rend téléchargeables depuis la page du run pendant quelques jours.
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }} # même si les tests ont échoué : c'est justement là qu'on veut le rapport
with:
name: playwright-report
path: playwright-report/
retention-days: 14
4. Trois pipelines commentés ligne par ligne
Les trois workflows ci-dessous sont ceux du repo cicd26. Ils vivent dans un seul repo, d'où le defaults.run.working-directory qui fait tourner les commandes dans le bon sous-dossier ; dans votre projet, vous supprimerez cette clé et les filtres paths.
Chacun suit le même schéma : un job de tests rapide sur SQLite, un job de tests sur MySQL qui n'est lancé que si le premier passe, et un job de style en parallèle.
Pourquoi deux bases de données ?
SQLite en mémoire est parfait pour la CI : zéro installation, base neuve à chaque test, une suite complète en une seconde. Mais votre production tourne sur MySQL ou PostgreSQL, et les moteurs SQL ne sont pas interchangeables : une contrainte, un type de colonne, une fonction de date peuvent se comporter différemment. Tester aussi sur le vrai moteur attrape ces différences.
GitHub Actions le rend facile avec services: : vous déclarez une image Docker, GitHub la démarre à côté de votre job, dans la même VM, joignable sur 127.0.0.1. Le --health-cmd est essentiel : MySQL met plusieurs secondes à démarrer, et sans lui votre premier test partirait avant que la base ne réponde.
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: chat
ports: ["3306:3306"]
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=5
Note
Pour PostgreSQL, remplacez par image: postgres:17, les variables POSTGRES_PASSWORD et POSTGRES_DB, le port 5432 et --health-cmd="pg_isready". Le principe est identique.
Laravel + Pest
name: Laravel CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: laravel-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
name: Tests (SQLite)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Installer PHP
uses: shivammathur/setup-php@v2 # LA référence pour PHP dans Actions
with:
php-version: "8.4"
extensions: pdo_sqlite
coverage: none # plus rapide sans Xdebug
- name: Cache Composer
uses: actions/cache@v6
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
- name: Installer les dépendances
run: composer install --no-interaction --prefer-dist --no-progress
- name: Préparer l'environnement
run: |
cp .env.example .env
php artisan key:generate # sans APP_KEY, Laravel refuse de démarrer
- name: Lancer les tests
run: php artisan test # phpunit.xml force sqlite + :memory:
Ce qu'il faut voir :
shivammathur/setup-php@v2est une action communautaire mais c'est le standard de fait : elle installe la version de PHP demandée et les extensions listées en quelques secondes.cp .env.example .envpuiskey:generate: la VM n'a pas votre.env. On le fabrique à partir de l'exemple. Si votre.env.exampleest incomplet, c'est ici que ça casse, et c'est une bonne chose de le découvrir.php artisan testlitphpunit.xml, qui définitDB_CONNECTION=sqliteetDB_DATABASE=:memory:pour les tests. Aucune base à créer.
Le job MySQL ajoute le bloc services: vu plus haut, needs: tests, et surcharge les variables :
tests-mysql:
name: Tests (MySQL)
runs-on: ubuntu-latest
needs: tests
services:
mysql: { ... } # le bloc complet est plus haut
env: # gagnent sur phpunit.xml (qui n'a pas force="true")
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: chat
DB_USERNAME: root
DB_PASSWORD: root
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with: { php-version: "8.4", extensions: pdo_mysql, coverage: none }
- run: composer install --no-interaction --prefer-dist --no-progress
- run: cp .env.example .env && php artisan key:generate
- run: php artisan test
Et le job de style, indépendant, qui tourne en parallèle :
lint:
name: Style (Pint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with: { php-version: "8.4", coverage: none }
- run: composer install --no-interaction --prefer-dist --no-progress
- run: vendor/bin/pint --test # --test : vérifie sans modifier, rouge si mal formaté
Pièges Laravel classiques en CI : APP_KEY manquante (oubli de key:generate), extension PHP absente (pdo_mysql non listée dans extensions:), test qui dépend d'un php artisan migrate que personne n'a lancé (utilisez RefreshDatabase, qui migre pour vous).
Django + pytest
name: Django CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: django-${{ github.ref }}
cancel-in-progress: true
jobs:
tests-sqlite:
name: Tests SQLite (Python ${{ matrix.python }})
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.12", "3.13", "3.14"] # 3 jobs en parallèle
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
cache: pip # cache basé sur requirements.txt
- name: Dépendances système pour mysqlclient
run: sudo apt-get update && sudo apt-get install -y default-libmysqlclient-dev pkg-config
- run: pip install -r requirements.txt
- run: pytest -q
Ce qu'il faut voir :
- La matrice Python : le même job tourne trois fois, en parallèle. Le nom du job affiche la version, ce qui rend la liste des checks lisible dans la PR.
sudo apt-get install …: vous avez le droit d'installer des paquets système sur la VM, vous êtes root. Ici,mysqlclienta besoin des en-têtes MySQL pour se compiler. C'est un exemple typique de dépendance qui existe sur votre machine et pas sur une VM vierge.pytest -qlitpytest.ini, qui pointe verschatproject.settings. La base de test SQLite est créée et détruite parpytest-django.
Le job MySQL reprend le services: et passe la configuration par variables d'environnement. Côté Django, settings.py bascule sur MySQL si DB_ENGINE=mysql est défini ; c'est une pratique à adopter dès maintenant, elle vous servira aussi chez votre hébergeur :
tests-mysql:
name: Tests MySQL
runs-on: ubuntu-latest
needs: tests-sqlite
services:
mysql: { ... }
env:
DB_ENGINE: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_NAME: chat
DB_USER: root
DB_PASSWORD: root
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with: { python-version: "3.13", cache: pip }
- run: sudo apt-get update && sudo apt-get install -y default-libmysqlclient-dev pkg-config
- run: pip install -r requirements.txt
- run: pytest -q
lint:
name: Style (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with: { python-version: "3.13" }
- run: pip install ruff
- run: ruff check .
Pièges Django classiques : DJANGO_SETTINGS_MODULE introuvable (pas de pytest.ini), ALLOWED_HOSTS ou SECRET_KEY lus depuis un .env absent (donnez des valeurs par défaut sûres pour les tests), migrations non committées (makemigrations oublié : la CI le verra tout de suite).
Node/Express + Vitest, puis Playwright
name: Node CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch: # bouton « Run workflow »
concurrency:
group: node-${{ github.ref }}
cancel-in-progress: true
jobs:
unit:
name: Vitest (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
matrix:
node: [22, 24, 26]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci # installe EXACTEMENT package-lock.json
- run: npm test
Ce qu'il faut voir :
npm ciet pasnpm install:ciinstalle exactement ce que ditpackage-lock.jsonet échoue si le lock est absent ou incohérent. C'est ce que vous voulez : la CI teste les mêmes versions que vous, au patch près. Committez toujours votre lockfile.cache: npm: une ligne, etsetup-nodegère le cache pour vous.
Le job E2E est la partie la plus intéressante. Playwright ouvre un vrai navigateur Chromium, charge votre page, tape un message et vérifie qu'il apparaît. Le navigateur se fiche de votre stack serveur : ce job fonctionne à l'identique devant un backend Laravel ou Django. Il ne démarre que si les unitaires passent, et il publie son rapport HTML en artifact, échec ou pas.
e2e:
name: Playwright (E2E)
runs-on: ubuntu-latest
needs: unit # les E2E sont lents : seulement si les unitaires passent
timeout-minutes: 15 # un navigateur qui bloque ne doit pas manger vos minutes
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium # navigateur + librairies système
- run: npx playwright test
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 14
Le fichier playwright.config.js demande à Playwright de démarrer votre serveur lui-même et d'attendre que /health réponde avant de lancer le premier test. Vous n'avez donc rien à orchestrer dans le YAML :
export default defineConfig({
testDir: "./e2e",
use: { baseURL: "http://localhost:3000" },
webServer: {
command: "npm start",
url: "http://localhost:3000/health",
reuseExistingServer: !process.env.CI,
},
});
Et le test lui-même tient en cinq lignes lisibles par n'importe qui :
test("un utilisateur envoie un message et le voit apparaître", async ({ page }) => {
await page.goto("/");
await page.fill("#body", "Bonjour depuis Playwright");
await page.click("button[type=submit]");
await expect(page.locator("#messages li").last()).toContainText("Bonjour depuis Playwright");
});
Pièges Node classiques : lockfile absent ou non committé (npm ci refuse), test qui dépend d'un port déjà pris, E2E lancés sans --with-deps (Chromium a besoin de librairies système que la VM n'a pas).
Astuce
Un seul test E2E sur votre parcours vital (se connecter, envoyer un message, le voir arriver) vaut plus que dix tests unitaires supplémentaires. Il vérifie que tout est branché : routes, base, JavaScript côté client, WebSocket. C'est aussi une superbe démo pour votre rapport.
5. Le CD : brancher la production
Vous avez maintenant une CI qui dit vert ou rouge sur chaque PR. Reste à faire en sorte que le vert déploie et que le rouge bloque.
Votre hébergeur déploie déjà à chaque push
Bonne nouvelle : vous n'avez presque rien à écrire. Tous les hébergeurs que vous avez comparés au premier cours proposent le push-to-deploy :
- Les PaaS managés (Vercel, Netlify, Render, Railway, Fly.io, Heroku…) se connectent à votre repo GitHub en trois clics : choisissez le repo et la branche
main, l'hébergeur détecte la stack, construit et déploie à chaque push. La plupart offrent aussi des preview deployments : chaque PR reçoit une URL temporaire, parfaite pour montrer une fonctionnalité à votre binôme avant le merge. - Un VPS (Hetzner, OVH, DigitalOcean…) vous donne une machine nue. Pour retrouver le même confort, installez un panneau de déploiement auto-hébergé comme Coolify ou Dokploy : une interface web, une intégration GitHub, un bouton « déployer à chaque push », la gestion des variables d'environnement, des bases de données et des certificats HTTPS. Vous gardez le contrôle et le prix fixe du VPS, avec l'expérience d'un PaaS.
Dans les deux cas, le mécanisme est le même : l'hébergeur écoute les événements de votre repo via un webhook GitHub. Push sur main → il clone, construit, redémarre.
Faire attendre la CI
Il y a un piège. Par défaut, l'hébergeur déploie dès le push, sans attendre votre workflow. Si un push cassé arrive sur main, il est en production avant que la CI ait fini de dire rouge. Deux façons de l'éviter :
- Ne jamais pousser directement sur
main. Tout passe par une PR, et la PR ne peut être mergée que si la CI est verte. C'est la protection de branche, ci-dessous. Une fois en place, tout ce qui arrive surmaina déjà été validé et l'hébergeur peut déployer immédiatement. - Demander à l'hébergeur d'attendre. Beaucoup le proposent en une case à cocher (« Wait for CI to pass » chez Render et Railway, par exemple). Sinon, désactivez le déploiement automatique et déclenchez-le depuis un job
deployde votre workflow, avecneeds:sur vos tests, en appelant l'URL de deploy hook fournie par l'hébergeur :
deploy:
needs: [tests, tests-mysql, lint]
if: github.ref == 'refs/heads/main' # jamais depuis une PR
runs-on: ubuntu-latest
steps:
- run: curl -fsS -X POST "${{ secrets.DEPLOY_HOOK_URL }}"
L'URL du hook est un secret : quiconque la connaît peut déclencher un déploiement.
La protection de branche : rendre le rouge bloquant
Sans cette étape, votre CI est décorative : elle affiche une croix rouge et n'importe qui peut cliquer sur « Merge » quand même. Dans votre repo : Settings → Branches (ou Rules → Rulesets sur les repos récents) → ajoutez une règle sur main avec :
- Require a pull request before merging : plus de push direct sur
main; - Require status checks to pass : cochez vos jobs (
Tests (SQLite),Style (Pint)…). Le bouton « Merge » reste gris tant qu'ils ne sont pas verts ; - Require approvals : 1, si vous voulez formaliser la review par le binôme, ce qui colle à la consigne d'au moins une PR revue par personne et par semaine.
Ces trois cases sont, à elles seules, la moitié de la valeur de ce chapitre. Cochez-les aujourd'hui.
Deux finitions
Le badge de statut dans votre README affiche en temps réel si main est vert. Onglet Actions → votre workflow → menu « … » → Create status badge. C'est le premier signal de sérieux qu'un visiteur, ou un jury, voit sur votre repo.
Dependabot pour les actions : vos @v7 finiront par vieillir. Un fichier .github/dependabot.yml de six lignes ouvre une PR automatique quand une action ou une dépendance a une nouvelle version, et votre CI vérifie que la mise à jour ne casse rien.
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
6. Quand c'est rouge
Un run rouge n'est pas un échec, c'est le pipeline qui fait son travail. Voici le réflexe à acquérir :
- Onglet Actions, cliquez sur le run rouge. À gauche, la liste des jobs : repérez celui avec la croix.
- Dans le job, le step qui a échoué est déjà déplié. Les steps verts au-dessus vous disent jusqu'où ça a marché : si
npm ciest vert etnpm testrouge, ce n'est pas un problème d'installation. - Dans le log, cherchez la première ligne d'erreur, pas la dernière. La dernière ligne dit toujours
Process completed with exit code 1, ce qui ne vous apprend rien. Le vrai message est plus haut : un nom de test, une assertion, une exception. - Reproduisez en local avec exactement la même commande que le YAML. Si ça passe chez vous et pas en CI, la différence est dans l'environnement : une version, une variable d'environnement, un fichier non committé, une dépendance système.
- Re-run jobs uniquement si l'échec est aléatoire : un téléchargement qui a échoué, un timeout réseau. Un test qui échoue une fois sur trois est un bug, dans le test ou dans le code. Ne le relancez pas jusqu'à ce qu'il passe : corrigez-le.
Quelques causes fréquentes, dans l'ordre où vous les rencontrerez :
| Symptôme | Cause probable |
|---|---|
Échec dès npm ci ou composer install |
Lockfile absent, non committé ou désynchronisé |
APP_KEY, SECRET_KEY, variable introuvable |
.env.example incomplet, ou une variable définie seulement dans votre .env local |
Connection refused sur la base |
Service sans health check, ou mauvais DB_HOST (c'est 127.0.0.1, pas localhost ni mysql) |
| Tests verts en local, rouges en CI | Ordre des tests, base non remise à zéro, fuseau horaire, fichier généré localement |
| Un job qui n'apparaît pas du tout | Il a un needs: sur un job rouge, ou un filtre paths: qui l'exclut |
Attention
N'éditez jamais le workflow « au hasard » pour faire passer la CI. Si vous ne comprenez pas l'erreur, vous ne comprenez pas non plus ce que vous êtes en train de désactiver. Lisez le log, reproduisez, corrigez la cause.
7. À vous : TP et checklist Projet 1
TP en classe (30 minutes)
Sur votre projet, en binôme :
- Créez
.github/workflows/ci.ymlen partant du modèle de votre stack. Retirezworking-directoryet les filtrespaths. Poussez sur une branche, ouvrez une PR, regardez le premier run. Il sera probablement rouge : c'est normal, lisez le log, corrigez jusqu'au vert. - Écrivez un test sur une règle réelle de votre chat : un message vide est refusé, ou un non-membre reçoit 403. Faites-le passer.
- Cassez-le volontairement : changez la règle dans le code, pas dans le test. Poussez. La PR passe au rouge et le bouton « Merge » se grise si la protection de branche est active. Lisez le log, trouvez la ligne utile, remettez le code en état, revoyez le vert.
- Activez la protection de branche sur
mainavec vos checks obligatoires. - Vérifiez le déploiement : mergez, regardez votre hébergeur construire, ouvrez l'URL de production.
Checklist avant la revue d'avancement n°1
- Un workflow dans
.github/workflows/qui tourne surpull_requestet surpushversmain - Au moins un test d'intégration sur le cœur (envoi ou lecture de message, permissions), qui passe en CI
- Un job de style (Pint, ruff ou ESLint) en parallèle des tests
- La base de données de test décrite dans le YAML (SQLite en mémoire ou service container)
- Aucune valeur sensible dans le repo : secrets GitHub et
.env.exampleà jour - Protection de branche sur
main: PR obligatoire, checks obligatoires - Déploiement automatique sur
mainchez votre hébergeur, vérifié au moins une fois - Badge de statut dans le README
Le rapport de projet contient une section « CI/CD et déploiement » : cette checklist en est le plan. Une capture d'un run vert, une capture de la règle de protection de branche, trois phrases sur ce que vos tests couvrent, et le jury sait que vous avez compris.
En résumé
- La CI attrape les bugs dans la PR, là où ils coûtent quelques minutes au lieu d'une soirée.
- Un workflow GitHub Actions est un fichier YAML :
on:dit quand,jobs:tournent en parallèle sur des VM vierges,steps:s'exécutent en séquence dans chacune.needs:ordonne,matrixmultiplie,concurrencyannule l'inutile. - Rien ne survit d'un run à l'autre : dépendances,
.env, base de données, tout doit être décrit dans le YAML. Ce qui casse en CI est ce qui aurait cassé chez l'hébergeur. - Testez le cœur de votre application, pas ses getters. Un test d'intégration par règle métier vitale, un E2E sur le parcours principal.
- SQLite pour la vitesse, un service container MySQL ou PostgreSQL pour la fidélité. Le health check n'est pas optionnel.
- Votre hébergeur déploie déjà à chaque push. Votre travail, c'est de garantir que rien n'arrive sur
mainsans être vert : protection de branche, checks obligatoires. - Un run rouge se lit de haut en bas, première erreur d'abord, et se reproduit en local avant toute modification du workflow.
Prochain point de théorie : le lint et l'analyse statique, ou comment faire relire tout votre code par une machine qui ne se fatigue jamais.
Pour aller plus loin
- Documentation GitHub Actions : https://docs.github.com/actions
- Syntaxe complète d'un workflow : https://docs.github.com/actions/reference/workflow-syntax-for-github-actions
- Service containers : https://docs.github.com/actions/use-cases-and-examples/using-containerized-services
- Playwright en CI : https://playwright.dev/docs/ci-intro
- Pest : https://pestphp.com · pytest-django : https://pytest-django.readthedocs.io · Vitest : https://vitest.dev
- Coolify : https://coolify.io · Dokploy : https://dokploy.com
- Repo d'exemple du cours : https://github.com/opmvpc/cicd26