Aller au contenu

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

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 :

  1. expliquer ce qu'un pipeline CI/CD fait, quand il s'exécute et sur quelle machine ;
  2. écrire des tests unitaires et d'intégration minimaux dans votre stack (Laravel, Django ou Node) ;
  3. configurer un workflow GitHub Actions qui lance ces tests à chaque PR, avec ou sans base de données ;
  4. brancher le déploiement automatique de votre hébergeur sur main, protégé par la CI ;
  5. 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

Le coût d'un bug selon le moment où il est détecté

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

La pyramide des tests, avec des exemples tirés d'un chat

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 null là 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

Le cycle de vie d'un run GitHub Actions

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 :

  1. 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.
  2. 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.
  3. Ç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

Anatomie d'un workflow : workflow, jobs et steps

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 ci installe, npm test le trouve.
  • Un step est soit run: (une commande shell), soit uses: (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

Graphe de jobs : parallèle par défaut, needs pour l'ordre, matrix pour les versions

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 :

  1. Les valeurs de test sans importance (DB_PASSWORD: root pour un MySQL jetable qui vit trois minutes) se mettent en clair dans le YAML, via env:.
  2. 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.
  3. Ce que votre framework attend dans un .env se génère à la volée : cp .env.example .env && php artisan key:generate pour Laravel, par exemple. Votre .env.example doit 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 ?

Un service container MySQL à côté du job

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@v2 est 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 .env puis key:generate : la VM n'a pas votre .env. On le fabrique à partir de l'exemple. Si votre .env.example est incomplet, c'est ici que ça casse, et c'est une bonne chose de le découvrir.
  • php artisan test lit phpunit.xml, qui définit DB_CONNECTION=sqlite et DB_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, mysqlclient a 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 -q lit pytest.ini, qui pointe vers chatproject.settings. La base de test SQLite est créée et détruite par pytest-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 ci et pas npm install : ci installe exactement ce que dit package-lock.json et é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, et setup-node gè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

Le pipeline complet, de la branche à 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 :

  1. 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 sur main a déjà été validé et l'hébergeur peut déployer immédiatement.
  2. 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 deploy de votre workflow, avec needs: 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

Lire un run qui a échoué

Un run rouge n'est pas un échec, c'est le pipeline qui fait son travail. Voici le réflexe à acquérir :

  1. Onglet Actions, cliquez sur le run rouge. À gauche, la liste des jobs : repérez celui avec la croix.
  2. 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 ci est vert et npm test rouge, ce n'est pas un problème d'installation.
  3. 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.
  4. 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.
  5. 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 :

  1. Créez .github/workflows/ci.yml en partant du modèle de votre stack. Retirez working-directory et les filtres paths. 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.
  2. É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.
  3. 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.
  4. Activez la protection de branche sur main avec vos checks obligatoires.
  5. 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 sur pull_request et sur push vers main
  • 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 main chez 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, matrix multiplie, concurrency annule 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 main sans ê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