Lab : le pipeline GitHub Actions de Signalements
Ce lab rassemble le cours GitHub Actions. Vous construisez, dans votre propre dépôt GitHub, le pipeline de l'application Signalements tel qu'il est à la fin de la leçon 11, puis un script vérifie qu'il a les propriétés attendues. Il valide le niveau 200 des compétences « Pipelines CI/CD » et « Stratégies de déploiement », et contribue au niveau 300 de « Sécurité de la chaîne d'approvisionnement logicielle ».
Comptez une demi-journée. Toutes les pièces sont données dans les leçons ; essayez d'écrire chacune avant d'ouvrir la leçon correspondante.
Ce qu'il faut
- Un compte GitHub et un dépôt public de travail, qui contient l'application Signalements telle qu'à la fin du cours Construire des images de conteneurs (
app.py, tests,requirements*.txt,Dockerfile, répertoireci/). Sur un dépôt public, les minutes et les runners Arm sont gratuits, et les environnements avec relecteurs sont disponibles sur le plan gratuit. - Sur votre poste : Git, Docker, Python 3, actionlint,
uv(pour lancer zizmor etcheck-jsonschemasans les installer) etgh. - Pour la leçon 9 : deux environnements
recetteetproductiondans les réglages du dépôt.
L'arborescence à atteindre
.github/
├── actions/preparer-python/action.yml action composite (leçon 7)
├── dependabot.yml mises à jour avec délai de carence (leçon 11)
└── workflows/
├── ci.yml vérifications, matrice, agrégateur (leçons 1 à 5)
├── image-multiarch.yml workflow réutilisable d'image (leçons 6 et 7)
├── livraison.yml image, recette, production (leçon 9)
├── purge-recette.yml maintenance manuelle (leçon 2)
└── securite.yml audit zizmor (leçon 11)
ci/
├── changements.sh le code a-t-il changé ? (leçon 3)
├── resume_tests.py résumé Markdown des tests (leçon 3)
├── pousser.sh poussée avec recalage (leçon 9)
└── epingler.py épinglage par empreinte (leçon 11)
deploy/chart/values-recette.yaml, values-production.yamlLes exigences
| Exigence | Leçon |
|---|---|
| Les workflows passent actionlint, et zizmor ne relève aucun constat de gravité haute | 1, 11 |
Chaque workflow déclare ses permissions ; chaque étape run: s'exécute avec shell: bash | 1, 11 |
| Chaque job a une durée maximale | 1 |
| La CI ne vérifie qu'une fois, annule les runs périmés des demandes de fusion, et tourne chaque lundi | 2 |
| Un job de détection des changements ignore les vérifications quand seule la documentation change | 3 |
| Les tests d'intégration tournent contre PostgreSQL 17 et 18, en matrice, avec un contrôle de santé | 4 |
Un job agrégateur unique, en if: always(), porte le verdict exigé par la protection de branche | 4 |
| Les dépendances pip sont en cache, les rapports de tests en artefacts à rétention courte | 5 |
| L'image est construite sur un runner natif par architecture, publiée par empreinte, assemblée et attestée | 6 |
| La préparation de Python est une action composite ; la construction d'image, un workflow réutilisable | 7 |
| Les déploiements écrivent l'étiquette dans Git (GitOps), par environnement, sans annulation | 8, 9 |
Aucune expression github.event ou github.head_ref dans un script | 3, 11 |
| Toutes les actions externes sont épinglées par empreinte, et Dependabot les tient à jour avec un délai de carence | 11 |
packages: write n'apparaît jamais au niveau d'un workflow | 6, 11 |
Étape 1 : la CI
Solution
Le fichier ci.yml final est construit au fil des leçons 1 à 5 : déclencheurs et concurrence (leçon 2), job changements et résumé des tests (leçon 3), matrice d'intégration avec service PostgreSQL et agrégateur ci-terminee (leçon 4), cache de pip et artefacts (leçon 5). Exigez seulement le check CI terminée dans la protection de la branche main.
Étape 2 : l'image et la livraison
Solution
Le workflow réutilisable image-multiarch.yml (leçons 6 et 7) construit chaque architecture sur ubuntu-24.04 et ubuntu-24.04-arm, publie par empreinte sur GHCR, assemble l'index et l'atteste. livraison.yml (leçon 9) l'appelle, puis écrit l'étiquette dans deploy/chart/values-recette.yaml pour chaque fusion sur main, et dans values-production.yaml pour chaque étiquette v*, après approbation de l'environnement production. Pour l'écriture de l'étiquette, utilisez l'action de la leçon 8 (publiée dans votre propre compte) ou, à défaut, une étape qui fait la même chose avec un outil YAML.
Étape 3 : le durcissement
Solution
Épinglez toutes les actions avec ci/epingler.py (leçon 11), ajoutez .github/dependabot.yml et validez-le, ajoutez securite.yml, et corrigez ce que zizmor signale. Réglez les permissions par défaut du dépôt en lecture seule.
Vérifier
Le script suivant contrôle les exigences sur votre dépôt. Il ne déclenche rien sur GitHub : il lit vos fichiers, lance actionlint, zizmor et check-jsonschema. Les comportements à l'exécution (approbation de la production, images sur les deux architectures, commit de déploiement) se vérifient dans l'onglet Actions de votre dépôt.
"""Vérifie les propriétés attendues des workflows du lab GitHub Actions.
Usage : python verifier_lab.py <dépôt> (exige actionlint, uvx et PyYAML)
Chaque contrôle affiche OK ou ÉCHEC ; le code de sortie est le nombre d'échecs.
"""
import glob
import os
import re
import subprocess
import sys
import yaml
depot = sys.argv[1] if len(sys.argv) > 1 else "."
os.chdir(depot)
echecs = 0
def controle(titre, ok, detail=""):
global echecs
print(f"{'OK ' if ok else 'ÉCHEC '} {titre}{' : ' + detail if detail and not ok else ''}")
echecs += 0 if ok else 1
def charger(chemin):
# YAML 1.1 lit la clé « on » comme le booléen True (leçon 1).
donnees = yaml.safe_load(open(chemin, encoding="utf-8"))
if True in donnees:
donnees["on"] = donnees.pop(True)
return donnees
workflows = {os.path.basename(f): charger(f) for f in sorted(glob.glob(".github/workflows/*.yml"))}
actions = sorted(glob.glob(".github/actions/*/action.yml"))
textes = {f: open(f, encoding="utf-8").read() for f in glob.glob(".github/workflows/*.yml") + actions}
# 1. Les outils de validation
r = subprocess.run(["actionlint"], capture_output=True, text=True)
controle("actionlint ne signale rien", r.returncode == 0, r.stdout.strip().splitlines()[0] if r.stdout else "")
r = subprocess.run(["uvx", "zizmor", "--offline", "--min-severity", "high", ".github/"], capture_output=True, text=True)
bilan = next((l for l in (r.stdout + r.stderr).splitlines() if "findings" in l.lower()), "")
controle("zizmor : aucun constat de gravité haute", "No findings" in r.stdout + r.stderr or r.returncode == 0, bilan)
# 2. Épinglage : toute action externe par empreinte complète
non_epinglees = [f"{os.path.basename(f)}: {m}" for f, t in textes.items()
for m in re.findall(r"uses:\s*([\w.-]+/[\w./-]+@\S+)", t)
if not re.search(r"@[0-9a-f]{40}$", m)]
controle("toutes les actions externes sont épinglées par empreinte", not non_epinglees, ", ".join(non_epinglees))
# 3. Permissions, shell, durées
for nom, w in workflows.items():
controle(f"{nom} : permissions déclarées au niveau du workflow", "permissions" in w)
a_des_run = any("run" in e for j in w["jobs"].values() for e in j.get("steps", []))
shell = (w.get("defaults") or {}).get("run", {}).get("shell")
controle(f"{nom} : shell bash par défaut", not a_des_run or shell == "bash")
sans_delai = [j for j, d in w["jobs"].items() if "uses" not in d and "timeout-minutes" not in d]
controle(f"{nom} : chaque job a un timeout-minutes", not sans_delai, ", ".join(sans_delai))
# 4. CI : agrégateur, services en bonne santé
ci = workflows.get("ci.yml", {"jobs": {}})
agregateurs = [j for j, d in ci["jobs"].items() if str(d.get("if", "")).strip() == "always()"]
controle("ci.yml : un job agrégateur en if: always()", len(agregateurs) == 1)
if agregateurs:
attendus = set(ci["jobs"]) - set(agregateurs)
besoins = set(ci["jobs"][agregateurs[0]].get("needs", []))
controle("ci.yml : l'agrégateur dépend de tous les autres jobs", attendus == besoins, ", ".join(sorted(attendus - besoins)))
services = [s for d in ci["jobs"].values() for s in (d.get("services") or {}).values()]
controle("ci.yml : chaque service a un contrôle de santé", bool(services) and all("--health-cmd" in s.get("options", "") for s in services))
# 5. Pas d'expression non fiable dans un script
injections = [f"{os.path.basename(f)}" for f, t in textes.items()
for bloc in re.findall(r"run:\s*\|?\n?((?:.*\n)*?)(?=\n\s*-\s|\n\s*\w[\w-]*:|\Z)", t)
if re.search(r"\$\{\{\s*github\.(event\.|head_ref)", bloc)]
controle("aucune expression github.event ou github.head_ref dans un run:", not injections, ", ".join(sorted(set(injections))))
# 6. Livraison : environnements et concurrence des déploiements
liv = workflows.get("livraison.yml", {"jobs": {}})
envs = {d["environment"]["name"] if isinstance(d.get("environment"), dict) else d.get("environment"): d
for d in liv["jobs"].values() if d.get("environment")}
controle("livraison.yml : environnements recette et production", {"recette", "production"} <= set(envs))
controle("livraison.yml : déploiements sérialisés sans annulation",
all(d.get("concurrency", {}).get("cancel-in-progress") is False for d in envs.values()) and bool(envs))
ecrit_packages = [n for n, w in workflows.items() if (w.get("permissions") or {}) not in ({}, None)
and isinstance(w.get("permissions"), dict) and w["permissions"].get("packages") == "write"]
controle("packages: write jamais au niveau d'un workflow", not ecrit_packages, ", ".join(ecrit_packages))
# 7. Dependabot
r = subprocess.run(["uvx", "--quiet", "check-jsonschema", "--builtin-schema", "vendor.dependabot", ".github/dependabot.yml"],
capture_output=True, text=True)
controle(".github/dependabot.yml valide", r.returncode == 0)
if r.returncode == 0:
d = yaml.safe_load(open(".github/dependabot.yml"))
actions_dep = [u for u in d["updates"] if u["package-ecosystem"] == "github-actions"]
controle("Dependabot met à jour les actions, avec un délai de carence", bool(actions_dep) and "cooldown" in actions_dep[0])
print(f"\n{echecs} échec(s)")
sys.exit(echecs)$ uv run --with pyyaml python verifier_lab.py signalements
OK actionlint ne signale rien
OK zizmor : aucun constat de gravité haute
OK toutes les actions externes sont épinglées par empreinte
OK ci.yml : permissions déclarées au niveau du workflow
OK ci.yml : shell bash par défaut
OK ci.yml : chaque job a un timeout-minutes
OK image-multiarch.yml : permissions déclarées au niveau du workflow
OK image-multiarch.yml : shell bash par défaut
OK image-multiarch.yml : chaque job a un timeout-minutes
OK livraison.yml : permissions déclarées au niveau du workflow
OK livraison.yml : shell bash par défaut
OK livraison.yml : chaque job a un timeout-minutes
OK purge-recette.yml : permissions déclarées au niveau du workflow
OK purge-recette.yml : shell bash par défaut
OK purge-recette.yml : chaque job a un timeout-minutes
OK securite.yml : permissions déclarées au niveau du workflow
OK securite.yml : shell bash par défaut
OK securite.yml : chaque job a un timeout-minutes
OK ci.yml : un job agrégateur en if: always()
OK ci.yml : l'agrégateur dépend de tous les autres jobs
OK ci.yml : chaque service a un contrôle de santé
OK aucune expression github.event ou github.head_ref dans un run:
OK livraison.yml : environnements recette et production
OK livraison.yml : déploiements sérialisés sans annulation
OK packages: write jamais au niveau d'un workflow
OK .github/dependabot.yml valide
OK Dependabot met à jour les actions, avec un délai de carence
0 échec(s)
Sur une copie volontairement abîmée (une action désépinglée, un timeout-minutes retiré, une expression injectée dans un script, le contrôle de santé de PostgreSQL supprimé), le script relève les six défauts :
$ uv run --with pyyaml python verifier_lab.py lab-casse | grep -v "^OK"
ÉCHEC actionlint ne signale rien : .github/workflows/ci.yml:69:24: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
ÉCHEC zizmor : aucun constat de gravité haute : 16 findings (5 ignored, 9 suppressed, 1 unsafe fixes): 0 informational, 0 low, 0 medium, 2 high
ÉCHEC toutes les actions externes sont épinglées par empreinte : ci.yml: actions/checkout@v7
ÉCHEC ci.yml : chaque job a un timeout-minutes : verifier
ÉCHEC ci.yml : chaque service a un contrôle de santé
ÉCHEC aucune expression github.event ou github.head_ref dans un run: : ci.yml
6 échec(s)
Le script vérifie des propriétés, pas des fichiers identiques à ceux du cours : une autre organisation des jobs est acceptée tant qu'elle les respecte. Pour le niveau 300 de la compétence « Sécurité de la chaîne d'approvisionnement logicielle », ajoutez une revue : faites relire vos workflows par une autre personne, en lui demandant de trouver un chemin entre une demande de fusion externe et un secret.
Nettoyage
Supprimez les paquets publiés sur GHCR (Packages de votre compte) et les environnements du dépôt de travail. Si vous avez lancé localement un registre ou un serveur OpenBao pendant le cours, arrêtez et supprimez ces conteneurs nommément (docker rm -f <nom>), sans prune global si la machine sert à d'autres projets.