28 mars 2023
Dans ce post on présente le Semantic Versioning et on explique comment, dans une démarche devops, on peut automatiser la génération des numéros de version de nos releases en utilisant des formats conventionnels de commit. L'exemple présenté s'appuie sur le processus CI/CD de GitLab mais l'essentiel du propos est applicable aux autres outils d'intégration continue.
Quand et comment attribuer un numéro de version à son projet ?
On affecte un numéro de version à son programme à chaque fois qu'on en publie un nouvel état partagé. Par publication, on entend la mise à disposition de ce qui représente le logiciel (ca peut être son code ou une distribution exécutable, ou les deux) afin qu'il puisse être testé, utilisé ou mis en production quelque part. Techniquement on appelle cela une release.
Cela signifie qu'on n'affecte pas forcément un nouveau numéro de version à chaque fois qu'on réalise une modification du code d'un programme. En général l'affectation survient quand on est prêt à rendre publique (même si c'est pour une visibilité purement interne) une nouvelle évolution du logiciel.
Quand on utilise un système de gestion des versions comme git, le numéro de version prend la forme d'un tag qui permet d'étiqueter un commit. Par exemple, si vous estimez que le dernier commit de votre projet correspond à un état du code qui mérite d'être publié, vous pouvez lui affecter un numéro de version avec la commande suivante :
`git tag -a 1.0.2 -m "Version 1.0.2"
L'option -a permet de préciser le tag en lui-même (ici 1.0.2), tandis que l'option -m permet éventuellement de lui associer une description plus complète.
Attention, ce numéro de version n'est connu que de git et votre code n'a aucun moyen d'y accéder. Là où ça devient intéressant, c'est que les systèmes d'intégration et de déploiement continus (CI/CD) sont capables de lire cette information pour exécuter des scripts automatisés qui vont construire et publier votre code, en affichant aux yeux de tous, le numéro de version auquel est associée la construction.
Par exemple, avec l'outil CI de gitlab, on peut faire en sorte que tout commit possédant un tag, crée automatiquement une nouvelle release d'un code, qui s'affiche comme ci-dessous :

On le détaille plus bas mais si la publication de code implique la construction de l'image d'un conteneur docker, on peut utiliser ce tag (donc le numéro de version) pour qu'il devienne le tag de l'image créée,
par exemple maboite/monlogicel:1.0.2. Cela permet la désignation précise de la version du logiciel à déployer avec un docker run ou un dans un cluster kubernetes.
Une gestion simple des numéros de version avec git consiste à considérer la branche main comme étant la branche portant toutes les versions publiables d'un projet. Ce sont donc uniquement les commit
ou préférablement les merge opérés sur cette branche qui seront tagués. Quand le code doit évoluer on suit donc le processus suivant :
- Les modifications du code (nouveaux développements, corrections de bugs, etc.) ont lieu dans des branches distinctes de la branche
main. - Quand le développeur estime qu'un travail est terminé, la branche de travail est fusionnée (merge) avec la branche
main, ou une demande est faite pour qu'elle le soit (on appelle cela unemerge request) - Après la fusion, un tag indiquant le nouveau numéro de version est affecté au nouvel état.
De quoi est fait un numéro de version ?
Il n'y a pas de norme universelle imposant la structure d'un numéro de version. Une bonne pratique dans la création logicielle consiste à utiliser un numéro au format SemVer qui est le diminutif de Semantic Versioning. Le terme Semantic vient souligner le fait qu'un numéro de version doit être signifiant, et donc apporter des informations sur ce qu'il représente. En l'occurrence, un numéro au format SemVer a pour format trois entiers séparés par des points désignant
MAJOR.MINOR.PATCH
avec
- MAJOR : Le numéro MAJOR désigne une évolution de version dont la rétrocompatibilité avec les précédentes n'est pas assurée. Il s'agit donc d'une évolution majeure d'un logiciel pouvant entrainer le disfonctionnement des systèmes utilisant les versions précédentes. Par exemple une bibliothèque ou une API en version 2.0.4 n'est pas forcément compatible avec la même en version 1.5.2.
- MINOR: Le numéro MINOR indique que des fonctionnalités ont été ajoutées, mais que le code reste compatible avec les anciennes versions.
- PATCH: Le numéro PATCH indique des corrections de bugs ou de styles qui n'ont pas d'effet sur les fonctionnalités.
On peut ajouter à ce numéro de base des caractères supplémentaires pour distinguer les types de release, par exemple :
1.0.2-rc: pour désigner le fait qu'il s'agit d'une release candidate1.0.2-beta02: pour désigner le fait qu'il s'agit de la deuxième distribution beta
On peut trouver la spécification complète d'un numéro de version au format SemVer ici : https://semver.org
Générer automatiquement un numéro de version au format SemVer
On a dit plus haut que les systèmes d'intégration et de déploiement continu étaient capables d'utiliser les tag de version associés aux commit pour étiqueter leurs différents artefacts (images docker, archives de code...). L'idée est ici de pousser l'automatisation encore plus loin et de générer ces tags sans intervention humaine.
S'il n'y pas d'intervention humaine, sur quoi se baser pour produire les numéros de version ? Sur les messages des différents commit !
Pour que cela fonctionne, le type d'évolution apportée par la modification du code doit être déduit du message du commit. Si le message laisse penser qu'il s'agit d'une évolution PATCH, alors le numéro le plus à droite sera incrémenté de un. S'il s'agit d'une évolution MINOR, alors le deuxième numéro sera incrémenté et le plus à droite sera remis à zéro. Enfin s'il s'agit d'une évolution MAJOR, le premier numéro sera incrémentée, et les deux autres remis à zéro.
Tout repose donc sur le message du commit, qui doit se conformer à un standard et être correctement spécifié par l'auteur du commit pour que son interprétation soit claire pour le générateur de numéros de version.
Formats de commit conventionnels
Il n'y pas de règle universelle pour la constitution d'un message de commit mais il existe un guide de bonne pratique défini ici :
https://www.conventionalcommits.org
Ce guide spécifie qu'un message de commit doit avoir le format suivant :
<type>[scope optionnel]: <description>[contenu optionnel][pied de page optionnel]
La partie la plus importante est le titre du commit avec le type qui peut prendre des valeurs conventionnelles comme :
- feat pour indiquer l'ajout d'une fonctionnalité
- fix pour indiquer la résolution d'un bug
- refactor pour indiquer une modification de code sans changements fonctionnels ou de performances
- perf pour indiquer une amélioration des performances
- ci pour indiquer une modification qui ne concerne pas le code, mais uniquement son déploiement
- docs pour indiquer une mofication concernant la documentation du projet (par exemple son README)
- test pour indiquer l'ajout ou la correction de tests
- revert pour indiquer le fait qu'on revient à une version précédente du code
- chore pour indiquer des changements qui ne concernent pas le code ou les tests (par exemple mettre à jour une dépendance)
- build pour indiquer des changements sur la façon dont est construit le projet
La partie scope optionnelle sert à indiquer plus précisément sur quelle partie du projet a lieu la modification ou l'ajout. La partie description permet de préciser un court résumé des modifications.
Voici quelques exemples de titre de commit au format conventionnel :
feat(api): Envoi d'un courrier suite à la commandefix(ProductComponent): Empeche le plantage lors du clic bouton okstyle: Suppression des points virgules inutiles
Optionnellement, la partie <type>[scope optionnel] peut être suivie par un point d'exclamation qui désigne une modification majeure de type BREAKING CHANGE. Par exemple :
feat(api)!: Rend obligatoire l'authentification
La bibliothèque semantic-release
Cette bibliothèque javascript (https://github.com/semantic-release/semantic-release) analyse les messages de commit pour générer un nouveau numéro de version comme ceci :
- Tout titre de commit dont le type est fix donne lieu à une incrémentation de version de type PATCH
- Tout titre de commit dont le type est feat donne lieu à une incrémentation de version de type MINOR.
- tout titre de commit dont le type est suivi par un point d'exclamation donne lieu à une incrémentation de version de type MAJOR.
Nous allons l'utiliser dans notre processus de CI/CD avec gitlab pour
- automatiser la génération du tag de version sur les commits de la branche
main. - utiliser ce tag pour étiquetter les images docker publiées dans le repository.
Utilisation de semantic-release avec GitLab CI/CD.
On suppose ici que votre projet utilise déjà le processus CI/CD de GitLab pour automatiser la publication d'images docker de votre application. La spécification de ce
pipeline initial n'est pas l'objet de ce post, mais voici un exemple de fichier gitlab-ci.yml automatisant la construction d'une image docker pour la partie frontend
d'une application web :
image: docker:latestservices:- docker:dindstages:- build- packagebuild-job:stage: buildimage: node:18script:- npm install- npm run buildartifacts:paths:- dist/only:- maindocker-build:stage: packagescript:- docker build -t $CI_REGISTRY_IMAGE .- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY- docker push $CI_REGISTRY_IMAGEonly:- main
L'exemple complet (un frontend Angular) est consultable ici: https://gitlab.com/blog150213/semver-exemple.git
Nous allons modifier ce pipeline pour lui ajouter une phase de release dans laquelle un numéro de version sera générée automatiquement.
Commençons par une remarque, semantic-release est certes une bibliothèque javascript, mais elle peut être utilisée pour le versioning de n'importe quel projet. En effet,
cette bibliothèque ne requiert pas d'être utilisée dans le processus de build du projet, mais uniquement par son processus de CI/CD, qui est indépendant du langage et du
framework utilisé par le projet.
C'est pourquoi cette bibliothèque ne nécessite pas de faire partie des dépendances du projet. Seul son fichier de configuration .releaserc doit être ajouté à la racine
du projet avec le contenu suivant :
{"branches": ["main"],"plugins": ["@semantic-release/commit-analyzer","@semantic-release/release-notes-generator",["@semantic-release/gitlab",{"gitlabUrl": "https://gitlab.com"}]]}
Ceci fait, il faut ajouter dans le fichier .gitlab-ci.yml une section release comme ceci :
release:image: node:18-buster-slimstage: releasebefore_script:- apt-get update && apt-get install -y --no-install-recommends git-core ca-certificates- npm install -g semantic-release @semantic-release/gitlabscript:- semantic-releaserules:- if: $CI_COMMIT_BRANCH == "main"
et modifier la partie stages du fichier gitlab-ci.yml pour que l'étape de release se fasse entre le build et le package :
stages:- build- release- package
Un dernier point, le script semantic-release a besoin de pouvoir accéder en écriture au projet. Pour cela, vous devez d'abord créer un nouveau token d'accès en passant par
vos préférences de profil et son menu Access Token comme présenté par l'écran ci-dessous :

La valeur de ce token doit être affecté à une variable d'environnement du CI/CD. Revenez
dans votre projet et accédez au sous-menu CI/CD de ces préférences. Positionnez-vous sur la partie Variables
et ajoutez une nouvelle variable du nom de GL_TOKEN en lui affectant le token pour valeur:

Ca y est, tout est en place. On peut désormais utiliser le versioning en soumettant un nouveau commit dont le titre génère une nouvelle version du projet.
Par exemple :
git add .git commit -m "feat(ci): Automatisation du versioning"git push
On visualise le pipeline chez GitLab:

Quand tout est passé au vert, on peut cliquer sur la partie semver-release pour obtenir les logs et vérifier le numéro de version généré:

On constate aussi qu'une release a été ajouté dans le menu Deployments/releases du projet:

Etiquetage de l'image docker
Pour l'instant notre image docker continue à avoir pour seule étiquette :latest comme le montre l'écran suivant extrait du menu Packages and registries/Container registry de GitLab:

Nous voulons utiliser le tag généré par semantic-release pour l'ajouter comme étiquette à notre image docker. Pour cela, ce numéro doit être transmis à l'étape suivante du pipeline CI/CD.
Une des façon de le faire est de demander à semantic-release d'inscrire la valeur du tag dans un fichier et de publier ce fichier en tant qu'artefact du job. L'étape suivante du pipeline (package) pourra alors
accéder à ce fichier et aller y chercher le numéro de version généré.
C'est le plugin @semantic-release/exec qui va nous permettre d'écrire le tag généré dans un fichier. Pour cela, incluez le plugin dans la configuration de semantic-release en modifiant le fichier .releaserc
comme ceci:
{"branches": ["main"],"plugins": ["@semantic-release/commit-analyzer","@semantic-release/release-notes-generator",["@semantic-release/exec",{"publishCmd": "echo ${nextRelease.version} > VERSION.txt"}],["@semantic-release/gitlab",{"gitlabUrl": "https://gitlab.com"}]]}
On a choisi d'appeler le fichier VERSION.txt. Nous le publions comme artefact du job en modifiant l'étape release du fichier
gitlab-ci.yml comme ceci (notez bien l'ajout du plugin @semantic-release/exec dans le npm install):
semver-release:image: node:18-buster-slimstage: releasebefore_script:- apt-get update && apt-get install -y --no-install-recommends git-core ca-certificates- npm install -g semantic-release @semantic-release/gitlab @semantic-release/execscript:- semantic-releaserules:- if: $CI_COMMIT_BRANCH == "main"artifacts:paths:- VERSION.txt
Il ne reste plus qu'à modifier l'étape package pour qu'elle lise le contenu du fichier et ajoute ce contenu comme étiquette de l'image docker. Voici la modification qui le permet:
docker-build: # This job runs in the test stage.stage: packagescript:- VERSION=$(cat VERSION.txt)- docker build -t $CI_REGISTRY_IMAGE .- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY- docker push $CI_REGISTRY _IMAGE- docker tag $CI_REGISTRY_IMAGE $CI_REGISTRY_IMAGE:$VERSION- docker push $CI_REGISTRY_IMAGE:$VERSION
Commitez les modifications avec un message provoquant le changement de version, par exemple:
git add .git commit -m "fix(ci): Ajout du tag à l'image docker"git push
Si le pipeline passe sans erreur, on peut contrôler les étiquettes des images docker en retournant dans le menu Packages and registries/Container registry pour y trouver par exemple:

Ces tags sont précieux pour sélectionner la version exacte de l'image que l'on veut déployer, et encore plus quand on veut faire du déploiement automatisé par exemple dans un cluster kubernetes.
Bonus, intégrer le numéro de version dans son application
Pour réaliser cela, il faut que le numéro de version soit intégré à l'image docker dans l'étape package.
Avec une variable d'environnement
Si votre application a accès aux variables d'environnement (c'est le cas de toutes les applications backend ou desktop), il suffit, lors du build de l'image docker, de fixer une variable d'environnement à la valeur du tag.
Pour cela on introduit dans le Dockerfile la variable d'environnement (avec ENV) et l'argument qui permet de la passer en paramètre avec ARG.
Voici un exemple pour une application node.js:
FROM node:16COPY package*.json ./RUN npm installCOPY . .ARG TAG=""ENV VERSION $TAGEXPOSE 4000CMD [ "node", "index.js" ]
puis dans le fichier gitlab-ci.yml on construit l'image docker avec:
docker build --build-arg TAG=$VERSION -t $CI_REGISTRY_IMAGE .
L'application une fois déployée peut lire la variable d'environnement. Si on reste sur l'exemple d'une application node.js, il
suffit par exemple d'écrire :
console.log('The value of TAG is:', process.env.VERSION)
En ajoutant le fichier VERSION.txt dans l'arborescence du projet
Si l'application n'a pas accès aux variables d'environnement (c'est le cas des applications frontend), il faut intégrer le ficher VERSION.txt à l'arborescence
du projet, si possible dans un dossier dans lequel l'application stocke ses fichiers statiques comme les images. En effet, ces dossiers sont en général laissé tels quels
par le système de packaging de l'application web et on peut donc y ajouter facilement des fichiers après le build.
Par exemple, pour une application qui stocke ses fichiers statiques dans un sous-dossier appelé assets, voici comment modifier l'étape de package du gitlab-ci.yml
pour y inclure le fichier VERSION.TXT:
docker-build: # This job runs in the test stage.stage: packagescript:- VERSION=$(cat VERSION.txt)- cp VERSION.txt /dist/semver-exemple/assets- docker build -t $CI_REGISTRY_IMAGE .- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY- docker push $CI_REGISTRY_IMAGE- docker tag $CI_REGISTRY_IMAGE $CI_REGISTRY_IMAGE:$VERSION- docker push $CI_REGISTRY_IMAGE:$VERSIONonly:- main
Ceci fait, l'application peut avoir accès au contenu du fichier en réalisant une requête http vers le fichier VERSION.txt.
Voici par exemple comment un composant Angular peut récupérer le numéro de version :
export class HomeComponent {version = ""constructor(private httpclient : HttpClient) {this.httpclient.get("assets/VERSION.txt", {responseType: 'text'}).subscribe(res => this.version = res);}}
Voila qui conclut notre découverte du Semantic Versioning et des outils qui permettent de générer automatiquement les numéros de version des releases. A vous de jouer pour les utiliser dans vos projets !