Ce que signifient réellement le CI et le CD
L’intégration continue (Continuous Integration) est la pratique consistant à fusionner fréquemment le travail de chaque développeur dans une branche partagée et à le vérifier automatiquement. Chaque fusion déclenche un build, un passage de lint et la suite de tests sur une machine dédiée, indépendante des postes de développement. L’objectif est de détecter les problèmes d’intégration en quelques minutes, tant que la modification est encore mineure, plutôt que lors d’une fusion pénible à la fin d’une longue branche.
La livraison continue (Continuous Delivery) étend ce concept : la branche principale est toujours dans un état prêt pour la mise en production, et le pipeline peut la déployer à tout moment. L’artéfact est construit et testé à chaque modification ; un humain décide alors du moment de la mise en production. Le déploiement continu (Continuous Deployment) supprime cette décision humaine, en envoyant chaque modification qui franchit les étapes de validation directement en production.
La seule et unique différence entre la livraison et le déploiement est l’étape d’approbation. Tout ce qui précède — le build, les tests, l’artéfact — est identique.
Les équipes prétendent souvent « faire du CI/CD » alors qu’elles ne font ni l’un ni l’autre. L’intégration continue exige que le build s’exécute sur une machine partagée et faisant foi, et pas seulement sur un ordinateur portable avant un commit. La livraison continue exige que l’artéfact produit par le pipeline soit celui qui est déployé, et non un élément reconstruit manuellement au moment de la mise en production. Si l’un de ces éléments manque, la boucle est ouverte et les bénéfices s’estompent.
L’anatomie d’un pipeline
Chaque pipeline, qu’il s’agisse d’un fichier GitHub Actions de dix lignes ou d’une configuration d’entreprise comprenant un millier de jobs, est construit à partir du même vocabulaire.
- Trigger — l’événement qui déclenche l’exécution : un push, une pull request, un tag, une planification ou un déclenchement manuel.
- Workflow — le fichier qui déclare les triggers et les jobs. Dans GitHub Actions, il se trouve dans
.github/workflows/. - Job — une unité de travail qui s’exécute sur un seul runner. Les jobs s’exécutent en parallèle, sauf si vous déclarez des dépendances avec
needs. - Step — une commande unique ou une action réutilisable à l’intérieur d’un job. Les steps s’exécutent dans l’ordre et le job s’arrête à la première erreur.
- Runner — la machine qui exécute un job. Les runners hébergés sont jetables ; les runners auto-hébergés sont des machines que vous gérez.
- Artifact — un fichier produit par un job et stocké pour des jobs ultérieurs ou pour des humains : un binaire, un bundle, un rapport de test.
- Cache — un répertoire restauré entre les exécutions pour éviter de répéter des tâches coûteuses, comme l’installation de dépendances.
- Environment — une cible nommée telle que
stagingouproductionavec ses propres secrets, règles de protection et URL. - Secret — une valeur chiffrée injectée dans un job au moment de l’exécution et masquée dans les logs.
- Status check — le résultat (succès ou échec) renvoyé au commit, que la protection de branche peut exiger.
Une fois ces termes maîtrisés, n’importe quel système de CI devient lisible. Jenkins, GitLab CI, CircleCI et GitHub Actions utilisent tous les mêmes concepts, même si les termes varient légèrement.
Un workflow GitHub Actions, ligne par ligne
Le workflow utile le plus simple consiste à récupérer le code, installer les dépendances et exécuter les tests.
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
on déclare les déclencheurs. Un push vers main ainsi que toute pull request lancent une exécution. jobs.test s’exécute sur un runner ubuntu-latest vierge. uses lance une action réutilisable ; run exécute une commande shell. npm ci installe exactement ce qui est fixé dans le lockfile, ce qui rend l’exécution reproductible. cache: npm indique à l’action de configuration de mettre en cache le répertoire npm, avec une clé basée sur le lockfile.
Ajoutez concurrency pour annuler les exécutions obsolètes, ce qui permet d’économiser du temps et de l’argent sur les branches actives :
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Lorsqu’un développeur pousse trois commits d’affilée, seule l’exécution la plus récente subsiste. Les plus anciennes sont annulées, et la pull request affiche un seul statut actuel au lieu d’une file d’attente de statuts périmés.
Build once, déployez le même artefact
L’habitude la plus précieuse dans un pipeline est de compiler exactement une seule fois. Le pipeline compile, teste et package l’application une seule fois, et chaque environnement, du staging à la production, exécute ce même résultat.
L’alternative — recompiler pour chaque environnement — semble anodine, mais elle ne l’est pas. Différents builds peuvent résoudre différentes versions de dépendances, récupérer différents digests d’images de base ou intégrer un NODE_ENV différent. Le staging teste alors un binaire que la production n’exécutera jamais, et le fait que « ça a passé le staging » ne prouve rien.
Deux règles rendent le « build-once » pratique. Premièrement, taguez l’artefact par commit, jamais avec un nom mutable comme latest :
docker build -t ghcr.io/me/app:$GIT_SHA .
docker push ghcr.io/me/app:$GIT_SHA
Deuxièmement, injectez la configuration au runtime. L’image ne doit pas savoir si elle s’exécute en staging ou en production. Les URLs de base de données, les feature flags et les clés API arrivent sous forme de variables d’environnement lors du démarrage du conteneur. Le build est identique partout ; seul l’environnement diffère.
Pour promouvoir un build, référencez le digest immuable plutôt que le tag, afin qu’une image retaguée ne puisse pas dériver :
kubectl set image deploy/app \
app=ghcr.io/me/app@sha256:...
C’est le fondement qui rend les rollbacks triviaux. La version précédente est simplement le digest précédent, toujours présent dans le registry, prêt à être déployé en quelques secondes.
Tests, lint et typecheck comme “gates”
Une “gate” (porte de validation) est un contrôle qui doit être réussi avant que le pipeline ne puisse continuer. Les gates essentielles sont rapides et peu coûteuses : le linting, le type checking et la suite de tests unitaires. Elles s’exécutent à chaque pull request et empêchent la construction de l’artéfact en cas d’échec.
Organisez-les de la plus rapide à la plus lente. Le lint et le typecheck se terminent généralement en quelques secondes et permettent de détecter une large catégorie d’erreurs. Viennent ensuite les tests unitaires. Les tests d’intégration et end-to-end, plus lents, peuvent être exécutés en parallèle ou uniquement sur la branche principale.
jobs:
lint:
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 run lint
- run: npm run typecheck
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
Deux jobs s’exécutent en parallèle, chacun sur son propre runner. Le lint échoue en vingt secondes sans attendre la suite de tests. Le compromis est que les deux jobs installent les dépendances ; un cache partagé permet de limiter ce coût.
Les gates n’ont d’utilité que si elles sont obligatoires. Sur votre forge, marquez les contrôles comme requis dans la protection des branches afin qu’une pull request ne puisse pas être fusionnée tant que l’un d’entre eux est au rouge.
Matrices et parallélisme
Une matrice exécute une définition de job sur plusieurs combinaisons d’entrées. C’est ainsi que vous testez plusieurs versions de Node et différents systèmes d’exploitation sans dupliquer votre YAML.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
Ceci se développe en quatre jobs. fail-fast: false permet aux autres de se terminer même si l’un d’eux échoue, ce qui est utile pour savoir si l’échec est spécifique à une version.
Le parallélisme n’est pas gratuit. Chaque job provisionne un runner et installe les dépendances ; ainsi, une matrice large coûte plus de temps et d’argent qu’une matrice restreinte. Testez les versions que vous supportez réellement, et non toutes les versions existantes.
Pour les suites de tests volumineuses, fragmentez (shard) les tests eux-mêmes : passez un index de shard et le total au test runner afin que chacun des quatre jobs exécute un quart des tests. La suite se termine alors en environ un quart du temps.
Secrets et variables d’environnement
Les secrets sont des valeurs chiffrées que le pipeline injecte au moment de l’exécution. Ils doivent être stockés dans le gestionnaire de secrets de la plateforme, jamais dans le dépôt, et jamais dans l’image.
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
La plateforme masque les valeurs de secrets connues dans les logs, mais ce masquage est fourni au mieux (« best-effort »). Il ne peut pas détecter un secret qui a été transformé, encodé en base64 ou affiché par un outil qui le reformate. La règle de sécurité est simple : n’affichez jamais un secret. N’exécutez pas env dans une étape de debug, ne faites pas de echo d’un token, et ne passez pas de secret à une commande qui logue ses arguments.
Limitez la portée des secrets à l’environnement qui en a besoin. Un déploiement de staging n’a aucune raison de posséder le mot de passe de la base de données de production. Sur GitHub, les secrets d’environnement ne sont disponibles que pour les jobs qui déclarent cet environnement, et peuvent nécessiter une approbation préalable.
Pour les fournisseurs de cloud, privilégiez l’OIDC aux clés à longue durée de vie. Le pipeline échange un token d’identité éphémère contre des identifiants temporaires, il n’y a donc aucun secret statique pouvant fuiter ou nécessitant une rotation.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy
aws-region: eu-west-1
Le token est valide quelques minutes et limité à un seul rôle. S’il fuite, le rayon d’impact est limité à une seule exécution.
Un avertissement mérite d’être souligné : les pull requests provenant de forks exécutent du code non approuvé. N’exposez jamais de secrets à ce code. Utilisez pull_request plutôt que pull_request_target pour tout ce qui récupère et build le code d’un contributeur, et exigez une approbation avant d’exécuter des workflows provenant de contributeurs pour la première fois.
Environnements, approbations et branches protégées
Un environnement est une cible de déploiement nommée possédant ses propres secrets, règles de protection et URL. C’est le mécanisme qui permet de distinguer la production de tout le reste.
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- run: ./deploy.sh
Les environnements peuvent exiger une approbation manuelle avant l’exécution d’un job, restreindre les branches autorisées à déployer vers eux, et limiter le temps d’attente d’un déploiement avant l’expiration du délai (timeout). C’est ici que se trouve le « bouton humain » de la livraison continue : le pipeline s’exécute automatiquement jusqu’à la barrière, puis un réviseur le libère.
Les branches protégées complètent les environnements. Sur la branche main, exigez une pull request, la réussite des status checks, une revue, et interdisez les force pushes. Ensemble, ils garantissent que le seul moyen d’accéder à la production est de passer par le pipeline, ce qui est précisément l’objectif.
Stratégies de déploiement et rollbacks
La manière dont une nouvelle version remplace l’ancienne est une décision de conception qui a des conséquences réelles pour les utilisateurs.
Le Rolling deployment remplace les instances progressivement, quelques-unes à la fois. La capacité diminue légèrement pendant l’échange, mais aucune infrastructure supplémentaire n’est requise. C’est le mode par défaut sur la plupart des plateformes et le point de départ idéal.
Le Blue-green deployment fait fonctionner deux environnements complets. Le trafic est dirigé vers l’environnement “blue” pendant que le “green” reçoit la nouvelle version ; une fois que le “green” est opérationnel, un simple changement de routage bascule tout le trafic. Le rollback consiste à revenir en arrière, ce qui est quasi instantané. L’inconvénient est le coût lié à l’exécution de deux environnements simultanément.
Le Canary deployment envoie une petite fraction du trafic — un pour cent, puis cinq, puis cinquante — vers la nouvelle version, et surveille les taux d’erreur et la latence avant de poursuivre. Cela permet de détecter des problèmes qu’un health check ne pourrait pas identifier, au prix d’un routage et d’un monitoring plus complexes.
Quelle que soit la stratégie, le déploiement doit être réversible. Comme l’artefact précédent est immuable et toujours présent dans le registry, un rollback est un redéploiement du digest précédent, et non une reconstruction :
kubectl rollout undo deploy/app
Deux détails rendent les rollbacks sécurisés. Les migrations de base de données doivent être rétrocompatibles pendant au moins une version, afin que l’ancien code puisse toujours fonctionner avec le nouveau schéma. De plus, les feature flags vous permettent de désactiver un nouveau comportement sans rien déployer du tout, ce qui constitue le rollback le plus rapide qui soit.
Registres d’artefacts et images de conteneurs
Un registre d’artefacts stocke le résultat du build : images de conteneurs, packages npm, binaires, tarballs. C’est le point de passage entre le build et le déploiement, et il doit être immuable.
Les images de conteneurs sont les artefacts les plus courants car elles embarquent leur propre runtime. Un registre tel que GitHub Container Registry, Amazon ECR ou Docker Hub stocke des couches (layers), et le pipeline y effectue un push à chaque build. Les déploiements récupèrent ensuite le digest exact.
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Les options cache-from et cache-to permettent de persister le cache des couches Docker entre les exécutions, afin qu’une couche de dépendances inchangée ne soit pas reconstruite entièrement à chaque fois.
Mettez en place une politique de rétention. Les registres accumulent rapidement des gigaoctets de données, et la suppression des anciennes images est essentielle pour maintenir la rapidité du pipeline. Conservez au moins les dernières versions publiées pour permettre un rollback si nécessaire.
Vérifications de statut et protection de branche
Une vérification de statut (status check) est le verdict du pipeline sur un commit. La forge l’associe à la pull request, et la protection de branche détermine si elle est indicative ou obligatoire.
Configurez la branche main pour exiger :
- Une pull request avant la fusion, avec au moins une approbation.
- Des vérifications de statut réussies, incluant le lint, le typecheck et les tests.
- Une branche à jour avec main avant la fusion, afin que les vérifications soient exécutées sur le code qui sera réellement déployé.
- Aucun force push et aucune suppression.
Le résultat est que la branche main est toujours « verte ». Chaque commit qu’elle contient a franchi les mêmes étapes de validation, et chaque déploiement à partir de celle-ci repose sur un artefact dont la fiabilité est connue.
Gardez la liste des exigences courte. Imposer une suite de tests end-to-end lente sur chaque pull request fait attendre les développeurs ; l’exiger dans la file d’attente de fusion (merge queue) ou sur la branche main offre la même sécurité sans créer de frictions.
Mise en cache et builds incrémentaux
La mise en cache fait la différence entre un pipeline de deux minutes et un de douze minutes. Le principe est simple : ne jamais répéter un travail dont les entrées n’ont pas changé.
Le cache le plus précieux concerne les dépendances. Utilisez le hash du lockfile comme clé afin qu’il soit invalidé précisément lorsque les dépendances changent :
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-
restore-keys permet une correspondance partielle : même en cas d’absence de la clé exacte, le cache le plus récent est restauré et mis à jour de manière incrémentale, ce qui est bien plus rapide qu’une installation à froid.
Les outils de build ajoutent leurs propres caches. Le build incrémental de TypeScript, le pré-bundling des dépendances de Vite et le cache de couches de Docker bénéficient tous d’une persistance entre les exécutions. Pour Docker, utilisez le registre ou le backend de cache du fournisseur de CI plutôt que le démon local, qui est supprimé avec le runner.
Un cache est une optimisation, jamais une source de vérité. Si un cache est corrompu ou obsolète, l’exécution doit tout de même rester correcte. Les outils de build qui font aveuglément confiance à un cache peuvent produire des résultats erronés ; définissez donc vos clés de cache avec prudence et considérez l’absence de cache (cache miss) comme un comportement normal.
Pipelines pour les monorepos
Un monorepo regroupe plusieurs packages dans un seul dépôt. Un pipeline naïf reconstruit et reteste l’ensemble des packages à chaque modification, ce qui ralentit le processus à mesure que le dépôt s’agrandit.
La solution réside dans la détection des changements. Les filtres de chemin (path filters) et les graphes de dépendances indiquent au pipeline quels packages sont affectés par un diff, et seuls ceux-ci sont alors buildés.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run test --filter='...[origin/main]'
Le filtre sélectionne les packages modifiés depuis main ainsi que tout ce qui en dépend. Une modification d’un utilitaire partagé déclenche le build de ses dépendants ; une modification d’un package “feuille” ne déclenche que son propre build.
Ajoutez un cache distant (remote cache) afin que les fichiers compilés et les résultats des tests soient partagés entre les machines et les exécutions. Si les entrées d’un package n’ont pas changé, son build est restauré plutôt que recalculé. Dans un monorepo, le gain est souvent plus important qu’avec le cache des dépendances.
Gardez votre pipeline rigoureux : une modification du lockfile à la racine ou d’une configuration partagée doit toujours déclencher l’exécution de la suite complète. Les tests sélectifs sont une optimisation qui ne doit jamais laisser passer un package défectueux.
Sécurité du pipeline
Le pipeline détient les identifiants de production, ce qui en fait une cible de choix. Traitez les fichiers de workflow comme du code sensible et examinez attentivement chaque modification.
Épinglez les actions à une version. Un tag comme @v4 est un pointeur mobile ; un SHA de commit complet est immuable. L’épinglage à un SHA est l’option la plus stricte et empêche un tag compromis d’exécuter du code malveillant dans votre pipeline.
Accordez le moindre privilège. Définissez explicitement permissions sur chaque workflow et job, en privilégiant la lecture seule par défaut, et n’ajoutez des permissions d’écriture que là où elles sont nécessaires.
permissions:
contents: read
Le GITHUB_TOKEN par défaut est souvent plus large que ce dont un job a réellement besoin. Le restreindre permet d’éviter qu’une étape compromise ne puisse pousser des commits ou publier des packages.
Privilégiez l’OIDC aux clés statiques, comme décrit précédemment. Des identifiants à courte durée de vie limités à un seul rôle éliminent les secrets permanents que les attaquants recherchent le plus.
N’exécutez pas de code non fiable avec des secrets. Les pull requests provenant de forks sont le vecteur d’attaque classique. Utilisez pull_request pour le build et les tests, exigez une approbation pour les nouveaux contributeurs, et ne récupérez jamais et n’exécutez jamais le code d’un fork dans un workflow ayant accès à des secrets.
Examinez les actions tierces. Une action est une dépendance qui s’exécute avec vos identifiants. Privilégiez les actions provenant de la forge elle-même ou d’éditeurs reconnus, et auditez toutes les autres.
Bonnes pratiques
- Générez l’artéfact une seule fois et faites-le progresser à travers chaque environnement.
- Marquez les artéfacts par le SHA du commit ou par digest, jamais uniquement par
latest. - Injectez la configuration au runtime ; gardez le build identique partout.
- Exécutez le lint et le typecheck avant la suite de tests (plus lente) pour obtenir des retours d’échec rapides.
- Rendez les vérifications importantes obligatoires dans la protection des branches.
- Mettez en cache les dépendances en utilisant le hash du lockfile comme clé, avec des restore keys.
- Limitez la portée des secrets aux environnements et privilégiez les identifiants OIDC à courte durée de vie.
- N’affichez jamais les secrets et ne les exposez jamais à des pull requests non approuvées.
- Épinglez les actions tierces et configurez les permissions de token selon le principe du moindre privilège.
- Utilisez
concurrencypour annuler les exécutions obsolètes. - Gardez le pipeline rapide ; un pipeline lent finit par être contourné.
- Effectuez les rollbacks via un redéploiement du digest précédent.
Erreurs courantes
- Reconstruire l’image différemment pour chaque environnement et appeler cela une « promotion ».
- Intégrer la configuration de l’environnement dans l’image lors de l’étape de build.
- Utiliser
latestcomme seul tag et perdre ainsi toute traçabilité. - Afficher des secrets dans une étape de debug en supposant que le masquage automatique capture tout.
- Exposer des secrets aux workflows déclenchés par des pull requests provenant de forks.
- Référencer des actions tierces via une branche instable.
- Laisser les permissions du token par défaut trop permissives.
- Marquer chaque check optionnel comme obligatoire, ralentissant ainsi considérablement les merges.
- Laisser des tests instables (flaky tests) en échec jusqu’à ce que plus personne ne consulte les résultats.
- Mettre en place un cache agressif sans clé d’invalidation basée sur les changements réels des entrées.
- Tout reconstruire dans un monorepo à chaque modification.
- Ne disposer d’aucun moyen de rollback autre que le revert d’un commit et l’attente du déploiement.
Et après ?
Un pipeline se termine par le déploiement d’un artefact ; la suite logique est donc d’explorer les Cloud Platforms, là où s’effectuent concrètement la mise en production, les health checks et les rollbacks. L’artefact est généralement un conteneur, et le guide Docker détaille les images et les builds multi-étapes produits par le pipeline. La plupart des runners étant des machines Linux, le guide Linux explique le shell et les outils dont dépendent vos étapes, tandis que le guide Node.js pose les bases du runtime et de la toolchain utilisée.