Utiliser Nix ou Docker pour des environnements de dev reproductibles
Introduction
Disposer d’une CI/CD efficace et robuste est l’une des clés pour développer des services de qualité. Cependant, mettre en place un tel système n’est pas toujours simple, et le maintenir dans le temps l’est encore moins. L’une des principales raisons en est la difficulté à obtenir un environnement d’exécution similaire entre le système de CI et les postes locaux des développeurs.
Malheureusement, ce type de système est parfois sous-développé, car il n’est pas toujours perçu comme créateur de valeur. Pourtant, sans lui, il est impossible de livrer un service dans de bonnes conditions et à un rythme soutenu.
À mon sens, l’essor de GitHub Actions est un symptôme de ces problèmes. En y réfléchissant, la promesse des GitHub Actions est de passer le moins de temps possible sur sa CI en utilisant au maximum les actions du marketplace. La promesse est alléchante, et il est vrai que le catalogue est bien fourni. Mais dès que l’on creuse un peu, on se rend compte que ce paradigme peut rapidement mener à plusieurs problèmes.
Notamment :
- Les CI/CD ont souvent besoin de secrets pour fonctionner. Il suffit qu’une action soit compromise pour que ces secrets soient volés. Dans le meilleur des cas, on peut effectuer une rotation des secrets à temps. Mais le temps économisé en n’écrivant pas soi-même l’action est probablement perdu à gérer les conséquences d’une telle attaque. Il ne s’agit pas d’un scénario hypothétique : c’est ce qui s’est produit récemment avec le piratage de Trivy, ainsi qu’avec celui de l’action tj-actions/changed-files en 2025.
- Dès que l’on introduit une action autre que le checkout ou les diverses actions
setup-*pour installer un programme, on perd une grande partie de notre capacité à tester les changements. Or, la CI/CD a également besoin d’être testée, car son interruption peut faire perdre du temps à toutes les équipes de développement. Dans le pire des cas, cela peut même retarder la mise en production de correctifs urgents.
Dans la suite de cet article, nous allons tenter de mettre en place de bonnes pratiques pour limiter ces problèmes tout en profitant des avantages de GitHub Actions.
Le scénario
Fixons-nous un objectif en partant d’une application simple : un serveur web en Go.
Pour éviter de nous limiter à un simple “hello-world”, nous allons y ajouter une bibliothèque tierce : templ pour le rendu HTML. Si vous ne connaissez pas templ, pas d’inquiétude, ce n’est pas le cœur du sujet. Retenez simplement que cet outil nécessite d’exécuter une commande pour générer du code avant de compiler notre application.
À partir de là, nous souhaitons être capables de :
- Effectuer le linting de notre code
- Tester notre code
- Générer le code (nécessaire avec
templ) - Compiler notre application
Mais nous allons nous imposer plusieurs contraintes pour garder une CI/CD maintenable :
- Les étapes de notre CI/CD doivent pouvoir s’exécuter facilement en local, afin de pouvoir les tester efficacement.
- Le système doit fonctionner sous Linux et macOS, car même si la CI s’exécutera sous Linux, certains développeurs utilisent macOS.
- Notre système doit être le plus sécurisé possible, notamment en étant résistant aux attaques de type supply-chain.
C’est parti !
Linting du code
Commençons par cet aspect, qui sera le plus simple. Si vous êtes un peu familier avec GitHub Actions, vous savez probablement qu’il est possible de le faire très simplement. Nous allons utiliser l’outil golangci-lint, un excellent outil qui permet de vérifier que notre code respecte un certain nombre de standards, comme le fait de ne pas ignorer d’erreurs.
Il faudra simplement veiller à bien générer notre code via templ generate avant.
on:
push:
branches:
- main
jobs:
codegen:
name: "Ensure code is generated"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
- name: Setup templ
run: go install github.com/a-h/templ/cmd/templ@latest
- name: "Ensure code is generated"
run: scripts/codegen.sh
lint:
name: "Lint code"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
La lune de miel avec GitHub Actions bat son plein : le code sera vérifié à chaque commit avec seulement 15 lignes de YAML. Tout semble parfait…
Sauf que non. Plusieurs problèmes persistent :
- À chaque exécution de ce workflow, le système va lire
golangci/golangci-lint-action@v9et télécharger l’action correspondante depuis GitHub. Mais que se passe-t-il si ce projet subit une attaque et qu’un pirate fait pointer le tagv9sur une version malveillante de l’action ? Dans ce cas, il faudra faire tourner nos secrets, mais même en agissant à temps, l’attaquant aura déjà eu tout le loisir de télécharger notre code source. Plus on utilise d’actions différentes dans notre workflow, plus ce risque est accru. - De plus, nous ne contrôlons pas la version de golangci-lint installée, ce qui peut entraîner des différences de résultats entre une utilisation locale et celle en CI. Nous voulons éviter ce genre de subtilités, car elles peuvent toutes nous faire perdre du temps si une erreur devait apparaître.
- Enfin, nous avons un job de génération de code et un job de linting. Si nous voulons garder ces jobs séparés, il faut réinstaller tous les utilitaires Go dans chaque job, ce qui crée une charge réseau inutile.
Améliorons un peu tout cela
Nous pouvons déjà faire mieux sans introduire de nouvel outil. Pour cela, il faut “pin” nos actions à des commits précis. C’est d’ailleurs une recommandation de sécurité faite par GitHub Actions, mais qui me semble peu souvent mise en œuvre en pratique.
La référence est ici : “Pin actions to a full-length commit SHA”.
Voici ce que cela donne après cette modification :
name: Run workflow with pinned dependencies
on:
push:
jobs:
codegen:
name: "Ensure code is generated"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: Setup templ
run: go install github.com/a-h/templ/cmd/templ@latest
- name: "Ensure code is generated"
run: scripts/codegen.sh
lint:
name: "Lint code"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
Ici, nous nous protégeons contre un vecteur d’attaque majeur. Cependant, il faut noter quelques points :
- Cela peut complexifier la mise à jour des actions, mais heureusement, les commentaires
#vX.X.Xpermettent à des outils comme Dependabot de trouver automatiquement les mises à jour concernant ces actions, même en utilisant un SHA de commit. - Il faut rester vigilant : si l’on modifie l’un de ces commits pour pointer vers un fork d’une de ces actions, notre CI le clonera sans sourciller. Il faut donc toujours vérifier que le commit correspond bien à un tag sur le dépôt attendu et non à un autre.
Profitons-en pour ajouter les tests. Notre workflow devrait maintenant ressembler à ceci :
name: Run workflow with pinned dependencies
on:
push:
jobs:
codegen:
name: "Ensure code is generated"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: Setup templ
run: go install github.com/a-h/templ/cmd/templ@latest
- name: "Ensure code is generated"
run: scripts/codegen.sh
lint:
name: "Lint code"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
test:
name: "Test code"
runs-on: ubuntu-latest
needs:
- codegen
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: "Tests"
shell: bash
run: go test ./...
Comme on peut le voir, nous allons télécharger la même version de Go trois fois de suite, ce qui est dommage. De plus, il est difficile de tester cette CI en local dans les mêmes conditions. Les deux principaux problèmes de cette approche sont donc :
- Manque de reproductibilité en local
- Vendor lock-in chez GitHub Actions
Si ces deux points ne vous posent pas de problème, il est probable que vous n’ayez pas besoin des pratiques que nous allons voir ensuite. Si cela fonctionne pour vous et que vous n’avez pas de difficulté à maintenir votre CI, mieux vaut ne pas la complexifier.
Ce n’est pas mon cas. Ayant travaillé à de nombreuses reprises sur des CI, je pense que pouvoir modifier et tester les scripts en local est une aide précieuse qui vous fera gagner beaucoup de temps. Cela permettra également de partager le même environnement de développement avec tous les contributeurs, alors pourquoi s’en priver ?
La quête de la reproductibilité
Si nous voulons pouvoir tester notre CI en local et éviter de retélécharger tout ou partie de notre toolchain, nous devons résoudre un problème de taille : mettre en place un environnement reproductible, quelle que soit la machine qui l’exécute.
Jusqu’à présent, ma réponse à cette question était immédiatement Docker, mais j’ai récemment tenté d’apprivoiser Nix, qui présente une courbe d’apprentissage un peu plus abrupte. Ces deux technologies permettent de traiter ce sujet de manière totalement différente, chacune avec ses avantages et ses inconvénients.
Plutôt que de nous lancer dans une comparaison abstraite, essayons d’adapter notre CI grâce à ces deux outils.
Docker
Docker est désormais très utilisé, je ne vais donc pas revenir en détail sur ce que c’est et dans quels autres cas cela peut être utile. Rappelons simplement qu’une image Docker permet de produire un filesystem minimaliste qui permet d’exécuter des programmes avec uniquement leurs dépendances.
Dans notre cas, voici à quoi pourrait ressembler une telle image :
FROM golang@sha256:fcdb3e42c5544e9682a635771eac76a698b66de79b1b50ec5b9ce5c5f14ad775 # golang:1.26.2
ARG SCALEWAY_CLI_VERSION="2.54.0"
ARG GOLANGCI_LINT_VERSION="2.11.4"
RUN apt-get update && apt-get install -y wget
RUN wget "https://github.com/scaleway/scaleway-cli/releases/download/v${SCALEWAY_CLI_VERSION}/scaleway-cli_${SCALEWAY_CLI_VERSION}_linux_amd64" -O /tmp/scaleway-cli && \
chmod +x /tmp/scaleway-cli && \
mv /tmp/scaleway-cli /bin/scw && \
scw version
RUN wget "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/golangci-lint-${GOLANGCI_LINT_VERSION}-linux-amd64.tar.gz" -O /tmp/golangci-lint.tar.gz && \
tar -xf /tmp/golangci-lint.tar.gz -C /tmp && \
mv /tmp/golangci-lint-${GOLANGCI_LINT_VERSION}-linux-amd64/golangci-lint /tmp/golangci-lint && \
chmod +x /tmp/golangci-lint && \
mv /tmp/golangci-lint /bin/golangci-lint && \
golangci-lint --version
RUN go install github.com/a-h/templ/cmd/templ@v0.3.1001 && \
templ version
RUN rm -rf /tmp/* && mkdir /src
WORKDIR /src
Prenons le temps de décortiquer cette image. Nous avons principalement trois étapes RUN qui vont nous permettre de télécharger et d’installer les binaires nécessaires à notre CI : golangci-lint, templ et scaleway-cli (utile pour le déploiement mais hors sujet ici). Go et sa toolchain seront déjà installés, car nous partons d’une image de base qui les contient : golang:1.26.2. Nous devons également pin l’image de base afin d’être sur de pull toujours la même.
Il suffit ensuite de builder cette image Docker :
docker build . -t ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0 -f Dockerfile.ci
Une fois cette commande exécutée, toute personne lançant un conteneur à partir de cette image aura exactement la même panoplie d’outils. En effet, une image correspond à un “snapshot” de filesystem, ce qui est assez similaire à une image de machine virtuelle.
En revanche, ce filesystem est isolé de la machine hôte et ne contient donc pas les sources de notre application. Nous pourrions les y mettre, mais cela nécessiterait de recréer une image Docker à chaque petite modification, ce qui serait très lourd. Pour ce cas d’usage, il vaut mieux monter un volume dans notre conteneur. En local, cela donnerait :
docker run -it -v $(pwd):/src ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0
L’option -v nous permet de faire correspondre un dossier de notre machine hôte à un emplacement dans notre conteneur (ici /src). À partir de là, nous serons capables de lancer toutes les commandes nécessaires pour travailler sur le projet.
Cependant, à ce stade, nous ne pouvons l’utiliser qu’en local. Pour que tout le monde puisse réellement utiliser la même image, il faut la pousser sur un registry :
docker push ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0
Ensuite, nous pouvons l’utiliser directement dans nos workflows GitHub, à condition que le registry soit public. S’il ne l’est pas, il faudra gérer l’authentification pour que GitHub puisse pull l’image concernée.
name: Run workflow with docker
on:
push:
env:
IMAGE_TAG: ${{ github.sha }}-nix
permissions:
contents: read
id-token: write
jobs:
codegen:
name: "Ensure code is generated"
runs-on: ubuntu-latest
container:
image: ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- run: scripts/codegen.sh
lint:
name: "Lint code"
runs-on: ubuntu-latest
container:
image: ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- run: golangci-lint run
test:
name: "Test code"
runs-on: ubuntu-latest
needs:
- codegen
container:
image: ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- run: go test ./...
Ici, c’est l’étape checkout qui permet de monter les sources dans le conteneur, c’est une partie un peu magique de GitHub Actions, mais cela fonctionne.
Nix
Voyons maintenant comment faire la même chose avec Nix.
Tout d’abord, contrairement à Docker, Nix est un gestionnaire de paquets. Ce sont donc deux technologies qui n’ont rien à voir sur le principe. La proposition de Nix est de permettre de garantir la reproductibilité des environnements.
Pour offrir ces garanties, Nix utilise des lockfiles et des checksums afin que chaque téléchargement de package, effectué à partir des mêmes fichiers, donne le même résultat. Nix vient avec son propre langage déclaratif, qui peut être intimidant (il l’a été pour moi).
Dans notre cas, nous voulons surtout mettre en place un environnement, un shell pour être plus précis, avec les programmes installés, disponibles et dans la bonne version.
Pour cela, nous pouvons utiliser les flakes de Nix. On peut voir un flake comme une “fonction” qui prend une ou plusieurs entrées et peut produire une ou plusieurs sorties. Je suis conscient que c’est très abstrait dit comme ça, mais nous allons préciser tout cela. Par exemple, dans notre cas, nous n’aurons qu’une entrée, nixpkgs, que l’on peut voir comme un dépôt contenant les paquets que l’on peut installer. C’est un simple dépôt GitHub.
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
Et nous n’aurons également qu’une sortie : un shell qui contient les binaires souhaités. Pour cela, Nix expose une fonction toute simple : mkShell.
pkgs = import nixpkgs { inherit system; };
shell = pkgs.mkShell {
packages = [
pkgs.go
pkgs.golangci-lint
pkgs.templ
pkgs.scaleway-cli
];
};
Si nous voulons être capables d’utiliser ce flake sur des postes macOS et aussi des postes ou serveurs Linux, il va falloir complexifier un peu notre code, mais faisons-le pas à pas.
Tout d’abord, nous allons définir la liste des systèmes que nous souhaitons supporter :
systems = [
"x86_64-linux"
"aarch64-darwin"
];
Avant d’aller plus loin, il faut préciser deux choses sur le langage Nix :
- On peut déclarer des variables avec la construction
let {} in {}. Les variables déclarées dans le blocletseront connues dans le blocin. Exemple :
let
h = "Hello";
X = "World";
in
{
helloWorld = h + " " + X;
}
Résultat : { helloWorld = "Hello World"; }
- Pour appliquer une fonction à tous les éléments d’une liste, on peut utiliser la fonction
map. Exemple :
builtins.map (x: x*2) [1 2 3]
Résultat : [ 2 4 6 ]
Ici, nous appliquons la fonction f(x) = x*2 à la liste [1,2,3], ce qui nous donne en retour la liste [2,4,6].
Ces éléments précisés, revenons à notre sujet. Nous allons devoir définir un shell par système que nous souhaitons supporter. Voici ce que cela va donner :
builtins.map (system: let
pkgs = import nixpkgs { inherit system; };
shell = pkgs.mkShell {
packages = [
pkgs.go
pkgs.golangci-lint
pkgs.templ
pkgs.scaleway-cli
];
};
in {
name = system;
value = {
default = shell;
};
}) systems
D’un point de vue haut niveau, cette fonction va retourner quelque chose comme :
[
{
name = "x86_64-linux",
value = {
default = $shell
}
},
{
name = "aarch64-darwin",
value = {
default = $shell
}
}
]
Ensuite, nous allons lui appliquer la fonction listToAttrs, qui permet de transformer une liste en attribute set (le terme en Nix qui désigne ce que l’on appelle les dictionnaires en Python ou les maps en Go). Le résultat donnera quelque chose comme :
{
"x86_64-linux": {
default = $shell
}
"aarch64-darwin": {
default = $shell
}
}
Or, c’est le genre d’objet qui est attendu par Nix pour pouvoir créer un shell. Il suffit donc d’associer cette valeur à la clé devShells pour que Nix comprenne que nous définissons des environnements de développement.
Mis bout à bout, voici ce que nous obtenons :
// flake.nix
{
description = "Development environments for this project";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
outputs = {nixpkgs, ...}: let
systems = [
"x86_64-linux"
"aarch64-darwin"
];
in {
devShells = builtins.listToAttrs (
builtins.map (system: let
pkgs = import nixpkgs { inherit system; };
shell = pkgs.mkShell {
packages = [
pkgs.go
pkgs.golangci-lint
pkgs.templ
pkgs.scaleway-cli
];
};
in {
name = system;
value = {
default = shell;
};
}) systems
);
};
}
Si toute la syntaxe n’est pas encore claire, ce n’est pas grave, il faut mettre les mains dedans pour l’apprendre. Mais cela en vaut la peine, car ensuite, il suffit de lancer cette commande :
nix develop
$ which go
/nix/store/ckcq2mj8zk0drhaaacy6mp9d924hnr4m-go-1.26.1/bin/go
$ which golangci-lint
/nix/store/j4w0z1lgrs4fdds7npk2bnby5ks612cf-golangci-lint-2.11.4/bin/golangci-lint
$ which scw
/nix/store/mn077hkqy1bs3dwxkvxi15l0k7vz03mk-scaleway-cli-2.54.0/bin/scw
$ which templ
/nix/store/v4qj3mml7lyps2nicavbys97w56dxvjy-templ-0.3.1001/bin/templ
Le temps de télécharger les binaires si ce n’est pas déjà fait, et nous voilà plongés dans un shell avec tous les outils nécessaires pour travailler. Il faut immédiatement préciser deux choses :
- Pour que cela fonctionne, le fichier
flake.nixdoit absolument être indexé dans Git, sinon Nix ne le reconnaîtra pas. - S’il n’y a pas de fichier
flake.lock, Nix prendra la dernière version des paquets dans son dépôt. Il est donc impératif de commiter ce fichier afin que les installations soient vraiment reproductibles. Si le fichier est présent, tout système lançant la commandenix developdans le dossier en question aura exactement les mêmes binaires installés.
Pour pouvoir l’utiliser dans la CI, il faut adapter un peu nos workflows. Voici ce que cela donne :
name: Run workflow with nix
on:
push:
env:
IMAGE_TAG: ${{ github.sha }}-nix
jobs:
codegen:
name: "Ensure code is generated"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
- shell: "nix develop -c bash -e {0}"
run: scripts/codegen.sh
lint:
name: "Lint code"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
- shell: "nix develop -c bash -e {0}"
run: golangci-lint run
test:
name: "Test code"
runs-on: ubuntu-latest
needs:
- codegen
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
- shell: "nix develop -c bash -e {0}"
run: go test ./...
La seule action qui nous reste ici est celle qui permet d’installer Nix dans les runners GitHub, car ce n’est pas le cas par défaut. Pour éviter de tout retélécharger à chaque fois, il est également nécessaire de configurer un cache, ce que fait automatiquement l’action DeterminateSystems/magic-nix-cache-action.
Conclusion
J’espère vous avoir montré comment mettre en place une CI/CD fiable, facile à maintenir grâce à sa reproductibilité, et sécurisée en évitant de tout retélécharger, ce qui limite une bonne partie des attaques de type supply-chain.
L’objectif de cet article n’est pas de dire que Docker est mieux que Nix ou l’inverse, mais plutôt de présenter deux manières de répondre à la même problématique. Si l’on tente de résumer les conséquences de l’utilisation de l’une ou l’autre de ces options, voici ce que nous pourrions dire :
- Avec Nix, contrairement à Docker, nous n’avons aucune isolation par rapport au reste du système. C’est une bonne chose lorsque l’on veut utiliser d’autres outils qui ne sont pas présents dans l’environnement, comme les IDE ou les débogueurs. Mais c’est un problème lorsque l’on veut faire tourner un processus sur un serveur.
- La prise en main de Nix est plus difficile avec son domain specific language (DSL). Avec Docker, on utilise essentiellement des commandes basiques pour construire une image à partir d’un Dockerfile.
- Avec Docker, il faudra mettre en place un autre processus pour builder et publier l’image utilisée dans la CI, ce qui complexifie la CI.
- En termes de temps d’exécution, je n’ai pas observé de différence flagrante entre les deux options.
Si vous voulez aller plus loin, le code source lié à cet article est disponible ici : https://github.com/dhawos/nix-docker-blog-post. N’hésitez pas à le parcourir.
Que cet article vous ait été utile ou que vous ayez remarqué des erreurs ou des imprécisions, n’hésitez pas à me le faire savoir sur mon Bluesky.
Merci de m’avoir lu et à bientôt.