Jobs, matrices et services
Pourquoi
Le workflow de Signalements vérifie le lint et les tests unitaires dans un seul job, sur une seule version de Python, sans base de données. Trois limites apparaissent dès qu'il sert une vraie équipe.
Les tests d'intégration ne tournent pas. test_integration.py est ignoré faute de DATABASE_URL. Au cours précédent, le serveur fait main démarrait un PostgreSQL éphémère sur un réseau privé, attendait qu'il réponde, et le détruisait quoi qu'il arrive. GitHub Actions sait le faire, à condition de lui dire comment savoir que la base est prête.
Une seule combinaison est vérifiée. Le développeur travaille avec PostgreSQL 18 ; un client héberge encore l'application sur PostgreSQL 17, qui reste maintenu jusqu'en 2029. Une fonction disponible sur l'un et pas sur l'autre passe tous les tests et casse chez le client.
Tout est séquentiel. Les tests d'intégration n'ont rien à attendre du formatage. Les jobs indépendants peuvent tourner en parallèle, chacun sur sa machine, et le verdict arrive plus vite.
Cette leçon organise le workflow en graphe de jobs, multiplie les tests d'intégration avec une matrice, et leur donne une vraie base avec un conteneur de service. Elle règle au passage une difficulté qui naît de ce découpage : quel check exiger avant la fusion quand il y en a sept, dont certains changent de nom ?
Les concepts
Le graphe des jobs
Sans indication, tous les jobs d'un workflow démarrent en même temps. needs crée une dépendance : le job attend que ceux qu'il cite soient terminés avec succès. On obtient un graphe orienté sans cycle, que GitHub affiche sur la page du run.
flowchart LR
C["changements"] --> V["verifier<br/>lint, unitaires"]
V --> I1["intégration<br/>py3.13 / pg17"]
V --> I2["intégration<br/>py3.13 / pg18"]
V --> I3["intégration<br/>py3.14 / pg17"]
V --> I4["intégration<br/>py3.14 / pg18"]
V --> I5["intégration<br/>py3.15 / pg18<br/>(expérimental)"]
C --> T["CI terminée"]
V --> T
I1 --> T
I2 --> T
I3 --> T
I4 --> T
I5 --> T
La règle de propagation est stricte : si un job échoue ou est ignoré, tous les jobs qui en dépendent sont ignorés, de proche en proche, sauf s'ils ont une condition qui les fait continuer (always(), failure()...). Dans le contexte needs, chaque job cité expose son résultat dans needs.<id>.result : success, failure, cancelled ou skipped.
Placer les tests d'intégration après verifier est un choix : on ne paie pas cinq machines et cinq bases de données pour un commit qui ne passe pas le lint. C'est l'échec rapide du cours précédent, appliqué au graphe. Le prix est une latence plus longue quand tout va bien ; si les vérifications rapides échouent rarement, on peut préférer tout lancer en parallèle.
La matrice
Une matrice déclare des listes de valeurs, et GitHub crée un job par combinaison (le produit cartésien) :
strategy:
matrix:
python: ["3.13", "3.14"]
postgres: ["17.11", "18.6"]Quatre jobs, chacun avec ses valeurs dans le contexte matrix (matrix.python, matrix.postgres). Le développement de la matrice se fait côté GitHub, avant l'attribution des runners : chaque combinaison est un job indépendant, sur sa propre machine.
Quatre réglages complètent la matrice :
includeajoute des valeurs à des combinaisons existantes, ou crée des combinaisons nouvelles. Pour chaque objet d'include, GitHub l'ajoute à toutes les combinaisons qu'il ne contredit pas ; s'il en contredit toutes, il devient une combinaison à part.excluderetire des combinaisons ; une correspondance partielle suffit. Lesincludesont traités après lesexclude.fail-fast(vrai par défaut) annule tous les jobs de la matrice, en cours ou en attente, dès que l'un échoue.max-parallellimite le nombre de jobs de la matrice exécutés en même temps.
Une matrice est limitée à 256 jobs par run. On l'atteint plus vite qu'on ne le croit : trois systèmes, quatre versions de langage, trois versions de base et deux architectures font déjà 72 jobs.
Les conteneurs de service
Un service est un conteneur démarré par le runner avant les étapes du job et détruit après, quel que soit le résultat. Il sert à fournir aux tests une base de données, un cache, une file de messages. C'est l'équivalent de la ligne service du serveur fait main, avec les mêmes besoins : un réseau, un nom pour joindre le service, un moyen de savoir qu'il est prêt.
Deux modes de communication existent, selon l'endroit où s'exécutent les étapes :
| Les étapes s'exécutent | Le service se joint par | Il faut |
|---|---|---|
| directement sur la VM du runner (cas par défaut) | localhost:<port> | publier le port avec ports: |
dans un conteneur de job (container:) | le nom du service (db) | rien : même réseau Docker, tous les ports visibles |
Les services ne fonctionnent que sur des runners Linux, et ne peuvent pas être déclarés dans une action composite (leçon 7).
En pratique
Le service PostgreSQL
Le job d'intégration de Signalements :
integration:
name: Intégration (Python ${{ matrix.python }}, PostgreSQL ${{ matrix.postgres }})
needs: verifier
runs-on: ubuntu-24.04
timeout-minutes: 15
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
python: ["3.13", "3.14"]
postgres: ["17.11", "18.6"]
experimental: [false]
include:
- python: "3.15"
postgres: "18.6"
experimental: true
services:
db:
image: postgres:${{ matrix.postgres }}
env:
POSTGRES_PASSWORD: ci
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 2s
--health-timeout 5s
--health-retries 15
env:
DATABASE_URL: postgresql://postgres:ci@localhost:5432/postgres
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
allow-prereleases: ${{ matrix.experimental }}
- run: pip install -r requirements-dev.txt
- run: pytest -q test_integration.pyLes points qui comptent :
services.db.imagedépend de la matrice : chaque combinaison démarre sa propre base, dans la version voulue. Les versions sont complètes (17.11, pas17), pour la même raison que l'on épingle tout le reste : une imagepostgres:17qui change de version mineure entre deux runs rend un échec difficile à expliquer.ports: 5432:5432publie le port de la base sur la VM, parce que les étapes s'exécutent directement sur la VM et joignent la base parlocalhost.optionsest passé tel quel àdocker create. Les quatre options--health-*donnent au conteneur un contrôle de santé :pg_isreadytoutes les 2 secondes, jusqu'à 15 essais. La section Sous le capot montre pourquoi c'est indispensable, et pas une simple précaution. Le>-est la syntaxe YAML d'un texte replié : les lignes sont jointes par des espaces, sans retour à la ligne final.POSTGRES_PASSWORD: ciest un mot de passe en clair, et c'est acceptable : la base n'existe que le temps du job, n'est joignable que depuis la VM, et ne contient que des données de test. Ce n'est pas un secret, et le mettre danssecretsne ferait que compliquer la lecture.DATABASE_URLest déclarée au niveau du job : toutes les étapes la voient.fail-fast: false: si PostgreSQL 17 échoue, on veut quand même savoir si PostgreSQL 18 passe. Le diagnostic « échoue sur 17 seulement » est beaucoup plus utile que « échoue quelque part ».- L'entrée expérimentale :
includeajoute une cinquième combinaison, Python 3.15 (dont la version finale est annoncée pour le 1er octobre 2026 ; seule la version candidate 2 était installable lors des essais) avec PostgreSQL 18. Comme3.15n'existe pas dans la listepython, elle ne s'ajoute à aucune combinaison existante et devient un job à part. Les quatre combinaisons normales reçoiventexperimental: falsepar la troisième dimension, à une seule valeur, de la matrice.continue-on-errorempêche un échec de ce job de faire échouer le run, etallow-prereleasesautorisesetup-pythonà installer une préversion quand aucune version finale n'existe.
On sait ainsi, avant la sortie de chaque nouvelle version de Python, si les dépendances s'installent et si les tests passent, sans bloquer personne si ce n'est pas encore le cas. Localement, avec l'image python:3.15-rc-slim, les dépendances de Signalements s'installent et les tests unitaires passent déjà sur la version candidate 2 :
$ docker run --rm python:3.15-rc-slim python --version
Python 3.15.0rc2
$ docker run --rm -v "$PWD":/src:ro -w /src -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 \
python:3.15-rc-slim sh -c 'pip install --user --quiet -r requirements-dev.txt 2>/dev/null &&
python -m pytest -q -p no:cacheprovider test_app.py'
.... [100%]
4 passed in 0.14s
Reproduire la matrice localement
Le runner ne fait rien de magique pour les services : il crée un réseau Docker, démarre la base avec un alias sur ce réseau, attend qu'elle soit déclarée saine, exécute les étapes, puis détruit le tout. Le script suivant fait la même chose sur un poste (avec dix essais du contrôle de santé au lieu de quinze), pour les quatre combinaisons, avec les étapes dans un conteneur Python (le mode « conteneur de job », où la base se joint par son nom db) :
#!/usr/bin/env bash
# Reproduit localement la matrice d'intégration du workflow, comme le runner :
# un réseau par job, PostgreSQL en service (alias « db ») avec contrôle de santé,
# les tests dans un conteneur Python sur le même réseau, puis nettoyage.
set -uo pipefail
cd "$(dirname "$0")/.."
bilan=0
journal=$(mktemp)
for python in 3.13 3.14; do
for postgres in 17.11 18.6; do
reseau="matrice_$$_${python}_${postgres}"
docker network create "$reseau" > /dev/null
docker run -d --name "db_$reseau" --network "$reseau" --network-alias db \
-e POSTGRES_PASSWORD=ci \
--health-cmd pg_isready --health-interval 2s --health-timeout 5s --health-retries 10 \
"postgres:$postgres" > /dev/null
until [ "$(docker inspect -f '{{.State.Health.Status}}' "db_$reseau")" = healthy ]; do sleep 1; done
if docker run --rm --network "$reseau" -v "$PWD":/src:ro -w /src -e HOME=/tmp \
-e PIP_DISABLE_PIP_VERSION_CHECK=1 -e PYTHONDONTWRITEBYTECODE=1 \
-e DATABASE_URL=postgresql://postgres:ci@db:5432/postgres \
"python:$python-slim" sh -c 'pip install --user --quiet -r requirements-dev.txt &&
python -m pytest -q -p no:cacheprovider test_integration.py' > "$journal" 2>&1
then echo "OK python $python / postgres $postgres : $(tail -n 1 "$journal")"
else echo "ÉCHEC python $python / postgres $postgres"; tail -n 5 "$journal"; bilan=1
fi
docker rm -f "db_$reseau" > /dev/null; docker network rm "$reseau" > /dev/null
done
done
rm -f "$journal"
exit $bilanEnregistrez-le sous ci/matrice-locale.sh, rendez-le exécutable, et lancez-le :
$ time ci/matrice-locale.sh
OK python 3.13 / postgres 17.11 : 2 passed in 0.48s
OK python 3.13 / postgres 18.6 : 2 passed in 0.51s
OK python 3.14 / postgres 17.11 : 2 passed in 0.45s
OK python 3.14 / postgres 18.6 : 2 passed in 0.29s
real 0m50,162s
Cinquante secondes en séquence sur un poste ; sur GitHub, les quatre jobs tournent en parallèle, et la durée est celle du plus lent.
Ce que la matrice attrape
Un développeur ajoute à la table des signalements une référence publique, unique et triable dans le temps. PostgreSQL 18 fournit justement une fonction qui génère des UUID de version 7, et il l'utilise comme valeur par défaut :
" cree_le timestamptz NOT NULL DEFAULT now(),"
" reference uuid NOT NULL DEFAULT uuidv7())"Sur son poste, avec PostgreSQL 18, tout passe. La matrice, elle :
$ ci/matrice-locale.sh
ÉCHEC python 3.13 / postgres 17.11
/tmp/.local/lib/python3.13/site-packages/psycopg/connection.py:304: UndefinedFunction
=========================== short test summary info ============================
FAILED test_integration.py::test_stockage_postgresql - psycopg.errors.Undefin...
FAILED test_integration.py::test_creer_consulter_lister - psycopg.errors.Unde...
2 failed in 0.48s
OK python 3.13 / postgres 18.6 : 2 passed in 0.47s
ÉCHEC python 3.14 / postgres 17.11
/tmp/.local/lib/python3.14/site-packages/psycopg/connection.py:304: UndefinedFunction
=========================== short test summary info ============================
FAILED test_integration.py::test_stockage_postgresql - psycopg.errors.Undefin...
FAILED test_integration.py::test_creer_consulter_lister - psycopg.errors.Unde...
2 failed in 0.47s
OK python 3.14 / postgres 18.6 : 2 passed in 0.39s
$ echo $?
1
Le motif saute aux yeux : tout ce qui est PostgreSQL 17 échoue, tout ce qui est 18 passe, quelle que soit la version de Python. Le message d'erreur complet, côté base :
$ docker run -d --name pg17-essai -e POSTGRES_PASSWORD=ci postgres:17.11
$ docker exec pg17-essai psql -U postgres -c "SELECT uuidv7();"
ERROR: function uuidv7() does not exist
LINE 1: SELECT uuidv7();
^
HINT: No function matches the given name and argument types. You might need to add explicit type casts.
C'est exactement ce que fail-fast: false apporte. Avec la valeur par défaut, le premier échec aurait annulé les autres jobs, et l'on aurait vu « échec sur Python 3.13 / PostgreSQL 17 » sans savoir si PostgreSQL 18 passait. La matrice ne dit pas quoi faire (abandonner uuidv7(), générer l'identifiant côté application, ou annoncer la fin du support de PostgreSQL 17), mais elle force la décision avant la fusion, et pas chez le client.
Un seul check pour la protection de branche
Le workflow publie maintenant jusqu'à sept checks, dont les noms dépendent de la matrice : Intégration (Python 3.14, PostgreSQL 18.6). Exiger chacun d'eux dans la règle de protection de branche est fragile : ajouter une version à la matrice crée un check que la règle ignore, en retirer une crée un check exigé qui ne viendra jamais. Et quand verifier est ignoré (seulement de la documentation modifiée), les jobs d'intégration le sont aussi, ce qui est correct mais rend le tableau difficile à lire.
La pratique courante est un dernier job, agrégateur, qui dépend de tous les autres et publie un verdict unique. C'est lui seul que la règle de protection exige :
ci-terminee:
name: CI terminée
needs: [changements, verifier, integration]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 2
steps:
- name: Bilan des jobs
env:
RESULTATS: ${{ toJSON(needs.*.result) }}
run: |
echo "Résultats : $RESULTATS"
if grep -qE '"(failure|cancelled)"' <<<"$RESULTATS"; then
echo "Au moins un job a échoué ou a été annulé."
exit 1
fineeds.*.result est un filtre d'objet (leçon 3) : la liste des résultats des jobs cités. Le job échoue si l'un d'eux a échoué ou a été annulé, et réussit si tous ont réussi ou ont été ignorés. La logique se vérifie localement : enregistrez le script de l'étape dans bilan.sh, et donnez-lui les trois cas typiques (documentation seule, une intégration en échec, run annulé) :
$ RESULTATS='["success", "skipped", "skipped"]' bash --noprofile --norc -eo pipefail bilan.sh; echo "code de sortie : $?"
Résultats : ["success", "skipped", "skipped"]
code de sortie : 0
$ RESULTATS='["success", "success", "failure"]' bash --noprofile --norc -eo pipefail bilan.sh; echo "code de sortie : $?"
Résultats : ["success", "success", "failure"]
Au moins un job a échoué ou a été annulé.
code de sortie : 1
$ RESULTATS='["success", "cancelled", "skipped"]' bash --noprofile --norc -eo pipefail bilan.sh; echo "code de sortie : $?"
Résultats : ["success", "cancelled", "skipped"]
Au moins un job a échoué ou a été annulé.
code de sortie : 1
Sur GitHub, toJSON présente la liste sur plusieurs lignes, un élément par ligne ; le motif de grep fonctionne de la même façon.
Au passage, le job verifier prend le libellé Lint et tests unitaires, plus précis maintenant que l'intégration a son propre job. Avant l'agrégateur, ce renommage aurait bloqué les demandes de fusion (leçon 1) ; maintenant que seul CI terminée est exigé, il ne bloque plus rien.
Ce job est la seule exception à la recommandation de la leçon 3 de préférer !cancelled() à always(). Avec !cancelled(), un run annulé ignorerait l'agrégateur, et un job ignoré compte comme réussi pour la protection de branche : on pourrait fusionner sans que rien ait été vérifié. Avec always(), l'agrégateur s'exécute même après une annulation, voit cancelled, et échoue. Il ne fait qu'un grep : il ne risque pas de bloquer l'annulation.
Les autres formes de matrice
Exclure une combinaison. Si la version 3.13 de Python n'est plus prise en charge avec PostgreSQL 18 :
matrix:
python: ["3.13", "3.14"]
postgres: ["17.11", "18.6"]
exclude:
- python: "3.13"
postgres: "18.6"Une matrice calculée. Une matrice peut venir d'une sortie de job, convertie par fromJSON. Un job de détection (comme changements) produit la liste des composants touchés, et la matrice ne crée des jobs que pour eux :
composants:
outputs:
liste: ${{ steps.calcul.outputs.liste }} # par exemple ["api","export"]
# ...
tester:
needs: composants
strategy:
matrix:
composant: ${{ fromJSON(needs.composants.outputs.liste) }}Une liste vide fait échouer le développement de la matrice : prévoyez un if: sur le job pour ce cas.
Un conteneur de job. Au lieu de la VM, les étapes peuvent s'exécuter dans un conteneur, et la base se joint alors par son nom :
container: python:3.14-slim
services:
db:
image: postgres:18.6
# pas de ports : même réseau que le conteneur de job
env:
DATABASE_URL: postgresql://postgres:ci@db:5432/postgresC'est le mode le plus proche de l'environnement de production quand celle-ci tourne en conteneurs, et celui qu'utilise le script local. Les actions écrites en JavaScript s'exécutent alors aussi dans ce conteneur, ce qui suppose qu'il ait les bibliothèques nécessaires : une image très minimale peut faire échouer checkout.
Sous le capot
Le comportement des services est décrit dans le code de l'agent, ContainerOperationProvider.cs. Au début du job, dans l'étape Initialize containers :
- Le runner crée un réseau Docker propre au job, nommé
github_network_suivi d'un identifiant aléatoire. Le commentaire du code en donne la raison : éviter les conflits de ports quand plusieurs runners partagent une machine. - Il crée chaque conteneur de service avec
--networksur ce réseau et--network-aliaségal au nom du service (db), plus lesportspubliés et le contenu d'optionsajouté tel quel. - Il attend que les services soient sains. Et c'est là que tout se joue : il lit l'état de santé avec
docker inspect, et si l'image n'a pas de contrôle de santé, il n'attend pas du tout. Sinon, tant que l'état eststarting, il réessaie avec une attente qui double à chaque tentative, de 2 à 32 secondes ; si l'état final n'est pashealthy, le job échoue.
Or l'image officielle postgres ne déclare aucun contrôle de santé :
$ docker image inspect postgres:18.6 --format '{{json .Config.Healthcheck}}'
null
Sans les options --health-*, le runner passe donc immédiatement aux étapes, alors que PostgreSQL est encore en train d'initialiser son répertoire de données. Sur une VM rapide, checkout, setup-python et pip install prennent assez de temps pour que la base soit prête ; sur un jour chargé, ou avec un cache qui accélère l'installation (leçon 5), les tests démarrent trop tôt et échouent avec connection refused. C'est un test instable typique, qui n'échoue qu'une fois sur vingt, et dont la cause n'est pas dans le code testé.
En fin de job, dans Stop containers, le runner supprime les conteneurs et le réseau, que le job ait réussi ou non. Sur un runner hébergé, la VM entière disparaît de toute façon ; sur un runner auto-hébergé, ce nettoyage compte (leçon 12).
Pièges courants
connection refused aléatoire en début de tests. Pas de contrôle de santé sur le service. Ajoutez les options --health-*, ou une étape qui attend la base (le script ci/attendre_postgres.py du cours précédent).
could not translate host name "db". Les étapes s'exécutent sur la VM, pas dans un conteneur de job : le nom db n'existe que sur le réseau Docker. Utilisez localhost et publiez le port, ou passez en conteneur de job.
Bind for 0.0.0.0:5432 failed: port is already allocated. Sur un runner auto-hébergé qui exécute plusieurs jobs, ou qui a déjà un PostgreSQL. Publiez le port sans préciser le port de l'hôte (ports: ["5432"]) : Docker en choisit un libre, que l'on retrouve dans le contexte job.services.db.ports['5432'].
Une version de matrice tronquée. python: [3.10, 3.14] sans guillemets : 3.10 devient 3.1 (leçon 1). Toujours des chaînes.
Une faute de frappe dans matrix. actionlint connaît les clés de la matrice :
$ actionlint .github/workflows/faute.yml
.github/workflows/faute.yml:116:31: property "pyhton" is not defined in object type {experimental: bool; postgres: number; python: number} [expression]
|
116 | python-version: ${{ matrix.pyhton }}
| ^~~~~~~~~~~~~
(actionlint déduit ici que python est un nombre, alors que les valeurs sont des chaînes : son typage est approximatif, mais il trouve bien la clé inconnue.)
Le check exigé n'existe plus. Un job de matrice a changé de nom parce qu'une version a changé. Exigez l'agrégateur, pas les jobs de matrice.
Tous les jobs dépendants sont ignorés. Un job en amont a échoué ou a été ignoré, et la propagation a fait le reste. Le graphe de la page du run le montre ; la condition if: d'un job ignoré est détaillée dans son journal (leçon 3).
Sécurité
Les images de service sont du code exécuté. postgres:18.6 est une étiquette : elle peut être republiée. Pour un dépôt sensible, épinglez les services par empreinte (postgres:18.6@sha256:...), comme les images de base au cours Construire des images de conteneurs. zizmor le signale avec l'audit unpinned-images.
Les identifiants de test ne sont pas des secrets, mais restent des identifiants. Sur un runner hébergé, la base n'est joignable que depuis la VM éphémère. Sur un runner auto-hébergé, un port publié sur 0.0.0.0 est joignable depuis le réseau de la machine pendant la durée du job : publiez sur 127.0.0.1 (127.0.0.1:5432:5432) ou n'utilisez pas ports en mode conteneur de job.
Une matrice calculée à partir de données externes est une entrée non fiable. Si la liste de la matrice vient de noms de fichiers ou de répertoires choisis dans une demande de fusion, ses valeurs finissent dans des noms de jobs, des chemins, des commandes. Les règles de la leçon 3 s'appliquent : passez-les par env:, et validez-les contre une liste connue.
En production
Le coût d'une matrice. Chaque combinaison est un job facturé séparément, arrondi à la minute. La matrice de cette leçon, cinq jobs d'environ une minute, coûte cinq minutes par run, contre une pour un job unique. Sur un dépôt privé au plan gratuit, à 25 runs par jour ouvré, cela fait 2 750 minutes par mois pour l'intégration seule, au-delà des 2 000 incluses. Choisissez les dimensions avec une politique de prise en charge explicite : en général, la plus ancienne et la plus récente version supportées de chaque composant, pas toutes les versions intermédiaires.
Le parallélisme. Une organisation au plan gratuit exécute au plus 20 jobs à la fois, tous dépôts confondus. Une grosse matrice peut retenir les jobs des autres équipes ; max-parallel évite qu'un seul workflow occupe tout.
Les matrices de systèmes. Une matrice sur runs-on (ubuntu-24.04, windows-2025, macos-15) est courante pour un outil en ligne de commande distribué aux utilisateurs. Pour une application serveur comme Signalements, qui ne tourne qu'en conteneur Linux, elle n'apporte rien et coûte cher : une minute macOS coûte dix fois une minute Linux.
Garder l'agrégateur. Il simplifie aussi l'évolution : on peut réorganiser tout le graphe, ajouter des jobs, renommer la matrice, sans toucher à la règle de protection de branche.
Exercices
1. Combien de jobs crée cette matrice, et lesquels ?
matrix:
python: ["3.13", "3.14"]
postgres: ["17.11", "18.6"]
exclude:
- postgres: "17.11"
include:
- python: "3.14"
postgres: "17.11"
lent: trueSolution
Le produit donne quatre combinaisons ; exclude retire les deux qui ont PostgreSQL 17.11 (une correspondance partielle suffit). Restent (3.13, 18.6) et (3.14, 18.6). Puis include est traité : l'objet {python: 3.14, postgres: 17.11, lent: true} contredit les deux combinaisons restantes sur postgres, il devient donc une combinaison à part. Total : trois jobs, (3.13, 18.6), (3.14, 18.6) et (3.14, 17.11, lent).
2. Retirez les options --health-* du service dans le script local, puis lancez-le plusieurs fois. Que se passe-t-il ? Pourquoi le script local échoue-t-il plus franchement que le workflow sur GitHub ?
Solution
Sans contrôle de santé, docker inspect -f '{{.State.Health.Status}}' échoue, car l'état du conteneur n'a pas de champ Health :
$ docker inspect -f '{{.State.Health.Status}}' sans-sante
template parsing error: template: :1:8: executing "" at <.State.Health.Status>: map has no entry for key "Health"
La boucle until ne se termine alors jamais : il faut adapter le script, par exemple en supprimant l'attente, ce qui imite le runner. On observe alors des échecs connection refused, parce que les tests démarrent pendant l'initialisation de PostgreSQL. Sur GitHub, le runner n'attend pas non plus, mais checkout, setup-python et pip install laissent souvent assez de temps à la base : l'échec n'arrive qu'occasionnellement, ce qui le rend bien plus difficile à diagnostiquer.
3. Écrivez un job deployer-recette qui ne s'exécute que sur main, seulement si ci-terminee a réussi. Faut-il un if: always() ?
Solution
deployer-recette:
needs: ci-terminee
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
steps:
- run: echo "déploiement (leçon 9)"Pas de always() : on veut justement que le job soit ignoré si ci-terminee a échoué. La condition implicite success() s'ajoute à github.ref == 'refs/heads/main'.
4. Un collègue propose de remplacer l'agrégateur par une règle de protection qui exige les cinq jobs d'intégration et Lint et tests unitaires. Donnez deux situations dans lesquelles cette règle bloque une demande de fusion correcte.
Solution
Une demande de fusion qui fait évoluer la matrice (passer de PostgreSQL 18.6 à 18.7) : le check Intégration (Python 3.14, PostgreSQL 18.6) n'est plus produit, et la règle l'attend indéfiniment ; il faut modifier la règle et le workflow en même temps, ce qu'une demande de fusion ne sait pas faire. Et une demande de fusion qui retire l'entrée expérimentale ou renomme le libellé name: du job d'intégration : même effet, pour tous les checks de la matrice. À l'inverse, ajouter une version à la matrice crée un check que la règle ignore : un échec sur cette version ne bloquerait rien.
5. Ajoutez Redis 8 comme second service du job d'intégration, avec un contrôle de santé. Quelle commande de santé utiliser, et comment l'application le joindrait-elle ?
Solution
services:
db:
# ... inchangé
cache:
image: redis:8.8.3
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 2s
--health-timeout 5s
--health-retries 15redis-cli ping répond PONG avec un code de sortie nul quand le serveur accepte les commandes. Les étapes s'exécutant sur la VM, l'application le joint par localhost:6379 (en conteneur de job, ce serait cache:6379). Les guillemets autour de redis-cli ping sont nécessaires : la commande contient une espace.
Récapitulatif
needsconstruit un graphe de jobs ; un job en échec ou ignoré fait ignorer tous les jobs qui en dépendent.- Une matrice crée un job par combinaison ;
includeajoute,excluderetire,fail-fast: falsedonne le tableau complet des échecs,continue-on-errortolère une entrée expérimentale. - Un service est un conteneur démarré avant les étapes, sur un réseau propre au job :
localhostetportsdepuis la VM, le nom du service depuis un conteneur de job. - Sans contrôle de santé, le runner n'attend pas le service : l'image
postgresn'en a pas, ajoutez les options--health-*. - Exigez un agrégateur
if: always()dans la protection de branche, pas les jobs de matrice. - Chaque combinaison est facturée : bornez la matrice par une politique de prise en charge.
Pour aller plus loin
- GitHub Docs, Running variations of jobs in a workflow : tous les cas d'
includeet d'exclude. - actions/runner, ContainerOperationProvider.cs : le cycle de vie complet des conteneurs de job et de service.
- Leçon suivante : Cache et artefacts.
Sources
- GitHub Docs, Workflow syntax : jobs.<job_id>.needs, strategy, services, container
- GitHub Docs, Running variations of jobs in a workflow (matrices)
- GitHub Docs, Communicating with Docker service containers
- GitHub Docs, Creating PostgreSQL service containers
- actions/runner, ContainerOperationProvider.cs (réseau, alias et contrôle de santé des services)
- Docker, référence de docker run : --health-cmd et options de contrôle de santé
- PostgreSQL 18, notes de version : fonction uuidv7()
- PostgreSQL, politique de versions et de fin de support
- actions/python-versions, manifeste des versions installables par setup-python