Aller au contenu
Anatomie d'un pipeline

Anatomie d'un pipeline

100 Comprendre ⏱ 1 h gitdockerci-cdgithub-actions

À la fin, vous saurez

  • Nommer les éléments d'un pipeline et le rôle de chacun
  • Construire un serveur d'intégration continue minimal avec un crochet Git et des conteneurs jetables
  • Expliquer pourquoi un pipeline repose sur les codes de sortie et sur des espaces de travail neufs
  • Distinguer artefact et cache
  • Retrouver ces mêmes éléments dans GitHub Actions et GitLab CI

Prérequis

Testé avec actionlint 1.7.12 docker 29.8.1 git 2.43.0 pytest 9.1.1 python 3.14.7 ruff 0.16.9 , vérifié le 1 octobre 2026

Pourquoi

À la leçon précédente, les tests ont été lancés à la main, dans un conteneur propre, après chaque fusion. Cela fonctionne tant que chacun y pense, n'oublie aucune étape, et lance la bonne commande. L'intégration continue exige au contraire que chaque commit soit vérifié, toujours de la même façon, et que le résultat soit visible de toute l'équipe. Il faut donc une machine qui réagit aux poussées, exécute les vérifications et publie un verdict : un système d'intégration continue, et la suite d'étapes qu'il exécute, le pipeline.

Les produits du marché (GitHub Actions, GitLab CI, Jenkins, Forgejo Actions, Woodpecker...) cachent beaucoup de mécanique derrière un fichier YAML. Pour comprendre ce qu'ils font, et pouvoir diagnostiquer un pipeline qui se comporte bizarrement, le plus efficace est d'en construire un. C'est l'objet de cette leçon : un serveur d'intégration continue complet en une cinquantaine de lignes de Bash, avec Git et Docker. Il n'est pas fait pour la production ; il est fait pour que chaque pièce soit visible.

Les concepts

Les pièces d'un pipeline

    flowchart LR
  P["git push"] --> D["Déclencheur"]
  D --> E["Espace de travail neuf<br/>(le commit, rien d'autre)"]
  E --> S1["Étape lint"]
  S1 -->|"code 0"| S2["Étape tests"]
  S2 -->|"code 0"| R["Statut : succès"]
  S1 -->|"code ≠ 0"| X["Statut : échec"]
  S2 -->|"code ≠ 0"| X
  S2 -.-> A["Artefacts<br/>(rapport, image)"]
  C[("Cache")] -.-> S1
  C -.-> S2
  
  • Le déclencheur (trigger) est l'événement qui lance le pipeline : une poussée sur une branche, l'ouverture d'une demande de fusion, une étiquette, une heure fixe, un clic.
  • La définition du pipeline décrit les étapes. Elle est versionnée dans le dépôt, à côté du code qu'elle vérifie : .github/workflows/*.yml chez GitHub, .gitlab-ci.yml chez GitLab, un Jenkinsfile chez Jenkins. Une modification du pipeline est donc un commit comme un autre, relu, testé et historisé.
  • L'agent (runner) est la machine qui exécute le travail. Le serveur décide quoi exécuter, l'agent l'exécute.
  • L'espace de travail (workspace) est le répertoire où l'agent dépose les sources du commit à vérifier. Il doit être neuf à chaque exécution.
  • Une étape (job, step, stage selon les produits) est une commande, ou un groupe de commandes, dont le code de sortie décide de la suite : 0 signifie succès, toute autre valeur signifie échec.
  • Le journal (log) conserve la sortie de chaque étape pour le diagnostic.
  • Un artefact est un fichier produit par le pipeline et conservé après lui : rapport de tests, binaire, image de conteneur.
  • Le cache conserve d'une exécution à l'autre ce qui coûte cher à refaire et qui peut être refait : dépendances téléchargées, résultats de compilation.
  • Le statut est le verdict, rattaché au commit, et affiché à l'équipe.

Un pipeline, c'est une suite de codes de sortie

Un système de CI ne comprend pas ce que font les étapes. Il ne lit pas « 3 passed » ou « Found 1 error » : il regarde le code de sortie de chaque processus. Tout outil utilisable en CI respecte donc la convention Unix : pytest, ruff, go test, docker build, trivy sortent avec un code non nul quand ils trouvent un problème. La conséquence pratique est importante : un script d'étape qui masque les codes de sortie (une commande suivie de || true, un tube commande | tee fichier sans set -o pipefail) produit un pipeline toujours vert, donc inutile.

Artefact ou cache

Les deux sont des fichiers qui survivent à une exécution, et on les confond souvent. Ils n'ont pourtant rien à voir :

ArtefactCache
RôleRésultat du pipeline, ce que l'on livre ou consulteAccélérateur, ce que l'on aurait pu retélécharger
Si on le perdOn a perdu quelque chose (il faut reconstruire depuis le commit)Le pipeline est seulement plus lent
Lien au commitFort : il est le produit de ce commitAucun : partagé entre commits et branches
ExemplesRapport de tests, binaire, image, SBOMPaquets pip ou npm, couches de construction
Peut-on lui faire confiance ?Oui, s'il est traçable jusqu'au commitNon : une étape ne doit jamais dépendre de son contenu pour être correcte

La dernière ligne est celle qui compte. Un pipeline doit donner le même résultat avec un cache vide. Un cache ne sert qu'à aller plus vite, et la leçon 5 montrera qu'il peut aussi servir de vecteur d'attaque.

En pratique

L'architecture du serveur

Le serveur tient en trois fichiers :

serveur-ci/
├── signalements.git/          dépôt nu, le « GitHub » de l'équipe
│   └── hooks/post-receive     le déclencheur
├── executer                   l'orchestrateur : un pipeline pour un commit
├── etat                       l'affichage des statuts
├── travaux/<n>/               un espace de travail par exécution (créé à la volée)
└── cache/pip/                 le cache des paquets Python (créé à la volée)

Et le dépôt de l'application contient un fichier .pipeline qui décrit les étapes.

Le déclencheur : un crochet Git

Git sait exécuter des scripts, appelés crochets (hooks), à certains moments de sa vie. Côté serveur, le crochet post-receive est exécuté après chaque poussée acceptée. Git lui passe sur l'entrée standard une ligne par branche mise à jour : l'ancienne empreinte, la nouvelle, et le nom de la référence.

$ mkdir serveur-ci && cd serveur-ci
$ git init --bare -b main signalements.git

Créez signalements.git/hooks/post-receive :

#!/usr/bin/env bash
# Déclencheur : Git appelle ce crochet après chaque poussée acceptée, avec une
# ligne « ancien nouveau référence » par branche mise à jour.
while read -r ancien nouveau ref; do
  case "$nouveau" in 0000000000000000000000000000000000000000) continue ;; esac  # branche supprimée
  "$(dirname "$PWD")/executer" "$nouveau" "$ref"
done

Une empreinte faite de zéros signale une branche supprimée : rien à vérifier. Git exécute les crochets depuis le répertoire du dépôt nu, d'où $(dirname "$PWD") pour retrouver serveur-ci/.

L'orchestrateur

Créez executer. C'est le cœur du système, il est commenté en quatre temps :

#!/usr/bin/env bash
# Exécute le pipeline d'un commit. Usage : executer <commit> <référence>
# Appelé par le crochet post-receive du dépôt, depuis le dépôt nu.
set -uo pipefail
commit=$1 ref=$2
base=$(cd "$(dirname "$0")" && pwd)

# 1. Un numéro et un espace de travail neuf pour chaque exécution
numero=$(( $(cat "$base/compteur" 2>/dev/null || echo 0) + 1 ))
echo "$numero" > "$base/compteur"
travail="$base/travaux/$numero"
mkdir -p "$travail/src" "$travail/journaux" "$base/cache/pip"
git archive "$commit" | tar -x -C "$travail/src"
echo "pipeline n°$numero : ${ref#refs/heads/} @ ${commit:0:7}"

# 2. La définition du pipeline vient du dépôt lui-même
if [ ! -f "$travail/src/.pipeline" ]; then
  echo "  pas de fichier .pipeline : rien à exécuter"
  exit 0
fi

# 3. Chaque étape dans un conteneur jetable ; la première qui échoue arrête tout
statut=succes
while read -r etape image commande; do
  case "$etape" in ''|'#'*) continue ;; esac
  debut=$(date +%s)
  if docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \
       -e PIP_CACHE_DIR=/cache/pip -e PIP_DISABLE_PIP_VERSION_CHECK=1 \
       -v "$base/cache":/cache -v "$travail/src":/src -w /src \
       "$image" sh -c "$commande" > "$travail/journaux/$etape.log" 2>&1 < /dev/null
  then
    echo "  OK     $etape ($(( $(date +%s) - debut )) s)"
  else
    echo "  ÉCHEC  $etape ($(( $(date +%s) - debut )) s), fin du journal :"
    tail -n 5 "$travail/journaux/$etape.log" | sed 's/^/         /'
    statut=echec
    break
  fi
done < "$travail/src/.pipeline"

# 4. Le statut est enregistré, lié au commit
echo "$statut $commit $ref" > "$travail/statut"
echo "résultat : $statut (journaux dans $travail/journaux)"

# Code de sortie : 0 si le pipeline a réussi
[ "$statut" = succes ]

Rendez les deux scripts exécutables :

$ chmod +x executer signalements.git/hooks/post-receive

Chaque ligne a une raison d'être :

  • git archive "$commit" | tar -x extrait exactement le contenu du commit, et rien d'autre : ni historique, ni fichiers non versionnés, ni restes d'une exécution précédente. C'est l'espace de travail neuf.
  • docker run --rm : chaque étape démarre d'une image publique et disparaît ensuite. Rien ne passe d'une étape à l'autre, sauf ce qui est écrit dans l'espace de travail ou le cache.
  • --user "$(id -u):$(id -g)" : les étapes tournent avec l'identité de l'utilisateur du serveur, pas en root. Sans cette option, les fichiers produits dans l'espace de travail (rapport de tests, __pycache__) appartiendraient à root, et le serveur ne pourrait plus les supprimer. HOME=/tmp donne un répertoire personnel inscriptible à cet utilisateur, qui n'a pas de compte dans l'image.
  • -v "$base/cache":/cache et PIP_CACHE_DIR : pip range ses téléchargements dans un répertoire qui survit aux conteneurs. C'est le cache.
  • < /dev/null : la boucle while read lit le fichier .pipeline sur l'entrée standard. Une étape qui lirait elle aussi l'entrée standard (un docker run -i, un ssh) avalerait les lignes suivantes, et le pipeline s'arrêterait sans erreur. On la débranche par précaution.
  • break au premier échec : inutile de lancer les tests si l'analyse a échoué. C'est le principe d'échec rapide (fail fast), approfondi à la leçon 3.
  • La dernière ligne donne au script lui-même un code de sortie qui reflète le verdict. Il servira dans les exercices.

La définition du pipeline

Dans le dépôt de Signalements, tel que vous l'avez laissé à la leçon 1 (avec la route de consultation et requirements-dev.txt), créez .pipeline. Chaque ligne donne le nom de l'étape, l'image dans laquelle elle s'exécute, et la commande :

# étape   image              commande
lint      python:3.14-slim   pip install --user --quiet ruff==0.16.9 && python -m ruff check --no-cache .
tests     python:3.14-slim   pip install --user --quiet -r requirements-dev.txt && python -m pytest -q -p no:cacheprovider --junitxml=rapport-tests.xml

pip install --user installe dans $HOME/.local, inscriptible par l'utilisateur non-root ; python -m ruff et python -m pytest évitent de dépendre du PATH. L'option --junitxml demande à pytest un rapport au format JUnit XML, le format d'échange que comprennent presque toutes les plateformes de CI pour afficher les résultats de tests.

Première poussée

Faites de ce serveur le dépôt distant de votre copie de travail, puis poussez :

$ git remote add ci /chemin/vers/serveur-ci/signalements.git
$ git add .pipeline && git commit -m "Signalements avec la consultation, et un pipeline"
$ git push ci main
remote: pipeline n°1 : main @ 19bb367
remote:   ÉCHEC  lint (4 s), fin du journal :
remote:          95 |             return jsonify(etat="degrade", erreur=str(erreur)), 503
remote:          96 |     return jsonify(etat="ok")
remote:             |
remote:
remote:          Found 1 error.
remote: résultat : echec (journaux dans /home/.../serveur-ci/travaux/1/journaux)
To /home/.../serveur-ci/signalements.git
 * [new branch]      main -> main

Tout ce que le crochet écrit est renvoyé au développeur, préfixé par remote:. Le retour arrive pendant la poussée elle-même : c'est le plus court des cycles de retour possibles.

Et le premier pipeline est rouge. Le journal complet explique pourquoi :

$ cat serveur-ci/travaux/1/journaux/lint.log
BLE001 Do not catch blind exception: `Exception`
  --> app.py:94:16
   |
92 |             with connexion() as cnx:
93 |                 cnx.execute("SELECT 1")
94 |         except Exception as erreur:  # la base est injoignable
   |                ^^^^^^^^^
95 |             return jsonify(etat="degrade", erreur=str(erreur)), 503
96 |     return jsonify(etat="ok")
   |

Found 1 error.

Ce code n'a pourtant pas changé depuis le premier cours. Ce qui a changé, c'est l'outil : la version 0.16.0 de Ruff, publiée le 23 juillet 2026, a fait passer son jeu de règles actives par défaut de 59 à 413. La règle BLE001 reproche à la fonction sante d'intercepter toutes les exceptions, y compris une faute de frappe dans le code, qui serait alors présentée comme une panne de la base. Le reproche est fondé : n'interceptons que les erreurs du pilote PostgreSQL.

@app.get("/sante")
def sante():
    if DATABASE_URL:
        import psycopg
        try:
            with connexion() as cnx:
                cnx.execute("SELECT 1")
        except psycopg.Error as erreur:  # la base est injoignable
            return jsonify(etat="degrade", erreur=str(erreur)), 503
    return jsonify(etat="ok")
$ git commit -am "Santé : n'intercepter que les erreurs de la base"
$ git push ci main
remote: pipeline n°2 : main @ 7a254a5
remote:   OK     lint (2 s)
remote:   OK     tests (7 s)
remote: résultat : succes (journaux dans /home/.../serveur-ci/travaux/2/journaux)

Important

Cet épisode est la meilleure illustration de la raison pour laquelle on épingle la version des outils dans un pipeline (ruff==0.16.9, et non ruff). Une équipe qui installait « la dernière version » a vu, le 23 juillet 2026, tous ses pipelines passer au rouge sans avoir changé une ligne de code. Avec une version épinglée, la mise à jour de l'outil devient un commit délibéré, que l'on fait quand on a le temps de traiter les nouvelles alertes.

Les artefacts et les journaux

Chaque exécution laisse un répertoire :

$ ls serveur-ci/travaux/2/src serveur-ci/travaux/2/journaux
serveur-ci/travaux/2/journaux:
lint.log  tests.log

serveur-ci/travaux/2/src:
__pycache__  app.py  rapport-tests.xml  requirements-dev.txt  requirements.txt  test_app.py
$ cat serveur-ci/travaux/2/statut
succes 7a254a5a7f9b2732af50c4b2ba21e9fe76617441 refs/heads/main

Le rapport rapport-tests.xml est un artefact : il appartient à ce commit, et il dit quels tests ont été exécutés et combien de temps chacun a pris :

<?xml version="1.0" encoding="utf-8"?><testsuites name="pytest tests"><testsuite name="pytest" errors="0" failures="0" skipped="0" tests="3" time="0.218" timestamp="2026-10-01T14:43:00.490622+00:00" hostname="a75d4dbcf939"><testcase classname="test_app" name="test_accueil" time="0.012" /><testcase classname="test_app" name="test_creer_puis_lister" time="0.001" /><testcase classname="test_app" name="test_detail" time="0.002" /></testsuite></testsuites>

Le hostname est celui du conteneur jetable qui a exécuté les tests.

Le cache

Le cache de pip pèse maintenant 21 Mo. Relançons le pipeline sur le même code (un commit vide suffit), puis une seconde fois après avoir mis le cache de côté :

$ du -sh serveur-ci/cache/pip
21M	serveur-ci/cache/pip
$ git commit --allow-empty -m "Relancer" && git push -q ci main
remote: pipeline n°3 : main @ e7d0583
remote:   OK     lint (2 s)
remote:   OK     tests (4 s)
$ mv serveur-ci/cache serveur-ci/cache.bak
$ git commit --allow-empty -m "Relancer sans cache" && git push -q ci main
remote: pipeline n°4 : main @ 0a71440
remote:   OK     lint (4 s)
remote:   OK     tests (6 s)

Le résultat est identique, seule la durée change : c'est la définition d'un cache. Sur Signalements, le gain est de quelques secondes, parce que les dépendances sont légères et la connexion rapide ; sur un projet qui télécharge des centaines de paquets npm ou compile des dépendances, il se compte en minutes.

Une branche rouge, et le statut

Le crochet traite toutes les branches. Poussons une branche d'essai dont un test échoue :

$ git switch -c essai
$ sed -i 's/"Place du Marché"$/"Place du marché"/' test_app.py
$ git commit -am "Test : casse du lieu" && git push -q ci essai
remote: pipeline n°5 : essai @ 880045d
remote:   OK     lint (2 s)
remote:   ÉCHEC  tests (5 s), fin du journal :
remote:
remote:          test_app.py:22: AssertionError
remote:          =========================== short test summary info ============================
remote:          FAILED test_app.py::test_detail - AssertionError: assert 'Place du Marché' ==...
remote:          1 failed, 2 passed in 0.11s
remote: résultat : echec (journaux dans /home/.../serveur-ci/travaux/5/journaux)

Fowler compte parmi les pratiques de l'intégration continue que « tout le monde voit ce qui se passe ». Le script etat résume les dernières exécutions :

#!/usr/bin/env bash
# Affiche le statut des dernières exécutions. Usage : etat [nombre]
cd "$(dirname "$0")/travaux" || exit 1
for numero in $(ls | sort -n | tail -n "${1:-5}"); do
  [ -f "$numero/statut" ] || { echo "n°$numero  en cours ou sans pipeline"; continue; }
  read -r statut commit ref < "$numero/statut"
  printf 'n°%-3s %-7s %-12s %s\n' "$numero" "$statut" "${ref#refs/heads/}" "${commit:0:7}"
done
$ serveur-ci/etat
n°1   echec   main         19bb367
n°2   succes  main         7a254a5
n°3   succes  main         e7d0583
n°4   succes  main         0a71440
n°5   echec   essai        880045d

La branche essai est rouge, main reste verte : le défaut est confiné à la branche de son auteur.

Le même pipeline dans les outils du marché

Chaque pièce de notre serveur a son équivalent :

PièceNotre serveurGitHub ActionsGitLab CI
Définition.pipeline.github/workflows/*.yml.gitlab-ci.yml
Déclencheurcrochet post-receiveon: push, on: pull_request...poussée, demande de fusion, rules:
Unité d'exécutionune lignejob, composé de stepsjob, regroupés en stages
Agentla machine du serveur, docker runrunner hébergé ou auto-hébergéGitLab Runner (exécuteur docker, kubernetes...)
Espace de travailtravaux/<n>/src$GITHUB_WORKSPACE, rempli par actions/checkout$CI_PROJECT_DIR, rempli par le runner
Artefactfichiers laissés dans travaux/<n>actions/upload-artifactartifacts:
Cachecache/pipactions/cache, setup-python avec cache: pipcache:
Statutetatchecks affichés sur le commit et la demande de fusionstatut du pipeline sur le commit

Pour comparaison, voici le même pipeline écrit pour GitHub Actions. Il a été validé avec actionlint 1.7.12 (présenté dans le cours Construire des images de conteneurs), mais pas exécuté ici, faute de dépôt GitHub :

name: CI
on:
  push:
  pull_request:
permissions:
  contents: read
jobs:
  lint:
    runs-on: ubuntu-24.04
    container: python:3.14-slim
    steps:
      - uses: actions/checkout@v7
      - run: pip install --quiet ruff==0.16.9 && ruff check .
  tests:
    needs: lint
    runs-on: ubuntu-24.04
    container: python:3.14-slim
    steps:
      - uses: actions/checkout@v7
      - run: pip install --quiet -r requirements-dev.txt && pytest -q --junitxml=rapport-tests.xml
      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: rapport-tests
          path: rapport-tests.xml

On y retrouve tout : déclencheurs, une image par travail, un espace de travail rempli à partir du commit, l'ordre des étapes (needs: lint), l'artefact. Le cours GitHub Actions détaille cette syntaxe.

Sous le capot

Le crochet et la poussée. Le crochet post-receive s'exécute après la mise à jour des références : quand le pipeline tourne, la branche est déjà poussée, quel que soit le verdict. Git attend néanmoins la fin du crochet pour rendre la main au client, et relaie sa sortie avec le préfixe remote:. Notre serveur est donc synchrone : la commande git push dure aussi longtemps que le pipeline. Avec neuf secondes, c'est agréable ; avec vingt minutes de tests, ce serait inacceptable.

Comment font les vrais systèmes. Ils découplent en trois. La forge (GitHub, GitLab, Forgejo) enregistre la poussée et produit un événement. Un ordonnanceur transforme l'événement en travaux placés dans une file d'attente. Des agents viennent chercher ces travaux. Ce dernier point surprend souvent : ce n'est pas le serveur qui contacte les agents, ce sont les agents qui interrogent le serveur. Un GitLab Runner demande s'il y a du travail toutes les 3 secondes par défaut (paramètre check_interval) ; un agent auto-hébergé de GitHub Actions maintient une connexion HTTPS sortante qu'il renouvelle toutes les 50 secondes (long poll). Conséquence pratique : un agent peut vivre dans un réseau privé, derrière un pare-feu qui n'accepte aucune connexion entrante, ce qui compte beaucoup pour déployer dans une infrastructure interne.

Ce que fait vraiment actions/checkout. Exactement ce que fait notre git archive : déposer dans l'espace de travail le contenu d'un commit précis. Par défaut, il ne récupère que ce commit (profondeur 1), sans l'historique : un pipeline qui a besoin de l'historique (pour calculer un numéro de version à partir des étiquettes, par exemple) doit le demander (fetch-depth: 0).

Le crochet pre-receive. Git propose aussi un crochet exécuté avant la mise à jour des références, et qui peut la refuser en sortant avec un code non nul. Les objets poussés sont alors gardés dans une zone de quarantaine, et ne sont intégrés au dépôt que si le crochet accepte. C'est la forme la plus stricte de protection d'une branche : rien n'entre dans main sans pipeline vert. L'exercice 3 la met en œuvre. Les forges obtiennent le même résultat autrement, avec les vérifications requises (required status checks) sur les demandes de fusion.

Pièges courants

Le pipeline qui ne marche que sur l'agent habituel. Un agent permanent, réutilisé d'une exécution à l'autre, accumule des fichiers : dépendances installées à la main, artefacts d'une branche précédente, variables d'environnement. Un pipeline peut alors réussir grâce à ces restes, et échouer le jour où il tombe sur un agent neuf. D'où git archive dans un répertoire neuf et docker run --rm : chaque exécution part de zéro.

Un pipeline toujours vert. pytest | tee rapport.txt renvoie le code de sortie de tee, c'est-à-dire 0, même si des tests échouent. En Bash, set -o pipefail fait remonter l'échec ; en YAML, les plateformes exécutent généralement les étapes avec bash -e, mais pas toujours avec pipefail : vérifiez dans la documentation de la vôtre, et testez qu'un échec rend bien le pipeline rouge.

Des outils non épinglés. Démontré plus haut avec Ruff 0.16. La même chose arrive avec une image python:3-slim qui passe de 3.14 à 3.15, ou une action GitHub référencée par une étiquette mobile. Épinglez les versions, et mettez-les à jour par des commits délibérés (des outils comme Dependabot ou Renovate proposent ces mises à jour automatiquement).

Un pipeline qui dépend du cache. Si une étape ne réussit qu'avec un cache rempli (un fichier qu'elle y trouve et ne sait pas recréer), le premier agent neuf, ou la première purge du cache, casse le pipeline. Testez régulièrement avec un cache vide, comme ci-dessus.

Des fichiers root dans l'espace de travail. Sans --user, un conteneur écrit en root dans le répertoire monté, et le serveur ne peut plus nettoyer. C'est un incident classique des agents auto-hébergés qui utilisent Docker.

Sécurité

Le serveur que nous venons d'écrire exécute n'importe quelle commande écrite dans .pipeline par n'importe quelle personne qui peut pousser une branche. C'est le cas de tous les systèmes de CI, et c'est la propriété qui en fait une cible : un pipeline est un service d'exécution de code à distance, offert à tous les contributeurs.

Pour l'instant, les dégâts sont limités parce que les étapes tournent dans des conteneurs jetables, sans privilèges, sans secrets, et ne voient que leur espace de travail et le cache. Mais déjà :

  • Le cache est partagé entre toutes les branches. Une branche malveillante peut y déposer un faux paquet que la construction de main réutilisera ensuite.
  • Les étapes ont accès au réseau. Une étape peut envoyer l'espace de travail, donc le code source, à un serveur extérieur.
  • Le serveur a accès au socket Docker. Si l'on ajoutait un jour, pour construire des images, une étape qui monte /var/run/docker.sock, cette étape deviendrait root sur la machine (cours Docker : les fondamentaux, leçon 12).

La leçon 5 reprend ces risques un par un, en ajoutant au serveur ce qui en fait vraiment une cible : des secrets.

En production

  • Des agents jetables. Les agents hébergés de GitHub et de GitLab sont des machines virtuelles neuves à chaque travail, détruites ensuite. Pour des agents auto-hébergés, visez la même chose : une machine virtuelle ou un pod Kubernetes par travail (Actions Runner Controller pour GitHub, exécuteur kubernetes pour GitLab Runner), plutôt qu'un serveur permanent.
  • Des agents dans votre réseau, si nécessaire. Puisque les agents ne reçoivent aucune connexion entrante, on peut en placer dans un réseau privé chez Scaleway pour déployer sur des ressources qui ne sont pas exposées sur Internet, sans ouvrir de port.
  • Rétention des journaux et des artefacts. Notre serveur garde tout pour toujours. Les plateformes appliquent une durée de rétention (90 jours par défaut pour les artefacts et journaux de GitHub Actions, réglable) : décidez-la en fonction de vos besoins d'audit, et publiez ailleurs les artefacts qui doivent durer (images dans un registre, binaires dans un dépôt d'artefacts).
  • Le pipeline de ce site. Le site apprendre.lyneko.com est construit par un workflow GitHub Actions de trois travaux : lint (le contrôle des tirets et des compétences), build-and-push (construction de l'image du site et publication dans le registre Scaleway, sauf pour une demande de fusion), et deploy, réservé à main. La leçon 4 montre ce dernier.

Exercices

1. Lire un pipeline (niveau 100). Pour chacune des pièces suivantes de notre serveur, dites si c'est un artefact, un cache, un journal ou un statut, et ce qui se passe si on la supprime : travaux/2/src/rapport-tests.xml, cache/pip/, travaux/2/journaux/tests.log, travaux/2/statut.

Solution

rapport-tests.xml est un artefact : il appartient au commit 7a254a5 ; si on le supprime, il faut relancer le pipeline sur ce commit pour le retrouver. cache/pip/ est un cache : si on le supprime, les prochains pipelines retéléchargent les paquets et durent quelques secondes de plus, avec le même résultat. tests.log est un journal : le supprimer fait perdre le diagnostic de cette exécution. statut est le verdict : sans lui, etat affiche « en cours ou sans pipeline », et l'équipe ne sait plus si ce commit a été vérifié.

2. Ajouter une étape (niveau 100). Ajoutez au pipeline une étape format qui vérifie, sans rien modifier, que le code est formaté selon ruff format. Où la placez-vous dans le fichier, et pourquoi ? Que doit renvoyer la commande quand un fichier est mal formaté ?

Solution
format    python:3.14-slim   pip install --user --quiet ruff==0.16.9 && python -m ruff format --check --no-cache .

--check ne modifie rien et sort avec le code 1 si un fichier serait reformaté, ce qui fait échouer l'étape. On la place en premier, ou juste après lint : elle dure deux secondes et échoue souvent, donc elle doit passer avant les tests, plus longs (principe d'échec rapide). Sur le code de Signalements tel quel, elle échoue : ruff format reformaterait app.py et test_app.py. Formatez-les avec ruff format . et commitez.

3. Interdire les poussées rouges sur main (niveau 100). Écrivez un crochet pre-receive qui exécute le pipeline pour toute poussée sur main, et refuse la poussée si le pipeline échoue. Vérifiez-le en poussant la branche essai sur main (git push ci essai:main).

Solution

signalements.git/hooks/pre-receive, à rendre exécutable :

#!/usr/bin/env bash
# Refuse toute poussée sur main dont le pipeline échoue.
code=0
while read -r ancien nouveau ref; do
  [ "$ref" = refs/heads/main ] || continue
  case "$nouveau" in 0000000000000000000000000000000000000000) continue ;; esac
  "$(dirname "$PWD")/executer" "$nouveau" "$ref" || code=1
done
exit "$code"
$ git push ci essai:main
remote: pipeline n°6 : main @ 880045d
remote:   OK     lint (3 s)
remote:   ÉCHEC  tests (5 s), fin du journal :
...
remote: résultat : echec (journaux dans /home/.../serveur-ci/travaux/6/journaux)
To /home/.../serveur-ci/signalements.git
 ! [remote rejected] essai -> main (pre-receive hook declined)
error: failed to push some refs to '/home/.../serveur-ci/signalements.git'
$ git ls-remote ci main
0a71440727f9568a00db6cf6eabded9ed63d01c2	refs/heads/main

main n'a pas bougé. Le code de sortie de executer (sa dernière ligne) fait tout le travail. Avec les deux crochets en place, une poussée acceptée sur main exécuterait le pipeline deux fois : faites ignorer main au crochet post-receive, ou retirez ce dernier pour main.

Récapitulatif

  • Un pipeline se compose d'un déclencheur, d'une définition versionnée dans le dépôt, d'agents qui exécutent des étapes dans un espace de travail neuf, de journaux, d'artefacts, d'un cache et d'un statut rattaché au commit.
  • Le verdict repose sur les codes de sortie : un outil de CI doit sortir en erreur quand il trouve un problème, et un script d'étape ne doit jamais masquer ce code.
  • Artefact : le produit du commit, à conserver et à tracer. Cache : un accélérateur, dont le pipeline doit pouvoir se passer.
  • Épinglez les versions des outils et des images, sans quoi le pipeline change de comportement sans changement de code (Ruff 0.16).
  • Les vrais systèmes sont asynchrones : événement, file d'attente, agents qui viennent chercher le travail par des connexions sortantes.
  • Un pipeline est un service d'exécution de code offert à tous ceux qui peuvent pousser : c'est le point de départ de sa sécurité (leçon 5).

Pour aller plus loin

  • La page githooks de la documentation Git, pour la liste complète des crochets et leurs paramètres.
  • Le chapitre 5 de Continuous Delivery (Humble et Farley), qui décrit le pipeline de déploiement comme une suite d'étapes de plus en plus coûteuses et de plus en plus proches de la production : c'est la structure des deux leçons suivantes.
  • La documentation Understanding GitHub Actions et CI/CD pipelines de GitLab, à relire maintenant que chaque terme a un sens concret.
Voir ma constellation →

Sources