Écrire sa propre action
Pourquoi
Les leçons précédentes ont utilisé des actions écrites par d'autres et en ont assemblé une, composite, à partir d'étapes existantes. Une action composite suffit tant que le travail tient dans quelques commandes shell. Dès qu'il demande de la logique (lire un format structuré, appeler une API, décider selon le contenu d'un fichier), le shell devient fragile, et c'est le moment d'écrire une action dans un vrai langage.
Le cas qui sert de fil conducteur à cette leçon est réel. Les applications de Lyneko se déploient selon le modèle GitOps : le pipeline construit l'image, puis écrit son étiquette dans le fichier de valeurs du chart Helm, et Argo CD aligne le cluster sur ce fichier. Dans les sept workflows qui le font, l'écriture de l'étiquette est une commande sed, en deux variantes. Quatre écrivent :
sed -i "0,/^ tag: /s|^ tag: .*| tag: ${TAG}|" deploy/chart/values.yamlet trois omettent le 0,/^ tag: /, ce qui remplace toutes les lignes tag: du fichier. La première variante remplace la première ligne qui commence par deux espaces et tag: . Elle fonctionne tant que le fichier de valeurs a la forme attendue. Le jour où le chart gagne une sauvegarde PostgreSQL déclarée avant l'image de l'application :
$ cat values.yaml
# Valeurs du chart Signalements
sauvegarde:
repository: docker.io/library/postgres
tag: "18.6"
image:
repository: rg.fr-par.scw.cloud/lyneko-apps/signalements
tag: main-6ef8842 # écrit par la CI
$ cp values.yaml values.orig.yaml
$ TAG=main-a1b2c3d
$ sed -i "0,/^ tag: /s|^ tag: .*| tag: ${TAG}|" values.yaml
$ diff values.orig.yaml values.yaml
4c4
< tag: "18.6"
---
> tag: main-a1b2c3d
La commande a modifié l'étiquette de l'image PostgreSQL, sans erreur, et le déploiement suivant aurait tenté de lancer postgres:main-a1b2c3d. Un fichier YAML se modifie avec un outil qui comprend le YAML. C'est ce que fera l'action de cette leçon, ecrire-etiquette, utilisable par les sept applications.
Les concepts
Trois sortes d'actions
| JavaScript | Docker | Composite | |
|---|---|---|---|
runs.using | node24 | docker | composite |
| Langage | JavaScript ou TypeScript compilé | n'importe lequel | étapes de workflow |
| Démarrage | immédiat : le runner fournit Node | construction ou téléchargement de l'image à chaque job | immédiat |
| Systèmes | Linux, Windows, macOS | Linux seulement | ceux de ses étapes |
| Dépendances | empaquetées dans le dépôt | dans l'image | celles des actions appelées |
Une action JavaScript s'exécute avec le Node.js embarqué par le runner : elle démarre en une fraction de seconde, sur tous les systèmes. C'est le choix par défaut pour une action de logique. Une action Docker apporte son propre environnement (une image), au prix d'un démarrage plus lent et de Linux seulement : on la choisit quand l'action dépend d'un outil natif difficile à installer autrement. Une action composite assemble des étapes, comme à la leçon 7.
Le fichier de métadonnées
Toute action est décrite par un fichier action.yml à la racine de son dépôt (ou du sous-répertoire qui la contient) :
name,description,author: l'identité de l'action ;inputs: les entrées, avec description,requiredetdefault;outputs: les sorties publiées ;runs: comment l'exécuter. Pour une action JavaScript,using: node24etmain:(le fichier à exécuter), plus éventuellementpre:etpost:(exécutés avant toutes les étapes du job et après, comme le nettoyage decheckout) ;branding: icône et couleur, seulement pour une publication sur la Marketplace.
Node 24, et rien d'autre
Le seul environnement d'exécution JavaScript disponible est Node 24 depuis le 23 septembre 2026, date à laquelle GitHub a retiré Node 20 des runners. Les actions qui déclarent encore node20 sont exécutées avec Node 24, sans possibilité de revenir en arrière. Une action maintenue déclare donc using: node24 et teste avec cette version.
Pas de npm install à l'exécution
Le runner ne lance aucune installation : il télécharge le dépôt de l'action à la version demandée et exécute le fichier désigné par main. Toutes les dépendances doivent donc être dans le dépôt, sous l'une de deux formes : le répertoire node_modules versionné (lourd, illisible), ou, comme le fait le modèle officiel de GitHub, un paquet unique produit par un empaqueteur (bundler) et versionné dans dist/. Le second choix crée une obligation : dist/ doit toujours correspondre aux sources, ce que la CI de l'action doit vérifier.
La boîte à outils
GitHub publie des bibliothèques pour écrire des actions, la toolkit. La principale, @actions/core (version 3, publiée uniquement en module ES), fournit :
getInput(nom): lit une entrée. Le runner transmet chaque entrée dans une variable d'environnementINPUT_<NOM>(nom en majuscules, espaces remplacées par des tirets bas) ;setOutput(nom, valeur): écrit une sortie dans le fichierGITHUB_OUTPUT;summary: construit le résumé du job ;info,warning,error: écrivent dans le journal, les deux dernières avec une annotation ;setFailed(message): signale l'échec de l'action (code de sortie 1 et annotation d'erreur).
En pratique
Organisation du dépôt
ecrire-etiquette/
├── action.yml les métadonnées
├── package.json dépendances épinglées, scripts
├── package-lock.json
├── src/
│ ├── etiquette.js la logique, sans rien de GitHub
│ └── index.js le point d'entrée : entrées, sorties, résumé
├── test/
│ ├── etiquette.test.js
│ └── donnees/values.yaml
├── dist/index.js le paquet exécuté par le runner (versionné)
└── .github/workflows/ci.ymlLa séparation entre etiquette.js et index.js est le choix le plus important de la leçon. La logique ne sait rien de GitHub : elle prend un texte, un chemin de clé, une valeur, et renvoie un résultat. Elle se teste avec le lanceur de tests de Node, sans simuler quoi que ce soit. Le point d'entrée, lui, est réduit à la colle entre les entrées de l'action et la logique.
package.json
{
"name": "ecrire-etiquette",
"version": "1.0.0",
"description": "Écrit une valeur dans un fichier YAML (étiquette d'image d'un chart Helm), sans toucher au reste du fichier.",
"type": "module",
"private": true,
"engines": {
"node": ">=24"
},
"scripts": {
"test": "node --test",
"bundle": "esbuild src/index.js --bundle --platform=node --target=node24 --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\""
},
"dependencies": {
"@actions/core": "3.0.1",
"yaml": "2.9.1"
},
"devDependencies": {
"esbuild": "0.28.2"
}
}Les dépendances sont installées avec --save-exact : des versions exactes, sans ^. Ce qui part dans dist/ est ce qui a été relu et testé, pas « la dernière version compatible » du jour de l'empaquetage. L'option --banner:js de la commande d'empaquetage est expliquée dans Pièges courants.
La logique : src/etiquette.js
// Logique pure, sans dépendance à GitHub Actions : elle se teste seule.
import { parseDocument, isScalar } from "yaml";
/**
* Remplace la valeur scalaire située à `chemin` (« image.tag ») dans un texte YAML,
* en conservant les commentaires, l'ordre des clés et la mise en forme du reste.
* Renvoie le nouveau texte, l'ancienne valeur, et si le texte a changé.
*/
export function ecrireValeur(texte, chemin, valeur) {
const document = parseDocument(texte);
if (document.errors.length > 0) {
throw new Error(`YAML invalide : ${document.errors[0].message}`);
}
const cles = chemin.split(".");
const noeud = document.getIn(cles, true);
if (noeud === undefined) {
throw new Error(`clé introuvable : ${chemin}`);
}
if (!isScalar(noeud)) {
throw new Error(`la clé ${chemin} n'est pas une valeur simple`);
}
const ancienne = String(noeud.value);
if (ancienne === valeur) {
return { texte, ancienne, modifie: false };
}
noeud.value = valeur;
return { texte: document.toString(), ancienne, modifie: true };
}La bibliothèque yaml lit le fichier en un document, qui garde les commentaires et l'ordre des clés, contrairement à un simple parse qui ne donnerait qu'un objet JavaScript. getIn(cles, true) renvoie le nœud lui-même plutôt que sa valeur, pour pouvoir le modifier en place. Trois refus explicites : un YAML invalide, une clé absente, une clé qui désigne une structure plutôt qu'une valeur. Et un cas particulier : si la valeur est déjà la bonne, le texte est rendu tel quel, octet pour octet, pour qu'un second passage ne produise pas de commit vide.
Les tests : test/etiquette.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { ecrireValeur } from "../src/etiquette.js";
const VALEURS = `# Valeurs du chart Signalements
image:
repository: rg.fr-par.scw.cloud/lyneko-apps/signalements
tag: main-6ef8842 # écrit par la CI
pullPolicy: IfNotPresent
# Les sondes vérifient /sante
sante:
tag: ne-pas-toucher
`;
test("remplace l'étiquette et garde les commentaires", () => {
const r = ecrireValeur(VALEURS, "image.tag", "main-a1b2c3d");
assert.equal(r.modifie, true);
assert.equal(r.ancienne, "main-6ef8842");
// Le commentaire est conservé ; son alignement est ramené à une espace.
assert.match(r.texte, /tag: main-a1b2c3d # écrit par la CI/);
assert.match(r.texte, /^# Les sondes vérifient \/sante$/m);
assert.match(r.texte, /tag: ne-pas-toucher/);
});
test("ne change rien si la valeur est déjà la bonne", () => {
const r = ecrireValeur(VALEURS, "image.tag", "main-6ef8842");
assert.equal(r.modifie, false);
assert.equal(r.texte, VALEURS);
});
test("refuse une clé absente", () => {
assert.throws(() => ecrireValeur(VALEURS, "image.digest", "x"), /clé introuvable : image.digest/);
});
test("refuse une clé qui n'est pas une valeur simple", () => {
assert.throws(() => ecrireValeur(VALEURS, "image", "x"), /n'est pas une valeur simple/);
});Le fichier de test contient deux clés tag, dont une qui ne doit pas bouger : exactement le cas qui a trompé sed. Le premier essai de ce test exigeait que l'alignement du commentaire (trois espaces) soit conservé, et il a échoué : la bibliothèque garde le commentaire mais le ramène à une seule espace. Ce n'est pas un défaut gênant pour un fichier de valeurs, mais c'est un comportement à connaître, et le test le documente maintenant.
$ npm test
✔ remplace l'étiquette et garde les commentaires (13.512417ms)
✔ ne change rien si la valeur est déjà la bonne (1.324752ms)
✔ refuse une clé absente (1.7135ms)
✔ refuse une clé qui n'est pas une valeur simple (0.955009ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 169.357428
Le point d'entrée : src/index.js
// Point d'entrée de l'action : lit les entrées, appelle la logique, publie les sorties.
import { readFile, writeFile } from "node:fs/promises";
import * as core from "@actions/core";
import { ecrireValeur } from "./etiquette.js";
const MOTIF_ETIQUETTE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
async function executer() {
const fichier = core.getInput("fichier", { required: true });
const cle = core.getInput("cle") || "image.tag";
const valeur = core.getInput("valeur", { required: true });
// Une étiquette d'image a une syntaxe stricte : on refuse tout le reste.
if (!MOTIF_ETIQUETTE.test(valeur)) {
throw new Error(`étiquette d'image invalide : ${JSON.stringify(valeur)}`);
}
const texte = await readFile(fichier, "utf8");
const resultat = ecrireValeur(texte, cle, valeur);
if (resultat.modifie) {
await writeFile(fichier, resultat.texte);
core.info(`${fichier} : ${cle} passe de ${resultat.ancienne} à ${valeur}`);
} else {
core.info(`${fichier} : ${cle} vaut déjà ${valeur}, rien à écrire`);
}
core.setOutput("ancienne-valeur", resultat.ancienne);
core.setOutput("modifie", String(resultat.modifie));
await core.summary
.addHeading("Étiquette de déploiement", 3)
.addTable([
[{ data: "Fichier", header: true }, { data: "Clé", header: true }, { data: "Avant", header: true }, { data: "Après", header: true }],
[fichier, cle, resultat.ancienne, valeur],
])
.write();
}
executer().catch((erreur) => core.setFailed(erreur.message));- La validation de l'étiquette suit la grammaire des étiquettes de la spécification OCI : 128 caractères au plus, lettres, chiffres,
_,.et-, sans commencer par.ou-. Elle a deux rôles : éviter d'écrire dans le fichier une valeur qui ferait échouer le déploiement plus tard, et refuser toute valeur exotique qui pourrait servir à autre chose (voir Sécurité). required: truefait échouergetInputavec un message clair si l'entrée manque.- Les sorties sont des chaînes :
String(resultat.modifie)écrittrueoufalse, que l'appelant comparera à'true'(leçon 3). - Toute erreur aboutit à
setFailed, qui pose le code de sortie 1 et une annotation visible sur la page du run.
action.yml
name: Écrire l'étiquette de déploiement
description: >-
Écrit l'étiquette d'image à déployer dans un fichier de valeurs Helm (ou tout
fichier YAML), sans modifier le reste du fichier. Pensée pour le modèle GitOps
de Lyneko : la CI écrit l'étiquette, Argo CD déploie.
author: Lyneko
inputs:
fichier:
description: "Chemin du fichier YAML, relatif à l'espace de travail"
required: true
cle:
description: "Chemin de la clé, séparé par des points"
required: false
default: image.tag
valeur:
description: "Nouvelle étiquette d'image"
required: true
outputs:
ancienne-valeur:
description: "Valeur avant modification"
modifie:
description: "« true » si le fichier a été modifié"
runs:
using: node24
main: dist/index.jsEmpaqueter
$ npm run bundle
dist/index.js 1.0mb ⚠️
⚡ Done in 37ms
esbuild rassemble le point d'entrée, la logique et toutes les dépendances en un seul fichier d'environ un mégaoctet (1 076 194 octets), qui sera versionné. L'avertissement signale seulement la taille ; elle vient surtout de @actions/core et de son client HTTP.
Exécuter l'action comme le runner
Le runner n'a rien de magique : il pose des variables d'environnement et lance node dist/index.js. On peut faire exactement la même chose sur un poste, avec les entrées dans des variables INPUT_* et des fichiers pour GITHUB_OUTPUT et GITHUB_STEP_SUMMARY :
$ INPUT_FICHIER=essai/values.yaml INPUT_VALEUR=main-a1b2c3d \
GITHUB_OUTPUT=essai/sortie GITHUB_STEP_SUMMARY=essai/resume.md node dist/index.js
essai/values.yaml : image.tag passe de main-6ef8842 à main-a1b2c3d
$ echo $?
0
$ diff essai/values.orig.yaml essai/values.yaml
4c4
< tag: main-6ef8842 # écrit par la CI
---
> tag: main-a1b2c3d # écrit par la CI
$ cat essai/sortie
ancienne-valeur<<ghadelimiter_af832250-41ab-4b09-871c-744916879e5f
main-6ef8842
ghadelimiter_af832250-41ab-4b09-871c-744916879e5f
modifie<<ghadelimiter_2aff7b6c-cc7e-40f9-aadf-daf68279dc9c
true
ghadelimiter_2aff7b6c-cc7e-40f9-aadf-daf68279dc9c
$ cat essai/resume.md
<h3>Étiquette de déploiement</h3>
<table><tr><th>Fichier</th><th>Clé</th><th>Avant</th><th>Après</th></tr><tr><td>essai/values.yaml</td><td>image.tag</td><td>main-6ef8842</td><td>main-a1b2c3d</td></tr></table>
Trois constats. Le diff ne touche qu'une ligne : la clé sante.tag et les commentaires sont intacts. @actions/core écrit chaque sortie avec la syntaxe à délimiteur de la leçon 3, et un délimiteur aléatoire (ghadelimiter_ suivi d'un UUID) : la bibliothèque applique d'elle-même la protection qu'on y avait construite à la main. Et le résumé est écrit en HTML, que GitHub affiche dans la page du run.
Les cas d'échec et le second passage :
$ INPUT_FICHIER=essai/values.yaml INPUT_VALEUR=main-a1b2c3d ... node dist/index.js # deuxième passage
essai/values.yaml : image.tag vaut déjà main-a1b2c3d, rien à écrire
$ INPUT_FICHIER=essai/values.yaml INPUT_VALEUR='main-x$(id)' ... node dist/index.js
::error::étiquette d'image invalide : "main-x$(id)"
$ echo $?
1
$ INPUT_FICHIER=essai/values.yaml INPUT_CLE=image.digest INPUT_VALEUR=main-a1b2c3d ... node dist/index.js
::error::clé introuvable : image.digest
$ INPUT_VALEUR=main-a1b2c3d node dist/index.js # fichier oublié
::error::Input required and not supplied: fichier
(Les ... remplacent les variables GITHUB_OUTPUT et GITHUB_STEP_SUMMARY, inchangées.) ::error:: est la commande de workflow qu'émet setFailed : sur GitHub, elle devient une annotation rouge en tête du run. Le modèle officiel de GitHub propose aussi un outil, @github/local-action, qui automatise cette simulation à partir d'un fichier .env.
La CI de l'action
Le dépôt de l'action a son propre workflow, avec deux jobs : les tests et la vérification de dist/, puis un essai réel de l'action sur un fichier de test.
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
tester:
name: Tests et dist à jour
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm test
- name: Vérifier que dist/ correspond aux sources
run: |
npm run bundle
if ! git diff --exit-code --stat dist/; then
echo "::error::dist/ n'est pas à jour : lancez « npm run bundle » et validez le résultat."
exit 1
fi
essayer:
name: Essai de l'action
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- id: etiquette
uses: ./
with:
fichier: test/donnees/values.yaml
valeur: main-${{ github.sha }}
- name: Vérifier le résultat
env:
MODIFIE: ${{ steps.etiquette.outputs.modifie }}
ANCIENNE: ${{ steps.etiquette.outputs.ancienne-valeur }}
run: |
test "$MODIFIE" = true
test "$ANCIENNE" = main-6ef8842
grep -q "tag: main-$GITHUB_SHA # écrit par la CI" test/donnees/values.yaml
grep -q 'tag: ne-pas-toucher' test/donnees/values.yamlnode-version: "24": on teste avec la version qui exécutera l'action, pas avec celle du poste du développeur (Node 26 ici).npm ciinstalle exactement ce que ditpackage-lock.json, et échoue si le fichier ne correspond pas àpackage.json.La vérification de
dist/reconstruit le paquet et exige qu'il soit identique à celui qui est versionné. Sans elle, rien ne garantit que le code exécuté par les utilisateurs est celui qui a été relu danssrc/. Localement, après avoir modifiésrc/index.jssans reconstruire :$ npm run bundle && git diff --exit-code --stat dist/ dist/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) $ echo $? 1Le job
essayerutilise l'action paruses: ./, c'est-à-dire ledist/versionné, sur un vrai fichier, et vérifie ses sorties et son effet. Les mêmes vérifications, exécutées localement après un passage de l'action avec une empreinte de commit d'exemple (ce test d'intégration utilise l'empreinte complète, ce qui suffit pour éprouver l'action) :$ cp test/donnees/values.yaml essai/v.yaml $ export GITHUB_SHA=0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b $ INPUT_FICHIER=essai/v.yaml INPUT_VALEUR=main-$GITHUB_SHA GITHUB_OUTPUT=essai/s \ GITHUB_STEP_SUMMARY=/dev/null node dist/index.js essai/v.yaml : image.tag passe de main-6ef8842 à main-0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b $ MODIFIE=$(sed -n '/^modifie<</{n;p}' essai/s) ANCIENNE=$(sed -n '/^ancienne-valeur<</{n;p}' essai/s) \ bash --noprofile --norc -eo pipefail -c ' test "$MODIFIE" = true test "$ANCIENNE" = main-6ef8842 grep -q "tag: main-$GITHUB_SHA # écrit par la CI" essai/v.yaml grep -q "tag: ne-pas-toucher" essai/v.yaml echo "vérifications : OK"' vérifications : OK
Utiliser l'action
Depuis le workflow de déploiement d'une application, l'appel remplace la commande sed :
# Même règle que l'étiquette produite par metadata-action : main-<7 caractères>.
- name: Calculer l'étiquette
id: version
run: echo "etiquette=main-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Écrire l'étiquette pour Argo CD
id: etiquette
uses: lyneko-team/ecrire-etiquette@c865d9964a40297c03b391d1a9ff0f3b2499c735 # v1.0.0
with:
fichier: deploy/chart/values.yaml
valeur: ${{ steps.version.outputs.etiquette }}
- name: Valider le déploiement
if: steps.etiquette.outputs.modifie == 'true'
run: |
git add deploy/chart/values.yaml
git commit -m "Deploy ${GITHUB_SHA::7}"
git pushLa leçon suivante construit le workflow de déploiement complet de Signalements autour de cet appel.
Sous le capot
Pour une étape uses: propriétaire/dépôt@ref, le runner télécharge pendant Set up job l'archive du dépôt de l'action à cette référence, la décompresse dans un répertoire de l'agent (_actions/propriétaire/dépôt/ref/), et lit action.yml. Au moment de l'étape, il prépare l'environnement : une variable INPUT_<NOM> par entrée (avec la valeur par défaut si l'appelant n'a rien donné), les variables GITHUB_* et RUNNER_*, les chemins des fichiers de commandes. Puis il lance son propre exécutable Node 24, livré avec l'agent, sur le fichier main. Le code de sortie décide du succès de l'étape. Les étapes pre et post suivent le même chemin, au début et à la fin du job.
La lecture des entrées par @actions/core tient en une ligne de son code source :
const val = process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] || '';Seules les espaces sont remplacées : l'entrée python-version de l'action composite de la leçon 7 arriverait dans INPUT_PYTHON-VERSION, un nom de variable que Bash ne sait pas écrire directement (d'où des noms d'entrées sans tiret dans cette action ; les sorties, elles, peuvent en avoir). Les entrées sont toujours des chaînes ; getBooleanInput les convertit en booléen selon la spécification YAML (true, True, TRUE ou leurs équivalents false), et échoue sur toute autre valeur.
Pièges courants
Error: Dynamic require of "net" is not supported. Le paquet est produit au format module ES, mais une dépendance (ici, dans le client HTTP de @actions/core) utilise encore require() de CommonJS. Sans la bannière qui recrée une fonction require à partir de import.meta.url, le paquet échoue au premier appel :
$ npx esbuild src/index.js --bundle --platform=node --target=node24 --format=esm --outfile=essai/sans-banniere.js
$ node essai/sans-banniere.js
file:///.../essai/sans-banniere.js:11
throw Error('Dynamic require of "' + x + '" is not supported');
^
Error: Dynamic require of "net" is not supported
C'est le rôle de l'option --banner:js du script bundle. Le modèle officiel de GitHub contourne le même problème avec les extensions CommonJS de rollup.
L'action exécute une ancienne version du code. dist/ n'a pas été reconstruit après une modification de src/. La vérification en CI l'empêche d'atteindre main.
Cannot find module '@actions/core'. L'action exécute directement src/index.js (main: src/index.js), sans empaquetage : le runner n'installe pas les dépendances.
Une entrée booléenne toujours vraie. if (core.getInput("simulation")) est vrai pour la chaîne "false". Utilisez core.getBooleanInput.
Le fichier modifié n'est pas celui attendu. Les chemins sont relatifs au répertoire de travail du processus, qui est l'espace de travail (GITHUB_WORKSPACE), pas le répertoire de l'action. Pour lire un fichier livré avec l'action, utilisez GITHUB_ACTION_PATH.
Une action développée et testée avec Node 26 échoue sur le runner. Le runner exécute Node 24 : une fonction apparue après cette version n'existe pas. --target=node24 fait signaler par esbuild la syntaxe non prise en charge, mais pas les API manquantes ; seuls des tests sous Node 24 le garantissent.
Sécurité
Publier une action, c'est faire exécuter son code, avec leurs droits, dans les pipelines des autres. Plusieurs des compromissions citées dans ce cours sont des compromissions d'actions : tj-actions/changed-files et reviewdog/action-setup en 2025, aquasecurity/trivy-action et actions-cool/issues-helper en 2026. Les responsabilités du mainteneur :
Valider les entrées. Une action reçoit des valeurs que l'appelant tire souvent d'événements non fiables (titre de demande de fusion, nom de branche). Ici, l'étiquette est validée par une expression stricte avant toute utilisation, et la valeur main-x$(id) est refusée. L'action n'exécute aucune commande shell ; si elle devait le faire, elle passerait les arguments sous forme de tableau (@actions/exec, execFile), jamais dans une chaîne interprétée par un shell.
Maîtriser ses dépendances. Tout ce qui est dans dist/ s'exécute chez les utilisateurs. Des versions exactes, un fichier de verrouillage, npm ci, une revue de chaque montée de version : l'action n'a que deux dépendances directes, et c'est voulu. Les scripts d'installation des paquets sont un vecteur d'attaque connu (le ver Shai-Hulud, en 2025, se propageait par un script postinstall) ; dans l'environnement de cette leçon, npm 11.19 a d'ailleurs refusé d'exécuter celui d'esbuild tant qu'il n'était pas approuvé (npm install-scripts ls le liste), sans conséquence ici puisqu'esbuild fonctionne sans.
Rendre dist/ vérifiable. Un paquet d'un mégaoctet ne se relit pas. La vérification en CI garantit au moins qu'il est le produit des sources relues, avec les dépendances verrouillées.
Publier des versions immuables. Depuis octobre 2025, GitHub permet de publier des versions immuables : l'étiquette et les fichiers d'une version publiée ne peuvent plus être modifiés ni supprimés. Activez-les pour le dépôt de l'action. Beaucoup d'actions maintiennent aussi une étiquette majeure mouvante (v1) qu'elles déplacent à chaque version : c'est pratique pour les utilisateurs, et c'est précisément ce qui a permis les attaques par réécriture d'étiquette. Documentez l'appel par empreinte comme la forme recommandée.
Demander le minimum. Si l'action a besoin d'un jeton, elle le prend en entrée (avec ${{ github.token }} comme valeur par défaut) et documente les permissions nécessaires ; elle n'en demande pas plus. ecrire-etiquette n'a besoin d'aucun jeton : elle modifie un fichier, et laisse au workflow le soin de le valider et de le pousser.
En production
Un dépôt par action, ou un dépôt d'actions. Une action publiée sur la Marketplace doit avoir son action.yml à la racine d'un dépôt public. Pour des actions internes, un dépôt lyneko-team/actions qui en contient plusieurs (lyneko-team/actions/ecrire-etiquette@<empreinte>) simplifie la maintenance, à condition de régler l'accès aux autres dépôts de l'organisation dans ses réglages Actions s'il est privé.
TypeScript. Le modèle officiel actions/typescript-action ajoute le typage, utile dès que l'action manipule des objets d'API. Le principe est le même : sources dans src/, paquet dans dist/, vérification en CI.
Le cycle de vie du moteur. Le passage forcé de Node 20 à Node 24 en septembre 2026 a obligé les mainteneurs à vérifier leurs actions sous Node 24 et à publier des versions qui déclarent node24. Il y en aura d'autres : une action maintenue suit le calendrier de GitHub, teste sur la version annoncée avant la bascule, et publie à temps.
Mesurer l'adoption. Pour une action interne utilisée par plusieurs équipes, l'API de recherche de code de GitHub (ou un simple grep sur les dépôts) indique qui l'appelle et à quelle version, avant de publier un changement incompatible.
Exercices
1. Pour chacun de ces besoins, quelle sorte d'action choisiriez-vous ? (a) Lancer helm lint et helm template sur un chart ; (b) commenter une demande de fusion avec le résumé d'un plan Terraform, via l'API de GitHub ; (c) exécuter un outil d'analyse distribué uniquement comme binaire Linux, avec une dizaine de bibliothèques natives.
Solution
(a) Composite : deux commandes, Helm étant disponible sur l'image du runner ou installé par une action existante. (b) JavaScript : appels d'API, mise en forme, gestion d'erreurs ; @actions/github fournit un client authentifié. (c) Docker : l'image apporte le binaire et ses bibliothèques, au prix d'un démarrage plus lent et de Linux seulement.
2. Ajoutez à ecrire-etiquette une entrée simulation (booléenne, fausse par défaut) qui calcule et affiche le changement sans écrire le fichier. Écrivez le test correspondant au niveau qui convient.
Solution
Dans action.yml :
simulation:
description: "Calculer le changement sans écrire le fichier"
required: false
default: "false"Dans src/index.js, lire const simulation = core.getBooleanInput("simulation"); et n'appeler writeFile que si resultat.modifie && !simulation. La logique pure (ecrireValeur) ne change pas : elle ne fait déjà qu'un calcul, et ses tests restent valables. Le comportement de simulation se teste en exécutant le paquet comme le runner, avec INPUT_SIMULATION=true, et en vérifiant que le fichier est inchangé et que la sortie modifie vaut true.
3. Un collègue propose de supprimer dist/ du dépôt et de mettre main: src/index.js avec une étape pre qui lance npm ci. Quels problèmes cela pose-t-il ?
Solution
Chaque utilisation de l'action téléchargerait ses dépendances depuis le registre npm, au moment de l'exécution : plus lent, dépendant de la disponibilité du registre, et surtout le code exécuté chez l'utilisateur ne serait plus figé par l'empreinte de l'action (une dépendance transitive compromise serait installée sans que rien ait changé dans le dépôt). Les scripts d'installation des dépendances s'exécuteraient aussi sur le runner, avec le jeton du job à portée. Le paquet versionné dans dist/ fige au contraire tout le code exécuté.
4. Pourquoi l'action valide-t-elle l'étiquette alors que la bibliothèque YAML échappe correctement toute valeur écrite dans le fichier ?
Solution
L'échappement protège le fichier YAML, pas ce qui le lit ensuite. Une étiquette invalide (une espace, un $, plus de 128 caractères) serait écrite proprement, puis ferait échouer le déploiement chez Argo CD et Kubernetes, loin du pipeline qui l'a produite. Et la valeur de l'étiquette finit dans d'autres contextes (messages de commit, journaux, scripts de l'appelant) où une valeur exotique peut avoir des effets. Valider au plus tôt, contre la grammaire réelle de la donnée, est moins coûteux que d'échapper partout.
5. L'action a 200 utilisateurs internes, qui l'appellent par l'étiquette v1. Vous devez publier une version qui renomme l'entrée cle en chemin. Décrivez la procédure.
Solution
Publier d'abord une version 1.x qui accepte les deux noms (chemin prioritaire, cle encore lu avec un avertissement core.warning qui annonce sa disparition). Laisser le temps aux équipes de migrer, en mesurant les appels à cle (recherche de code). Puis publier une version 2.0.0, immuable, qui ne lit plus que chemin, avec une note de migration. Ne pas déplacer l'étiquette v1 vers la version 2. Et profiter de l'occasion pour faire passer les appelants à un appel épinglé par empreinte, mis à jour par Dependabot.
Récapitulatif
- Action JavaScript pour la logique (rapide, tous systèmes), Docker pour un environnement natif particulier (Linux, plus lente), composite pour assembler des étapes.
action.ymldécrit entrées, sorties etruns; une action JavaScript déclareusing: node24, seule version disponible depuis le 23 septembre 2026.- Le runner n'installe rien : les dépendances sont empaquetées dans
dist/, versionné, et la CI vérifie qu'il correspond aux sources. - Séparez la logique pure, testée sans GitHub, du point d'entrée qui lit les entrées et publie les sorties.
- Une action se teste localement comme le runner l'exécute : variables
INPUT_*, fichiersGITHUB_OUTPUTetGITHUB_STEP_SUMMARY,node dist/index.js. - Validez les entrées, épinglez les dépendances, publiez des versions immuables, recommandez l'appel par empreinte.
Pour aller plus loin
- actions/javascript-action et actions/typescript-action : les modèles officiels, avec leur workflow
check-dist. - actions/toolkit : toutes les bibliothèques (
core,github,exec,cache,artifact). - Leçon suivante : Environnements, secrets et déploiement.
Sources
- GitHub Docs, Metadata syntax for GitHub Actions
- GitHub Docs, Creating a JavaScript action
- actions/toolkit, @actions/core (getInput, setOutput, summary, setFailed)
- actions/javascript-action, modèle officiel d'action JavaScript (check-dist, local-action)
- GitHub Changelog, Node 20 is no longer available in GitHub Actions (23 septembre 2026)
- GitHub Changelog, Immutable releases are now generally available (28 octobre 2025)
- eemeli/yaml, documentation (Document, getIn, conservation des commentaires)
- esbuild, options --platform, --format et --banner
- Node.js, node:test (lanceur de tests intégré)
- OCI Distribution Specification, grammaire des étiquettes