Aller au contenu

Lab : le pipeline GitHub Actions de Signalements

Prérequis

Testé avec actionlint 1.7.12 check-jsonschema 0.38.2 python 3.12.3 pyyaml 6.0.3 zizmor 1.30.1 , vérifié le 1 octobre 2026

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épertoire ci/). 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 et check-jsonschema sans les installer) et gh.
  • Pour la leçon 9 : deux environnements recette et production dans 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.yaml

Les exigences

ExigenceLeçon
Les workflows passent actionlint, et zizmor ne relève aucun constat de gravité haute1, 11
Chaque workflow déclare ses permissions ; chaque étape run: s'exécute avec shell: bash1, 11
Chaque job a une durée maximale1
La CI ne vérifie qu'une fois, annule les runs périmés des demandes de fusion, et tourne chaque lundi2
Un job de détection des changements ignore les vérifications quand seule la documentation change3
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 branche4
Les dépendances pip sont en cache, les rapports de tests en artefacts à rétention courte5
L'image est construite sur un runner natif par architecture, publiée par empreinte, assemblée et attestée6
La préparation de Python est une action composite ; la construction d'image, un workflow réutilisable7
Les déploiements écrivent l'étiquette dans Git (GitOps), par environnement, sans annulation8, 9
Aucune expression github.event ou github.head_ref dans un script3, 11
Toutes les actions externes sont épinglées par empreinte, et Dependabot les tient à jour avec un délai de carence11
packages: write n'apparaît jamais au niveau d'un workflow6, 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.

Plan du cours