Semantic Versioning avec Docker et GitLab CI<!-- --> | <!-- -->Code en stock
Semantic Versioning avec Docker et GitLab CI

28 mars 2023

Capture écran du diagramme du DPI

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 :

Une release chez gitlab

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 une merge 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 candidate
  • 1.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 commande
  • fix(ProductComponent): Empeche le plantage lors du clic bouton ok
  • style: 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:latest
services:
- docker:dind
stages:
- build
- package
build-job:
stage: build
image: node:18
script:
- npm install
- npm run build
artifacts:
paths:
- dist/
only:
- main
docker-build:
stage: package
script:
- docker build -t $CI_REGISTRY_IMAGE .
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $CI_REGISTRY_IMAGE
only:
- 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-slim
stage: release
before_script:
- apt-get update && apt-get install -y --no-install-recommends git-core ca-certificates
- npm install -g semantic-release @semantic-release/gitlab
script:
- semantic-release
rules:
- 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 :

Obtention d'un access token

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:

Affectation de GL_TOKEN

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:

GitLab pipeline

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é:

semver pipeline logs

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

GitLab releases

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:

GitLab releases

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-slim
stage: release
before_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/exec
script:
- semantic-release
rules:
- 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: package
script:
- 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:

Docker image tag

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:16
COPY package*.json ./
RUN npm install
COPY . .
ARG TAG=""
ENV VERSION $TAG
EXPOSE 4000
CMD [ "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: package
script:
- 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:$VERSION
only:
- 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 !