Écrire des tests, la plupart des formations le font faire. Les lancer à chaque modification, beaucoup moins de monde le fait, surtout sur un projet perso où personne ne vérifie.
Le scénario est banal. Une fonction est modifiée en fin de journée, le code part sur GitHub, et le test qui aurait signalé la casse dort dans le dossier tests/. On le découvre trois commits plus tard, quand plus personne ne sait quelle modification a tout cassé.
Le blocage tient en peu de chose : lancer les tests dépend d'une personne qui doit y penser, au bon moment, à chaque fois. Une habitude humaine finit toujours par sauter un jour de fatigue, alors qu'une machine qui lance pytest à chaque push ne fatigue pas.
C'est exactement ce que fait GitHub Actions, l'outil d'automatisation intégré à GitHub. Cet article s'adresse aux personnes en reconversion, aux développeurs juniors et aux curieux qui ont déjà un projet Python sur GitHub. À la fin, ton dépôt lancera ses tests tout seul, affichera le résultat sur chaque pull request et pourra refuser la fusion d'un code qui casse un test.
Le problème : des tests que personne ne lance
Il y a d'abord le cas du projet solo. Les tests ont été écrits pendant un tutoriel, ils passaient le jour même, puis le code a évolué sans eux. Ils donnent une impression de sécurité alors qu'ils ne surveillent plus rien.
Il y a ensuite le cas du petit groupe, typique des projets de fin de formation. Trois personnes poussent sur le même dépôt, chacune lance les tests sur sa machine quand elle y pense, avec sa propre version de Python et ses propres bibliothèques installées. Ce décalage entre machines, l'article sur la phrase « ça marche sur ma machine » le décrit en détail.
Dans les deux cas, le résultat des tests n'est visible par personne d'autre que la personne qui les a lancés, quand elle les a lancés. Une pull request arrive sans aucune indication sur l'état du code. La relecture se fait à l'œil, et l'œil rate les régressions, ces bugs qui réapparaissent dans un code qui fonctionnait.
L'article Pipeline CI/CD : le guide pour les débutants et reconversions explique l'idée générale de l'intégration continue et recommande, dans ses dernières lignes, de configurer un premier workflow GitHub Actions. Ici, on passe à la pratique : le fichier, ligne par ligne.
Le principe : une machine prêtée qui exécute ta liste
GitHub Actions repose sur une idée simple. Tu décris dans un fichier texte une liste de commandes, et GitHub les exécute sur une machine qu'il te prête dès qu'un événement se produit dans le dépôt.
Les cinq mots à connaître
- Workflow : le fichier qui décrit l'automatisation. Il est écrit en YAML, un format de configuration basé sur l'indentation, et doit être rangé dans le dossier
.github/workflows/du dépôt, avec l'extension.ymlou.yaml. - Événement : ce qui déclenche le workflow. Ici, un
push(envoi de commits) ou unepull_request(demande de fusion). - Job : un ensemble d'étapes exécutées sur la même machine. Un workflow peut en contenir plusieurs.
- Step (étape) : une commande shell (
run) ou une action réutilisable (uses), c'est-à-dire un petit programme publié par quelqu'un d'autre, comme celui qui installe Python. - Runner : la machine qui exécute le job. Avec
ubuntu-latest, GitHub fournit une machine virtuelle Linux neuve à chaque lancement.
Le verdict vient du code de sortie
Le point qui débloque tout le reste : GitHub ne lit pas les messages affichés par tes commandes. Il regarde uniquement leur code de sortie, un nombre que chaque programme renvoie en se terminant. Zéro signifie « tout s'est bien passé », n'importe quel autre nombre signifie un échec. Le premier step qui renvoie autre chose que zéro arrête le job et le marque en rouge.
pytest respecte cette convention : il renvoie 0 quand tous les tests passent, 1 quand au moins un échoue, et 5 quand il n'a trouvé aucun test à exécuter. Ce dernier cas, vérifié en local avec pytest 9.2, protège contre un workflow qui tourne à vide. Retiens cette règle, elle explique la plupart des pièges décrits plus bas : une coche verte veut dire « aucune commande n'a renvoyé d'erreur », et seulement ça.
Ce que ça coûte
D'après la documentation de facturation de GitHub Actions, les runners standards sont gratuits et sans limite sur les dépôts publics. Sur un dépôt privé avec le plan gratuit, GitHub inclut chaque mois un crédit de 12 dollars, décompté à 0,006 dollar la minute sur un runner Linux à deux cœurs, soit l'équivalent de 2 000 minutes.
Exemples : du projet au workflow qui bloque la fusion
Le projet de départ
Pour rester lisible, le projet tient en une fonction : calculer un prix après remise, arrondi au centime, en refusant une remise impossible. Voici l'arborescence complète, workflow compris.
mon-projet/
├── .github/
│ └── workflows/
│ └── tests.yml
├── src/
│ └── remise.py
├── tests/
│ └── test_remise.py
├── pyproject.toml
└── requirements-dev.txt
Le code testé :
# src/remise.py
def prix_remise(prix: float, pourcentage: int) -> float:
"""Applique une remise en pourcentage et arrondit au centime."""
if not 0 <= pourcentage <= 100:
raise ValueError("La remise doit être comprise entre 0 et 100")
return round(prix * (100 - pourcentage) / 100, 2)
Les tests, dont un qui vérifie qu'une erreur est bien levée :
# tests/test_remise.py
import pytest
from src.remise import prix_remise
def test_remise_simple():
assert prix_remise(100, 20) == 80
def test_arrondi_au_centime():
assert prix_remise(19.99, 15) == 16.99
def test_remise_invalide():
with pytest.raises(ValueError):
prix_remise(50, 120)
Deux fichiers de configuration complètent le tout. Le premier fige la version de pytest, pour que le runner installe exactement la même que ta machine. Le second indique à pytest où chercher les tests et permet l'import from src.remise import ... sans bricolage.
# requirements-dev.txt
pytest==9.2.0
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
Avant d'écrire le moindre workflow, lance pytest en local. Les trois tests doivent passer. Un workflow ne répare rien : il répète sur une machine propre ce qui marche déjà chez toi. Si les tests sont fragiles, l'article sur les tests qui cassent au mauvais moment aide à les stabiliser, et celui sur les tests et le TDD pour débuter reprend les bases.
Le fichier de workflow, ligne par ligne
# .github/workflows/tests.yml
# Version cible : Python 3.15, pytest 9.2, actions/checkout v7, actions/setup-python v7
name: Tests
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
pytest:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Récupérer le code
uses: actions/checkout@v7
- name: Installer Python
uses: actions/setup-python@v7
with:
python-version: "3.15"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Installer les dépendances
run: pip install -r requirements-dev.txt
- name: Lancer les tests
run: pytest
name: Tests donne le nom affiché dans l'onglet Actions du dépôt.
Le bloc on fixe les déclencheurs. Le workflow tourne à chaque push sur main et à chaque pull request, quelle que soit la branche d'origine. Le filtre sur main évite de lancer les tests deux fois quand tu pousses sur une branche qui a déjà une pull request ouverte, puisque la pull request les déclenche de son côté.
permissions: contents: read limite les droits du jeton que GitHub fournit automatiquement à chaque job, le GITHUB_TOKEN. Des tests n'ont besoin que de lire le code, donc on ne leur donne que ça. C'est d'ailleurs la permission que recommande le README de setup-python.
pytest: sous jobs est l'identifiant du job. Retiens-le : c'est sous ce nom que le résultat apparaîtra sur les pull requests, et c'est ce nom que tu choisiras plus loin pour bloquer la fusion. runs-on: ubuntu-latest demande une machine Linux, et timeout-minutes: 10 coupe le job s'il reste bloqué, au lieu de laisser tourner le compteur de minutes jusqu'à la limite de six heures.
Les steps se lisent de haut en bas. actions/checkout@v7 copie le dépôt sur la machine, qui démarre vide. actions/setup-python@v7 installe Python 3.15, et son option cache: pip garde les paquets téléchargés d'un lancement à l'autre, avec une clé calculée à partir du fichier indiqué dans cache-dependency-path. Viennent ensuite deux commandes que tu tapes déjà en local : l'installation des dépendances, puis pytest.
Les guillemets autour de "3.15" évitent un piège de YAML, qui lit sinon un nombre décimal : une version 3.10 sans guillemets deviendrait 3.1.
Le premier lancement, puis un échec volontaire
Ajoute le fichier, fais un commit et pousse sur main. Dans l'onglet Actions du dépôt, une ligne apparaît avec un rond orange, puis une coche verte.
Un workflow qui n'a jamais été rouge n'a jamais prouvé qu'il savait l'être. Le test suivant prend deux minutes et vaut le coup : crée une branche, change == 80 en == 81 dans le premier test, pousse et ouvre une pull request. Le job passe au rouge, et le log affiche exactement ce que tu verrais en local :
F.. [100%]
=================================== FAILURES ===================================
______________________________ test_remise_simple ______________________________
def test_remise_simple():
> assert prix_remise(100, 20) == 81
E assert 80.0 == 81
E + where 80.0 = prix_remise(100, 20)
tests/test_remise.py:7: AssertionError
=========================== short test summary info ============================
FAILED tests/test_remise.py::test_remise_simple - assert 80.0 == 81
1 failed, 2 passed in 0.01s
Sur la pull request, le bloc des vérifications affiche le job en échec. Le bouton de fusion reste cliquable pour l'instant : le rouge informe, il n'empêche rien. C'est l'étape suivante qui change ça.
Bloquer la fusion quand les tests échouent
GitHub permet d'exiger qu'un check, c'est-à-dire le résultat d'un job, soit au vert avant toute fusion vers main. La configuration passe par les rulesets, dans Settings, puis Rules, puis Rulesets :
- crée un ruleset de branche qui cible la branche par défaut ;
- coche Require a pull request before merging, pour interdire le push direct sur
main; - coche Require status checks to pass et ajoute le check
pytest; - passe le ruleset en mode actif, puis enregistre.
Deux détails tirés de la documentation des rulesets font perdre du temps. D'abord, un check n'apparaît dans la liste que s'il a réussi au moins une fois dans le dépôt au cours des sept derniers jours : d'où l'intérêt de faire tourner le workflow avant de configurer la règle. Ensuite, l'option Require branches to be up to date before merging oblige la branche à intégrer les derniers commits de main avant la fusion. Elle relance plus souvent les tests, mais évite de fusionner un code testé sur un main périmé.
Les rulesets sont disponibles sur les dépôts publics avec le plan gratuit, et sur les dépôts privés à partir du plan Pro. Sur un dépôt privé en plan gratuit, le workflow fonctionne et affiche ses résultats, mais rien ne bloque la fusion. Pourquoi passer par une pull request plutôt que fusionner en local, et comment gérer le cas du développeur seul : l'article Protéger la branche main sur GitHub le détaille.
Vérifier le fichier avant de le pousser
Une faute de frappe dans un workflow se découvre en général après le push, sous forme d'un lancement qui échoue avant même la première étape. actionlint, un vérificateur de workflows open source, l'attrape avant. Installé via pip, il a détecté en version 1.7.12 une étiquette de runner mal orthographiée dans notre fichier de test :
pip install actionlint-py
actionlint
# Avec une faute de frappe sur la ligne runs-on :
.github/workflows/tests.yml:14:14: label "ubuntu-lastest" is unknown. [...]
Les pièges qui donnent une coche verte sans rien vérifier
Le pipe qui avale l'échec
Envoyer la sortie de pytest dans un fichier avec tee paraît anodin. Sauf que la référence de la syntaxe des workflows précise que, quand aucun shell n'est indiqué sur Linux, GitHub lance bash -e, sans l'option pipefail. Le code de sortie d'une ligne avec un pipe devient alors celui de la dernière commande, ici tee, qui réussit toujours. Testé avec un test volontairement faux : bash -e renvoie 0, bash -eo pipefail renvoie 1.
# Piège : sans « shell: bash », un échec de pytest est masqué par tee
- name: Lancer les tests
run: pytest | tee resultats.txt
# Correction : déclarer le shell pour activer pipefail
- name: Lancer les tests
shell: bash
run: pytest | tee resultats.txt
Écrire shell: bash fait lancer bash --noprofile --norc -eo pipefail, et l'échec remonte. On peut aussi le déclarer une fois pour tout le workflow avec defaults.run.shell.
Le fichier de test que pytest ne voit pas
Par défaut, pytest ne collecte que les fichiers nommés test_*.py ou *_test.py. Un fichier verif_tva.py rempli de tests est ignoré sans le moindre avertissement. Le code de sortie 5 ne protège que si aucun test n'est trouvé du tout : à côté de trois tests valides, un fichier mal nommé contenant un assert False laisse pytest afficher « 3 passed » et renvoyer 0. Le nombre de tests affiché dans le log est le chiffre à surveiller.
Le check requis qui ne vient jamais
Ajouter un filtre paths pour ne lancer les tests que quand le code Python change semble économe. Mais un workflow ignoré à cause d'un filtre de chemin ou de branche laisse ses checks « en attente », et une pull request qui exige ce check reste bloquée. Une modification du README seule suffit à coincer la fusion. La documentation GitHub sur les checks requis conseille de ne pas rendre obligatoires les workflows qui peuvent être ignorés. Même logique pour un workflow lancé à la main avec workflow_dispatch : ses checks ne comptent pas pour une règle de fusion.
Les permissions laissées par défaut
Sans clé permissions, le jeton reçoit les droits par défaut réglés au niveau du dépôt ou de l'organisation, qui peuvent inclure l'écriture. Dès qu'une permission est déclarée, toutes celles qui ne sont pas listées passent à none. Avec contents: read, une action tierce compromise ne peut rien modifier dans le dépôt avec ce jeton.
Les versions d'actions qui bougent toutes seules
Une référence comme @v7 est une étiquette que l'auteur de l'action peut déplacer vers un nouveau commit. GitHub l'a montré cet été : le 20 juillet 2026, il a reporté sur toutes les versions majeures encore maintenues de actions/checkout un changement de sécurité qui bloque certaines récupérations de code dans les workflows pull_request_target. D'après le changelog de GitHub, les workflows référencés par une étiquette majeure ont reçu le changement automatiquement, ceux épinglés sur un hash de commit non.
Les deux options ont un coût. L'étiquette reçoit les correctifs sans rien faire, mais elle exécute du code que tu n'as pas relu. Le hash de commit fige le code exécuté, mais il faut le mettre à jour soi-même, en général avec Dependabot. Depuis le 28 octobre 2026, GitHub propose aussi des fichiers de verrouillage pour les workflows : l'extension gh actions-lock génère un fichier .github/workflows/actions.lock qui garde des étiquettes lisibles dans le YAML tout en exécutant les commits verrouillés.
Le même mécanisme sert ensuite à bien d'autres contrôles : un linter, un calcul de couverture, une analyse de sécurité comme celle décrite dans l'article sur CodeQL et la preuve d'un développement sécurisé, puis le déploiement. Le cache, les matrices de versions, les secrets, les environnements de staging et le déploiement sur un VPS sont au programme de la formation CI/CD avec GitHub Actions. Si les branches et les pull requests ne sont pas encore familières, la formation Git et GitHub pour débutants passe avant.
Questions fréquentes
GitHub Actions est-il gratuit ?
Sur un dépôt public, les runners standards sont gratuits et sans limite. Sur un dépôt privé, le plan gratuit inclut chaque mois un crédit de 12 dollars, soit l'équivalent de 2 000 minutes sur un runner Linux à deux cœurs facturé 0,006 dollar la minute. Sans moyen de paiement enregistré, les lancements sont bloqués une fois le crédit épuisé, sans facturation surprise.
Mon workflow ne se lance pas, que vérifier en premier ?
Le fichier doit être dans .github/workflows/, à la racine du dépôt, avec l'extension .yml ou .yaml. Vérifie ensuite que l'événement correspond à ce que tu fais : avec branches: [main] sous push, un push sur une autre branche ne déclenche rien. Une erreur de syntaxe YAML apparaît, elle, dans l'onglet Actions sous forme de lancement en échec immédiat.
Quelle différence entre push et pull_request comme déclencheur ?
push se déclenche à chaque envoi de commits sur une branche. pull_request se déclenche à l'ouverture d'une pull request et à chaque nouveau commit poussé dessus, et son résultat s'affiche directement dans la pull request. Pour bloquer une fusion, c'est le second qui compte.
Peut-on tester sur plusieurs versions de Python à la fois ?
Oui, avec une stratégie de matrice : le job est dupliqué pour chaque valeur d'une liste, par exemple les versions 3.13, 3.14 et 3.15, et chaque copie tourne en parallèle. Sur un dépôt privé, chaque copie consomme ses propres minutes. Pour un premier workflow, une seule version, celle utilisée en production, suffit.
Un dépôt privé gratuit peut-il bloquer une fusion quand les tests échouent ?
Non. Les rulesets et les branches protégées sont disponibles sur les dépôts publics avec le plan gratuit, et sur les dépôts privés à partir du plan Pro. Sur un dépôt privé en plan gratuit, le workflow tourne et affiche son résultat sur la pull request, mais la fusion reste possible même en rouge.