Déployer à partir d'un changelog sur GitLab CI

Publié le 24/10/2021 à 16:58. Mise à jour le 04/11/2021 à 19:58.

Déclencher automatiquement les déploiements à partir d'un changelog en utilisant l'API de GitLab et les Personal Access Token.

Déployer à partir d'un changelog sur GitLab CI

Photo de Remy Gieling sur Unsplash.

J'aime beaucoup les standards et les processus bien définis qui garantissent le bon déroulé du développement d'un projet. Mais je travaille aussi seul sur ce site, sur mon temps libre. L'automatisation est donc un aspect primordial pour que je puisse déployer rapidement lorsque je travaille sur ce projet.

J'ai déjà abordé des aspects d'optimisation des pipelines GitLab CI, mais cette fois-ci j'ai voulu remettre en question le workflow global de déploiement. Il était déjà automatisé en CI/CD:

  • mettre à jour quelques fichiers avec le nouveau numéro de version
  • fusionner dans la branche master
  • créer un tag Git
  • envoyer le tout sur GitLab pour déclencher une pipeline de déploiement

Tout ça était effectué via un simple script Bash exécuté en local, donc un peu fragile. L'objectif est de remplacer ce script par la pipeline en utilisant les branches Git et les Merge Requests.

Définition du workflow de déploiement

Le workflow GitFlow pour les branches Git

Si « GitFlow » ne vous dit rien, il s'agit d'un modèle de branches très populaire introduit en 2010 dans un article de blog de Vincent Driessen: A successful Git branching model.

Pour résumer le fonctionnement de GitFlow, la branche master représente l'état actuel de votre environnement de production, elle contient des tags Git représentant chaque version. La future version en cours de développement se trouve sur une branche nommée develop et chaque évolution se fait sur des branches feature/....
Lorsqu'une feature est terminée on la fusionne dans develop. Lorsque l'on souhaite ensuite livrer develop on crée une branche release/... à partir de celle-ci, qui finira fusionnée dans master. On crée ensuite un tag sur master.

Si vous découvrez GitFlow je vous conseille également le guide d'Atlassian qui illustre ce système de branches et qui aborde d'autres aspects ommis dans cet article.

Pourquoi utiliser GitFlow ? C'est un modèle qui a fait ses preuves, il convient très bien au développement d'applications (ici un site web). Et c'est une habitude pour beaucoup de développeurs.
Mon workflow actuel inclue en effet la branche develop, mais elle est directement fusionnée dans master sans passer par des branches de release qui sont très utiles en équipe mais parfois moins lorsque l'on travaille seul sur un projet.

Les étapes du déploiement automatiquement

Le workflow souhaité au départ est le suivant:

  1. Une feature est terminée, on ouvre une Merge Request vers develop qui déclenche une pipeline d'intégration continue.
  2. On fusionne ensuite la MR dans develop ce qui déclenche une nouvelle pipeline.
  3. Cette pipeline va vérifier si une nouvelle version de l'application est déclarée.
  4. Si c'est le cas, on modifie quelques fichiers si besoin, puis on crée une MR vers la branche souhaitée (dans mon cas ce sera directement master).

Toutes ces étapes doivent être automatisées à l'exception des fusions des MR.
Le seul mystère à élucider: comment vérifier la déclaration d'une nouvelle version de l'application ?

Le changelog à la rescousse

Un changelog est un fichier listant les changements d'un projet à chaque version. Il est courant de le générer à partir des logs de Git mais l'idée ici est de strictement faire l'inverse: générer les versions à partir du changelog !

Je maintiens déjà un changelog depuis longtemps selon le format de Keep A Changelog en MarkDown. Les changement non livrés sont situés dans la section "Unreleased", et chaque version est notée dans un titre de la manière suivante:

## [v1.2.3] - 2021-05-28

« C'est bien joli tout ça mais en quoi ça nous avance ? »

Si on souhaite livrer une nouvelle version, on la déclare dans le fichier CHANGELOG.md. Dans la pipeline on pourra alors extraire la dernière version déclarée dans le changelog et la comparer au dernier tag Git. Si ces valeurs diffèrent alors il faut livrer !

Mise en pratique

Créer un token pour les opérations automatisées

Certaines opérations comme l'écriture dans un dépôt Git sont impossible avec les identifiants mis à disposition par défaut dans les pipelines de GitLab CI. Il va donc être obligatoire de créer un Personal Access Token. Il suffit pour cela de suivre la documentation de GitLab: nommer le token et choisir les scopes api et write_repository.

Pour ensuite utiliser ce token de manière sécurisée on va l'enregistrer en tant que variable CI/CD: nommer la variable tel qu'elle sera utilisée dans la pipeline, indiquer la valeur du token généré et cocher la case pour masquer la variable dans les logs de CI.
Pour ma part la variable sera nommée AYMDEV_GITLAB_TOKEN.

Environnement pour les commandes Git

Ayant besoin de commandes git on va utiliser l'image bitnami/git dont on va stocker le nom dans une variable de pipeline dans le .gitlab-ci.yml:

variables:
    # ...
    GIT_IMAGE: "bitnami/git:2.33.0-debian-10-r61"

Chaque job exécutant des commandes git utilisera cette image, mais devra également configurer Git. Pour réutiliser la liste de commande suivante on va créer une ancre YAML:

.git-setup: &git-setup
    - git config --global user.name "${GITLAB_USER_LOGIN}"
    - git config --global user.email "${GITLAB_USER_EMAIL}"
    - git remote set-url origin "https://${GITLAB_USER_LOGIN}:${AYMDEV_GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
    - git tag -l | xargs git tag -d
    - git fetch --tags

On fait usage des variables d'environnement prédéfinies ainsi que de la variable contenant le token afin de configurer Git au minimum. On resynchronise également les tags pour éviter de bloquer les pipelines en cas d'erreur.

Extraction de numéros de version depuis le changelog

L'extraction du numéro de version se fera avec une RegEx. Attention cependant car il existe différents moteurs et que tous ne sont pas disponibles nativement sur toutes les images Docker utilisées en CI. J'apprécie particulièrement le moteur PCRE (Perl Compatible Regular Expression) cependant perl n'est pas toujours installé, et l'option -P de la commande grep n'est pas toujours disponible.
On va donc passer par grep -E en utilisant une POSIX-Extended RegEx. On ajoutera également l'option -o qui permet de ne récupérer que la partie correspondant au pattern.
Les inconvénients de ce type de RegEx sont nombreux: on ne peut pas utiliser de raccourcis tels que \d, ni de "capture groups" comme on le ferait en PHP.

Commençons par récupérer toutes les lignes de titre contenant un numéro de version:

^## \[v[0-9]+\.[0-9]+\.[0-9]+\] - [0-9]{4}(-[0-9]{2}){2}$

On commence par le début de ligne (^), le titre, le numéro de version puis la date de création de la version et la fin de la ligne ($).
Résultat: ## [v1.2.3] - 2021-05-28

On passera ce résultat à une seconde expression qui extraiera uniquement le numéro de version:

v[0-9]+\.[0-9]+\.[0-9]+

On ne récupère que la partie entre crochets [].
Résultat: v1.2.3

Et on gardera le premier résultat pour obtenir la dernière version déclarée. Pour facilement utiliser cette valeur on va déclarer une nouvelle ancre YAML qui déclarera une variable CURRENT_VERSION et que l'on ajoutera à tous les jobs ayant besoin de cette variable:

.get-git-tag: &get-git-tag
    - export CURRENT_VERSION=$(grep -oE '^## \[v[0-9]+\.[0-9]+\.[0-9]+\] - [0-9]{4}(-[0-9]{2}){2}$' CHANGELOG.md | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n 1)

Fusion d'une Merge Request dans develop

À la fusion de la MR une pipeline pour la branche develop va se lancer. On va rajouter un job à la fin qui sera chargé de vérifier la déclaration d'une nouvelle version:

trigger-merge-request:
    stage: release
    image: "$GIT_IMAGE"
    rules:
        - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"'
    before_script:
        - *git-setup
        - *get-git-tag
        - git checkout develop
        - git reset --hard origin/develop
    script:
        - ./ci/merge-request.sh

On utilise les ancres git-setup et get-git-tag, on synchronise la branche locale par rapport à celle du dépôt distant, puis on lance un script Bash ci/merge-request.sh dont voici le contenu:

#!/usr/bin/env bash

# Le job doit échouer à la moindre erreur
set -xe

# Récupération du dernier tag Git
LAST_GIT_TAG=$(git tag | sort -V | tail -1)

# S'il correspond à la dernière version dans le changelog on s'arrête
if [ "$LAST_GIT_TAG" == "$CURRENT_VERSION" ]; then
    exit 0
fi


# Mise à jour de quelques fichiers faisant référence à la version de production
sed -i "s/${LAST_GIT_TAG}/${CURRENT_VERSION}/g" README.md
sed -i "s~${CI_REGISTRY_IMAGE}:${LAST_GIT_TAG}~${CI_REGISTRY_IMAGE}:${CURRENT_VERSION}~" docker-compose.yml


# Si les fichiers ont changé, on crée un commit qu'on envoie sur GitLab
if [ "$(git diff --exit-code README.md docker-compose.yml)" ]; then
	git add README.md docker-compose.yml
	git commit -m "UPGRADE app to ${CURRENT_VERSION}"
	git push -o ci.skip
fi


# Contenu JSON à envoyer à l'API GitLab pour créer une Merge Request
REQUEST_BODY=$(cat <<-JSON
	{
	    "source_branch": "develop",
	    "target_branch": "master",
	    "title": "[AUTO-RELEASE] Deploy ${CURRENT_VERSION}"
	}
JSON
)

# Envoi d'une requête POST à l'API GitLab pour créer la Merge Request
curl -X POST "https://${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/merge_requests" \
    --header "PRIVATE-TOKEN:${AYMDEV_GITLAB_TOKEN}" \
    --header "Content-Type: application/json" \
    --data "${REQUEST_BODY}"

Une fois terminé, on devrait pouvoir observer une Merge Request sur le GitLab.

Notez la commande utilisée pour envoyer le commit: git push -o ci.skip.
ci.skip est une option de push de GitLab qui empêche de déclencher une pipeline. On ne souhaite pas en déclencher pour ce commit qui ne fait que changer le numéro de version alors que la pipeline actuelle sur develop est passée.
Cependant pour pouvoir fusionner la nouvelle MR vers master il faut configurer le projet afin que les "skipped pipelines" soient considérées valides. Dans l'interface du projet, aller dans Settings > General > Merge Requests > Merge checks puis cocher « Skipped pipelines are considered successful ».

Fusion de la Merge Request dans master

La pipeline de déploiement est enfin lancé, il sera juste nécessaire de rajouter un job qui créera un tag Git pour officiellement créer la nouvelle version:

publish-git-tag:
    stage: release
    image: "$GIT_IMAGE"
    rules:
        - if: '$CI_COMMIT_BRANCH == "master"'
    before_script:
        - *git-setup
        - *get-git-tag
        - git checkout master
        - git reset --hard origin/master
    script:
        - git tag -a -m "Releasing app $CURRENT_VERSION" "$CURRENT_VERSION"
        - git push --tags

Il n'y a aucun pré-requis pour les autres jobs. En revanche on peut réutiliser le changelog pour extraire la précédente version pour, par exemple, indiquer un tag d'image Docker à utiliser comme base de cache pour construire la nouvelle image:

build-docker-image:
    stage: release
    rules:
        - if: '$CI_COMMIT_BRANCH == "master"'
    before_script:
        # ...
        - *get-git-tag
    script:
        - export PREVIOUS_VERSION=$(grep -oE '^## \[v[0-9]+\.[0-9]+\.[0-9]+\] - [0-9]{4}(-[0-9]{2}){2}$' CHANGELOG.md | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | sed -n '2 p')
        - docker build --pull --cache-from "$CI_REGISTRY_IMAGE:$PREVIOUS_VERSION" -t "$CI_REGISTRY_IMAGE:$CURRENT_VERSION" -f ./Dockerfile .
        - docker push "$CI_REGISTRY_IMAGE:$CURRENT_VERSION"

Conclusion

La création d'une nouvelle version se fait désormais presque automatiquement, simplement en ajoutant une version au changelog !
Cette première approche est une base à adapter en fonction des projets. Dans une équipe de développement il sera probablement nécessaire de créer des branches de release plutôt que de créer une MR vers master. Mais l'essentiel est là, on sait créer des commits, des tags et des Merge Request depuis la pipeline !

Pour ma part il faudra encore quelques itérations avant que le workflow soit vraiment fluide, il est encore trop long et manuel. Comme l'a indiqué Vincent Driessen dans son article sur GitFlow en 2020, si le projet est en déploiement continu alors il est préférable de passer par un modèle de branches plus simple comme le GitHub flow.
Prochain objectif ? Me débarasser de la branche develop pour n'avoir que les feature et master.

Commentaires: 0

Invasion robot en provenance de robohash.org