Conventional Commits en entrée; SemVer, CHANGELOG, tag et GitHub Release en sortie ; un seul geste humain ; aucun token à créer. Ce billet explique la release automatisée de ce blog, pièce par pièce. La doc de git-cliff explique très bien comment monter ce pipeline. Elle ne dit pas comment on le rend aveugle d'un clic, ni ce qui l'en protège durablement.
Une borne avant de commencer : on release une application déployée, avec un mainteneur unique. La publication de bibliothèques sur Packagist ou npm et les monorepos multi-artefacts obéissent à d'autres contraintes ; ce billet ne les traite pas.
Ce que la release lit vraiment
Tout repose sur une idée simple : l'information de version existe déjà dans l'historique git, à condition d'écrire ses messages de commit dans un format que la machine sait relire. Ce format, c'est Conventional Commits : feat(scope): …, fix(scope): …, refactor: …, avec un ! ou un footer BREAKING CHANGE pour les ruptures.
git-cliff, un outil écrit en Rust qui se télécharge en une archive de 7 Mo, parcourt les commits depuis le dernier tag et en déduit deux choses. La prochaine version d'abord, selon SemVer : un feat bumpe le mineur, un fix le patch, un breaking le majeur.
Le CHANGELOG ensuite, au format Keep a Changelog : feat devient une entrée « Added », fix une entrée « Fixed », refactor et perf du « Changed ». Les types sans valeur pour le lecteur du changelog (docs, chore, test, ci, build) sont filtrés.
Pour aller plus loin
Un budget de poids de page : la sobriété qui ne tient plus à la chance
Toute la config tient dans un cliff.toml versionné à la racine. L'essentiel :
commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^refactor", group = "Changed" },
{ message = "^chore", skip = true },
]
[bump]
features_always_bump_minor = true
breaking_always_bump_major = trueRetenez la ligne skip = true sur chore : c'est elle qui tient le rôle du méchant au milieu de ce billet.
Un détail dont je suis content : les références de PR (#1704) présentes dans les sujets de commit deviennent des liens Markdown via un postprocessor regex, appliqué après rendu. git-cliff sait interroger l'API GitHub pour faire ça proprement, mais le repo est privé : il faudrait un token, du réseau sortant, de la gestion d'erreur. Une regex fait le même travail hors ligne, avec le même résultat à chaque exécution.
En local, le même binaire tourne dans une image Docker jetable (make changelog régénère le fichier, make changelog-unreleased prévisualise), avec la même version épinglée que la CI : 2.6.1. Deux environnements, un seul comportement.
Pour aller plus loin
jakzal/phpqa : sortir la QA Symfony de son vendor/
Trois jobs et un clic
Côté CI, tout vit dans un unique workflow, release.yaml. Trois jobs.
prepare se déclenche à chaque push sur main, donc à chaque promotion de develop. Il calcule la prochaine version avec git-cliff --bumped-version et la compare au dernier tag. Rien de releasable ? Il s'arrête là, avec une ligne de log polie.
Sinon, il régénère CHANGELOG.md et ouvre une PR chore(release): vX.Y.Z via l'action peter-evans/create-pull-request. Cette PR ne touche qu'un fichier, le CHANGELOG. Aucune CI ne tourne dessus, je la merge en admin : c'est le seul geste humain du pipeline, et c'est voulu. Une release reste une décision.
Le job s'auto-neutralise pour tuer la boucle infinie, puisque le merge de la PR de release est lui-même un push sur main :
if: >-
github.event_name == 'push' &&
!contains(github.event.head_commit.message, 'chore(release): v') &&
!contains(github.event.head_commit.message, 'release/')publish se réveille au merge d'une PR dont la branche commence par release/. Il extrait la version du nom de la branche, pose le tag, crée la GitHub Release. Petite paranoïa au passage : le nom de branche transite par une variable d'environnement au lieu d'être interpolé dans le script shell, parce qu'une ref de PR reste une valeur que quelqu'un d'autre peut nommer (mitigation d'injection classique sur GitHub Actions).
Sur le même sujet
Une IA a trouvé les failles que ma QA ne voyait plus
Les notes de release ne sont pas recalculées à ce moment-là : trois règles awk les extraient de la section correspondante du CHANGELOG mergé. J'ai appris cette prudence à mes dépens : régénérer les notes « depuis le dernier tag » ré-attribuait parfois à la nouvelle section des commits déjà publiés, avec un risque d'under-bump à la clé (consigné dans l'issue #1369 du repo). La source de vérité, c'est le fichier que j'ai relu et mergé, pas un nouveau calcul.
back-merge, enfin, s'exécute après une publication réussie et ouvre une PR main → develop pour réinjecter le commit chore(release) dans la branche de travail. Sans lui, la promotion suivante se présenterait « out-of-date » devant la branch protection de main.
Tout est idempotent : la PR de release se met à jour si elle existe déjà, le tag est sauté s'il est déjà posé, le back-merge se tait si develop est à jour. On peut relancer n'importe quel job sans rien casser.
Trois cartons scellés
Le point faible du pipeline tient dans la méthode de merge des promotions develop → main. Une promotion, chez moi, s'étiquette chore: promote develop → main. Mergée en merge commit, les commits conventionnels qu'elle transporte entrent dans le graphe de main et git-cliff les lit. Mergée en squash, il n'en reste qu'un seul commit, portant l'étiquette chore, un type filtré.
Un squash, c'est un carton de déménagement : tout le contenu y entre, il n'en ressort qu'une étiquette. git-cliff ne déballe pas les cartons : il lit les étiquettes. Pour lui, un push squashé ne contient rien de releasable. next == latest, pas de PR de release, terminé. Le code, lui, est bien sur main ; seule la version a cessé de bouger.
Le plus vicieux, c'est le silence. Aucun job rouge, aucun warning : prepare note poliment qu'aucun commit releasable n'est arrivé. Un pipeline qui décide légitimement de ne rien faire ressemble trait pour trait à un pipeline qu'on vient de rendre aveugle.
Le squash a un deuxième coût, moins visible : il réécrit les SHAs. Les commits d'origine vivent toujours sur develop, leur copie écrasée vit sur main, et les deux branches divergent à chaque promotion, jusqu'au back-merge de résolution qui recolle les historiques.
Ce piège, je l'ai déclenché trois fois en moins de vingt-quatre heures, dont deux après avoir moi-même exigé le merge commit, en gras, dans le body de la PR. Toutes mes PRs vers develop se squashent, GitHub propose par défaut la dernière méthode de merge utilisée, et la main va au bouton vert dont elle a l'habitude : un réflexe moteur pèse plus lourd qu'une consigne, même rédigée par soi.
La réparation, elle, tient en une promotion de rattrapage en vrai merge commit : elle rouvre les cartons, les commits conventionnels d'origine entrent dans le graphe de main, prepare les voit, la PR de release s'ouvre dans la foulée. Et SemVer retombe sur ses pattes : tout était du fix et du refactor, le bump reste un patch.
La règle est sortie de la doc
La protection durable a pris la forme d'un ruleset GitHub nommé main: merge-commit only. Depuis, les méthodes de merge autorisées sur main se réduisent au merge commit ; squash et rebase sont grisés côté plateforme, quel que soit mon état de fatigue. Les PRs vers develop, elles, continuent de se squasher : la règle vise la seule branche où git-cliff lit.
L'avertissement existait pourtant en double, dans le body des PRs et dans la doc du projet. Une convention documentée n'engage que ceux qui la relisent au bon moment. Le ruleset ne demande à personne de se souvenir de quoi que ce soit : il retire le mauvais bouton.
Depuis, les releases s'enchaînent sans accroc : v3.8.0, v3.9.x, v3.10.0. Non que je sois devenu plus discipliné. On m'a juste confisqué l'occasion de ne pas l'être.
86 secondes
Le coût de fonctionnement, mesuré sur la v3.10.0 : quatre-vingt-six secondes entre le merge de la promotion et la release publiée, notes comprises, clic humain inclus.
Côté facture : le GITHUB_TOKEN éphémère que le runner possède déjà, une archive de 7 Mo téléchargée par run, et aucun appel réseau au-delà de GitHub lui-même. Pas de bot tiers à qui déléguer des droits sur le repo, pas de SaaS de release, et aucun LLM dans le chemin critique. Il existe bien un agent IA optionnel pour reformuler les entrées cryptiques du CHANGELOG avant un tag, mais il vit hors du flux : le pipeline nominal n'en dépend jamais.
Le mot de la fin
Ce pipeline ne fabrique aucune information. La version était dans les types de commit, le changelog dans leurs sujets, les notes de release dans le fichier déjà relu. Trois jobs YAML et une regex se contentent de remettre en forme une discipline d'écriture qui existait avant eux. C'est ça, une release sobre : moins d'outillage, parce que l'information était déjà là.
Et le jour où cette discipline a cassé, trois cartons scellés par la seule personne censée la défendre, ce qui l'a rattrapée était encore plus sobre qu'un outil : une case décochée dans les réglages de la plateforme.
Faites le tour de votre CI un matin calme. Combien de briques y compensent, à grands frais, une convention que personne ne protège à la source ? Et si le prochain ajout à votre pipeline était une soustraction ?
Une coquille, une erreur dans ce billet ? Signale-la-moi.