← cd ..

Un modèle de chaîne de build CI : build multi-stage optimisé

AU
Aubin Puyoou · 16 octobre 2026

Introduction

Cet article décrit un pattern de chaîne de build CI/CD, dans un environnement dans lequel le projet repose sur des registres privés. Cette étape de la chaîne de construction nécessite un secret par registre utilisé pour construire les dépendances. Le concept de cette étape du build est simple : résoudre les dépendances une seule fois et publier deux artefacts, un jeu complet pour les tests, un jeu de production, de façon à permettre aux tâches suivantes de la chaîne de pouvoir les consommer sans avoir à s'authentifier.

La première partie décrit le modèle, la deuxième des pistes pour optimiser le stockage qu'il consomme sur la forge, la troisième ses inconvénients et l'impact qu'il implique dans différents environnements de travail. La quatrième déroule un pipeline d'exemple complet, un projet PHP/Composer qui consomme une bibliothèque privée de la même forge GitLab, la cinquième le transpose à npm, Maven et Cargo.


Partie 1 : Description du modèle

L'intention première de ce modèle vise à confier la résolution des dépendances à une unique étape de la CI. Publiés en artefact, ces paquets sont récupérables à tout moment dans la suite du pipeline.

Cette première étape sera nommée "deps", et peut regrouper les tâches d'installation des dépendances en vue d'une utilisation "production" ou avec ses dépendances de test.

Détail du séquencement en tâches CI :

Séquencement des tâches CI de l'étape deps aux étapes test et build image

L'étape deps est la seule à manipuler le secret de la forge : dans GitLab CI, c'est CI_JOB_TOKEN, une variable prédéfinie et temporaire, valable le temps du job, dont le projet de la dépendance doit autoriser l'usage (partie 4). Le registre d'images a le sien, distinct, détenu par l'étape build image (partie 4) — deux secrets, jamais le même job.

Étape de la CI Rôle Détient un secret ?
Étape deps Résout et télécharge les dépendances et publie deux artefacts : l'un avec les dépendances de développement, l'autre sans Secret du registre de paquets privé
test Consomme l'artefact incluant les dépendances de développement et exécute la suite de tests Non
build image Consomme l'artefact de production, construit et pousse l'image en copiant dans le contexte de build les fichiers mis à disposition par la CI Les identifiants d'accès au registre d'images

Trois avantages du pattern :

  1. Le Dockerfile ne contient aucun secret : l'image Docker n'intègre aucune autre fonctionnalité relative à la forge.

  2. Les tests unitaires peuvent directement être exécutés, sans nécessairement attendre la construction de l'image finale du conteneur.

  3. La récupération des fichiers a lieu une fois par pipeline, via une tâche permettant trivialement de récupérer les dépendances à toute étape du pipeline.


Partie 2 : Optimisation du stockage de la forge

Ce pattern s'accompagne d'un certain coût : chaque pipeline dépose deux archives de dépendances dans le stockage d'artefacts de la forge. Un vendor/ PHP pèse quelques dizaines de mégaoctets, un node_modules/ couramment plusieurs centaines. Multiplié par le nombre de pipelines, de branches et de tags, le volume grossit vite.

Il bute aussi sur un plafond : chaque fichier d'artefact est limité par défaut à 100 Mo sur une instance autohébergée, à 1 Go sur GitLab.com. Cette limite porte sur l'archive finale, et est réglable par instance, par groupe ou par projet (Settings → CI/CD → General pipelines → Maximum artifacts size).

Mesurer d'abord

Deux lignes en fin de script du job deps suivent la taille d'un pipeline à l'autre :

du -sh vendor                            # arborescence de production, décompressée
ls -lh vendor.tar.gz vendor-dev.tar.gz   # ce qui part réellement en artefact

À l'échelle du projet, Settings → Usage Quotas isole la part des job artifacts : c'est le chiffre à regarder avant chaque changement, et après.

Ce que GitLab conserve malgré certains paramétrages

Par défaut, GitLab conserve, pour chaque référence Git, les artefacts du dernier pipeline et ceux du dernier pipeline réussi, sans tenir compte de la directive expire_in (durée avant expiration) indiquée dans le pipeline, jusqu'à ce qu'un nouveau pipeline tourne sur la même référence. Sur une branche active, le pipeline suivant libère le précédent. Sur un tag, aucun pipeline ne repasse : chaque release garde ses archives indéfiniment.

Conservation des artefacts par référence : sur la branche main, chaque pipeline libère le précédent ; sur les tags v1.0 et v1.1, les archives restent au-delà d'expire_in, indéfiniment

Le réglage se désactive par projet (Settings → CI/CD → Artifacts → Keep artifacts from most recent successful jobs), et globalement sur une instance autohébergée (Keep artifacts from latest successful pipelines). Ici, le décocher se défend : l'artefact n'est qu'un intermédiaire, le réel livrable est l'image poussée au registre.

Une durée de vie par jeu de dépendances

expire_in vaut pour tous les artefacts d'un job : produites ensemble, les deux archives partagent la même échéance, alors que le jeu de développement ne sert qu'aux tests de son pipeline, et le jeu de production au retour arrière. Deux jobs donnent à chacun la sienne :

deps:dev:
  extends: .deps   # image, cache et authentification du job deps (partie 4)
  script:
    - composer install --no-interaction --no-progress
    - tar czf vendor-dev.tar.gz vendor
  artifacts:
    paths: [vendor-dev.tar.gz]
    expire_in: 1 week

deps:prod:
  extends: .deps
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction --no-progress
    - tar czf vendor.tar.gz vendor
  artifacts:
    paths: [vendor.tar.gz]
    expire_in: 1 month

Passé une semaine, la relance d'un job de test échoue sur l'assertion vendor/autoload.php (partie 4) : on relance alors le pipeline entier.

Contrôler quand générer un artefact pour la production

Une règle if: $CI_COMMIT_TAG permet de générer image et dépendances uniquement sur les tags. Le job de construction de l'image porte alors la même règle, et chaque consommateur dépend de son jeu de dépendances :

test:
  needs: [deps:dev]

build-image:
  rules:
    - if: $CI_COMMIT_TAG
  needs: [deps:prod, test]

Si les deux règles divergent, un needs vers un job absent fait refuser la création du pipeline entier : une erreur bruyante, que needs:optional ferait taire en masquant l'écart. Si vous souhaitez construire l'image à chaque pipeline, comme dans l'exemple de la partie 4, seules les durées de vie jouent.

Compresser une fois, et mieux

Le runner emballe chaque artefact dans une archive zip, et recompresse donc une archive tar.gz déjà compressée, pour un gain nul. Avec l'archiveur fastzip, activé par défaut sur les versions récentes du runner (feature flag FF_USE_FASTZIP), la variable ARTIFACT_COMPRESSION_LEVEL règle cet effort de fastest à slowest, fastest allant jusqu'à ne plus compresser du tout.

L'autre levier est l'algorithme de l'archive elle-même : zstd compresse en général mieux que gzip à niveau élevé, et décompresse plus vite, au profit de chaque job consommateur.

tar cf - vendor | zstd -T0 -19 -o vendor.tar.zst   # job deps
zstd -dc vendor.tar.zst | tar xf -                 # jobs consommateurs

zstd doit cependant être présent dans chaque image de jobs qui écrit ou lit l'archive, ce que les images officielles ne garantissent pas.

Le cache occupe aussi de la place

Le cache Composer grossit de la même manière : chaque modification de composer.lock crée une entrée, que le runner ne supprime jamais, et Clear runner caches n'en libère aucune, il incrémente seulement l'index du nom de cache. La purge se fait en dessous : par une règle de cycle de vie (lifecycle rule) sur le bucket d'un cache distribué S3 ; pour le cache local d'un runner Docker, GitLab ne prévoit rien d'automatique.

Faire le ménage dans les artefacts existants

Modifier expire_in ne s'applique pas aux artefacts déjà stockés. Pour l'existant, la page Build → Artifacts en supprime jusqu'à cent à la fois, et l'API tous ceux du projet, hormis ceux que protège la conservation par référence :

curl --request DELETE --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  "https://gitlab.internal.example/api/v4/projects/<id-du-projet>/artifacts"

La suppression est irréversible, et différée : l'appel ramène l'échéance des artefacts à l'instant présent, et la tâche de nettoyage périodique de GitLab les efface ensuite.


Partie 3 : Inconvénients et impacts sur des environnements de développement et de test

Le modèle a un prix, et il déplace deux pratiques : les tests applicatifs passent sur le code avant de générer l'image, et l'environnement de développement conteneurisé doit les installer hors des images.

L'image n'est plus autosuffisante

Installer les dépendances avant de construire l'image a une conséquence : elle ne se suffit plus à elle-même. Un docker build lancé hors CI produirait une image sans vendor/. Il faut donc prévoir une vérification pour la cible prod, comme expliqué dans la partie 4.

Ce compromis reste toutefois intéressant pour trois raisons :

  • Les identifiants des registres privés ne sont plus nécessaires dans l'image. Une image qui résout elle-même ses dépendances privées doit recevoir les secrets permettant d'y accéder.

  • L'image est plus légère et plus simple. Composer, git et les autres outils d'installation disparaissent de l'image de production. Cela réduit sa taille et limite les exécutables présents dans le conteneur.

  • Les dépendances sont installées en amont. Chaque modification ne nécessite plus de refaire les étapes d'installation dans l'image. En développement, un conteneur éphémère reçoit le secret le temps de l'installation, puis écrit vendor/ dans le répertoire du projet monté sur l'hôte. L'image de développement n'embarque donc pas le secret et peut être utilisée dans d'autres environnements, notamment pour les tests.

Quand ne pas l'appliquer

  • Des tiers construisent leur propre image depuis le dépôt, dont toutes les dépendances sont publiques ; typiquement un projet open source. Sans CI commune à qui confier la résolution, l'autosuffisance du Dockerfile redevient une exigence.

  • Le projet n'a pas de tests en CI. L'étape deps n'a plus de résolution à partager.

  • L'artefact est trop gros pour être transféré. Au-delà de quelques centaines de mégaoctets, et malgré les leviers de la partie 2, le transfert coûte plus cher que la reconstruction depuis un cache. Le choix doit être mesuré.

  • Le répertoire de dépendances est global à la machine et non local au projet — ~/.m2, ~/.cargo. Ce n'est plus un artefact mais un cache, et le pattern perd son participant central (partie 5).

Les tests passent avant l'image

C'est la dérive dans un environnement de tests : ils n'attendent plus l'image et démarrent dès que deps a publié le jeu de développement. Une suite en échec arrête le pipeline avant tout build.

En contrepartie, les tests ne portent pas sur l'image : ils portent sur le code et sur la même résolution, augmentée des dépendances de développement. Pour que leur verdict vaille pour la production, l'image du job de test doit avoir la même version de PHP et les mêmes extensions que l'image de production : un test en PHP 8.3 ne dit rien d'une production en 8.5. L'image officielle php:8.5-cli partage la version, mais pas les extensions que la cible base du Dockerfile ajoute ; le job de test de la partie 4 les installe donc à son tour.

Le développement installe hors de l'image

C'est la dérive côté développement : l'image dev n'embarque que l'outillage d'installation, et les dépendances s'installent depuis un conteneur éphémère, dans le répertoire du projet monté par un montage lié (bind mount).

# docker-compose.yml
services:
  api:
    build:
      context: .
      target: dev
    user: "${UID:-1000}:${GID:-1000}"
    volumes:
      - ./:/var/www/app
    environment:
      APP_ENV: dev
    ports:
      - "80:80"
docker compose run --rm -e COMPOSER_AUTH api composer install

Cette ligne répond à trois besoins :

  • Le secret reste temporaire. -e COMPOSER_AUTH, sans valeur, transmet au conteneur éphémère la variable du shell hôte contenant le jeton personnel. Elle n'est intégrée ni à l'image contenant l'application, ni au fichier Compose, ni aux arguments de la commande.

  • Les dépendances sont vérifiées dans le bon environnement. La cible dev hérite de la même image base que prod. Composer utilise donc la version de PHP et les extensions prévues pour la production, plutôt que celles installées sur l'hôte.

  • vendor/ reste accessible sur l'hôte. Le montage du répertoire permet à l'IDE d'accéder aux dépendances pour l'autocomplétion.

Installation en développement : le shell transmet COMPOSER_AUTH au seul conteneur éphémère, qui écrit vendor/ dans le répertoire monté puis disparaît avec le jeton ; le conteneur du service n'a aucun secret

Un piège : sans la directive user:, le conteneur écrit en root, et vendor/ devient sur l'hôte un répertoire que votre utilisateur ne peut plus supprimer. D'où user: "${UID}:${GID}", avec ces deux variables exportées dans le shell ou posées dans un .env.


Partie 4 : Pipeline d'exemple

Le projet d'exemple, une API PHP/Composer, consomme acme/storage-client, bibliothèque privée de la même forge GitLab, et pousse son image dans le registre du projet.

Prérequis côté forge

Dans le composer.json du projet consommateur :

{
  "repositories": [
    {
      "type": "vcs",
      "url": "https://gitlab.internal.example/acme/storage-client.git",
      "only": ["acme/storage-client"]
    }
  ],
  "require": {
    "php": "^8.5",
    "acme/storage-client": "^1.2"
  },
  "config": {
    "platform": {
      "php": "8.5.0"
    }
  }
}

Le champ only limite ce dépôt aux paquets qu'il doit fournir. Sans lui, Composer peut y chercher toutes les dépendances du projet, avec un risque de confusion de dépendances : un paquet homonyme provenant d'un dépôt prioritaire pourrait remplacer le vôtre. Sur une forge GitLab autohébergée, ajoutez aussi votre domaine à gitlab-domains pour que Composer utilise son API plutôt qu'un accès Git générique.

config.platform fixe la version de PHP utilisée pour résoudre les dépendances, indépendamment de celle embarquée dans composer:2. Sans ce réglage, une mise à jour de l'image amont peut faire échouer le pipeline sans aucune modification de votre code. À l'inverse, --ignore-platform-reqs contourne toutes les contraintes de plateforme, extensions comprises, et masque potentiellement des incompatibilités.

Enfin, le CI_JOB_TOKEN nécessite une autorisation explicite : le projet consommateur doit figurer dans l'allowlist du projet qui héberge la dépendance, dans Settings → CI/CD → Job token permissions → Authorized groups and projects. Sinon, l'accès peut échouer avec une simple erreur 404.

Le jeton du job deps n'ouvre le dépôt privé acme/storage-client que si la liste d'autorisation, réglée sur la dépendance, inscrit le projet ; un projet absent de la liste reçoit un 404

Trois précisions :

  • le nom de ce réglage a changé plusieurs fois entre GitLab 15.9 et 17.6 : fiez-vous à la documentation de votre version ;

  • la liste ne peut plus être désactivée : l'option « All groups and projects » a disparu de GitLab.com en avril 2025, avant la 18.0, cassant les pipelines qui en dépendaient sans qu'une ligne de .gitlab-ci.yml ait bougé ;

  • elle ne s'applique qu'aux projets privés : une dépendance en visibilité internal ou public s'en passe, jusqu'au jour où son dépôt devient privé.

Le .gitlab-ci.yml

stages: [deps, test, build]

variables:
  IMAGE: $CI_REGISTRY_IMAGE/api

.composer-auth: &composer-auth
  - export COMPOSER_AUTH="{\"http-basic\":{\"$CI_SERVER_HOST\":{\"username\":\"gitlab-ci-token\",\"password\":\"$CI_JOB_TOKEN\"}}}"

deps:
  stage: deps
  image: composer:2
  variables:
    COMPOSER_CACHE_DIR: $CI_PROJECT_DIR/.composer-cache
    ARTIFACT_COMPRESSION_LEVEL: fastest   # les archives sont déjà compressées (partie 2)
  cache:
    key:
      files: [composer.lock]
    paths: [.composer-cache/]
  before_script:
    - *composer-auth
  script:
    # Jeu complet, pour les tests
    - composer install --no-interaction --no-progress
    - tar czf vendor-dev.tar.gz vendor
    # Jeu de production, sans dépendances de développement
    - composer install --no-dev --optimize-autoloader --classmap-authoritative --no-interaction --no-progress
    - tar czf vendor.tar.gz vendor
  artifacts:
    paths: [vendor.tar.gz, vendor-dev.tar.gz]
    expire_in: 1 month

test:
  stage: test
  image: php:8.5-cli
  needs: [deps]
  before_script:
    # Les extensions de la cible base du Dockerfile, absentes de l'image officielle
    - apt-get update && apt-get install -y --no-install-recommends libzip-dev libicu-dev
    - docker-php-ext-install zip intl
  script:
    - tar xzf vendor-dev.tar.gz
    - test -f vendor/autoload.php || { echo "vendor absent — artefact expiré ?"; exit 1; }
    - vendor/bin/phpunit --colors=never --log-junit=junit.xml
  artifacts:
    reports:
      junit: junit.xml

build-image:
  stage: build
  image: docker:24.0
  services: [docker:24.0-dind]
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_TLS_VERIFY: 1
    DOCKER_CERT_PATH: "/certs/client"
  needs: [deps, test]
  script:
    - tar xzf vendor.tar.gz
    - test -f vendor/autoload.php || { echo "vendor absent — artefact expiré ?"; exit 1; }
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker build --target prod
        --label org.opencontainers.image.revision="$CI_COMMIT_SHA"
        --label org.opencontainers.image.source="$CI_PROJECT_URL"
        --label org.opencontainers.image.created="$CI_JOB_STARTED_AT"
        -t "$IMAGE:$CI_COMMIT_SHORT_SHA" .
    - docker push "$IMAGE:$CI_COMMIT_SHORT_SHA"

deps est le seul job qui s'authentifie auprès de la forge. COMPOSER_AUTH fournit directement les identifiants au format JSON, sans créer de fichier auth.json susceptible de se retrouver dans un artefact. $CI_SERVER_HOST évite également de figer le nom de la forge.

Les deux exécutions de composer install produisent les jeux de dépendances de la partie 1. --optimize-autoloader génère une classmap pour accélérer le chargement des classes ; --classmap-authoritative désactive en plus toute recherche de classes en dehors de cette table. Un seul job suffit pour produire les deux jeux, chacun conservant son propre cycle de vie (partie 2).

Deux pièges côté cache :

  • COMPOSER_CACHE_DIR doit pointer dans $CI_PROJECT_DIR, car GitLab ne met en cache que les chemins relatifs au projet. Le répertoire par défaut de Composer se trouve ailleurs.

  • cache:key:files attend des fichiers explicitement nommés. Depuis GitLab 18.4, un motif générique peut faire retomber la clé sur default, avec un cache partagé entre les pipelines.

Pourquoi utiliser tar ? vendor/bin/ contient des liens symboliques que certaines versions du runner ont mal archivés : transformés en fichiers texte (gitlab-runner#4201) ou omis depuis la version 18.1 (gitlab-runner#39033). Le pipeline reste alors vert, mais l'image échoue à l'exécution. Une archive tar construite manuellement préserve les liens, les permissions et les bits d'exécution.

expire_in: 1 month doit correspondre à la fenêtre de retour arrière nécessaire, pas à un objectif arbitraire d'économie de stockage.

test démarre dès la fin de deps grâce à needs et récupère ses artefacts. Le before_script aligne les extensions PHP sur celles de la production (partie 3). Vérifier la présence de vendor/autoload.php permet de détecter immédiatement un artefact manquant ou expiré.

build-image utilise Docker-in-Docker avec TLS : le runner doit donc être configuré en privileged = true. Sans accès à cette configuration, un socket Docker partagé constitue une alternative. Enfin, --password-stdin évite d'exposer le mot de passe dans les arguments du processus, tandis que les labels OCI relient l'image au commit qui l'a produite.

Le Dockerfile

# syntax=docker/dockerfile:1

##############################################################################
# base — PHP, extensions, configuration commune
##############################################################################
FROM php:8.5-fpm AS base

RUN apt-get update && apt-get install -y --no-install-recommends \
        libzip-dev libicu-dev \
 && docker-php-ext-install zip intl \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /var/www/app

##############################################################################
# dev — l'outillage d'installation, sans dépendances ni secret
##############################################################################
FROM base AS dev

COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
ENV COMPOSER_NO_INTERACTION=1

COPY docker/php/zz-dev.ini /usr/local/etc/php/conf.d/zz-dev.ini

##############################################################################
# prod — consomme l'artefact produit par la CI
##############################################################################
FROM base AS prod

COPY docker/php/zz-prod.ini /usr/local/etc/php/conf.d/zz-prod.ini

# Code en propriété root : le processus d'exécution ne peut pas le réécrire.
COPY --chown=root:root ./ /var/www/app

# Sans cette garde, un build lancé hors CI produit une image sans dépendances,
# et rien n'échoue avant la production.
RUN test -f vendor/autoload.php \
 || { echo "vendor/ absent : cette cible consomme l'artefact du job deps"; exit 1; }

# Seuls les répertoires réellement variables appartiennent à www-data.
RUN mkdir -p var/cache var/log \
 && chown -R www-data:www-data var \
 && chmod -R 750 var

USER www-data

La cible prod n'a ni Composer, ni secret, ni RUN d'installation : elle copie le vendor/ que la CI a déposé dans le contexte, et sa garde fait échouer tout build lancé sans lui. La cible dev n'embarque que l'outillage d'installation ; les dépendances, et le secret qui les ouvre, lui arrivent au moment de l'installation (partie 3).

Les cibles du Dockerfile : dev et prod partent de base ; docker compose construit dev, le job build-image dépose vendor/ dans le contexte de build puis construit prod, qui le copie

Le .dockerignore

# vendor/ n'est PAS exclu : la cible prod copie celui que la CI a déposé.
.git
.gitlab-ci.yml
.env
.env.*
auth.json
tests/
docker-compose*.yml
*.tar
*.tar.gz

La première ligne compte autant que les suivantes : beaucoup de .dockerignore excluent vendor/, et le commentaire dit pourquoi celui-ci ne le fait pas, avant qu'un collègue ne le « corrige » de bonne foi.


Partie 5 : Déclinaisons dans les autres langages

L'architecture — un job de résolution qui détient le secret, un artefact, des jobs consommateurs — se transpose telle quelle. Les détails, non.

PHP / Composer Node / npm Java / Maven Rust / Cargo
Fichier verrou des versions de dépendances composer.lock package-lock.json pom.xml (versions figées) Cargo.lock
Utilitaire d'installation composer install npm ci mvn -o après dependency:go-offline cargo build --locked
Authentification COMPOSER_AUTH .npmrc avec substitution de variable settings.xml, bloc <server> .cargo/config.toml, credential provider
Répertoire produit vendor/ node_modules/ ~/.m2/repository target/ + ~/.cargo
Clé de cache utilisable composer.lock package-lock.json pom.xml Cargo.lock

Ce qui change selon l'écosystème

  • Le verrouillage des versions de plateforme. Avec Composer et npm, la résolution des dépendances dépend notamment de la version de l'interpréteur disponible dans l'image. Il faut donc tenir compte de la plateforme cible dès l'installation. Maven fonctionne différemment : il résout des dépendances Java sous forme de bytecode, ce qui ne pose pas le problème de la même manière. Avec Cargo, les dépendances sont compilées pour une cible donnée : la question se déplace vers la chaîne de compilation.

  • Le piège des liens symboliques. Il concerne surtout vendor/bin et node_modules/.bin, où les liens symboliques peuvent poser problème lors du transfert des dépendances. Ce cas ne se retrouve pas sous cette forme dans un dépôt Maven local.

  • Le rôle des répertoires de dépendances. vendor/ et node_modules/ contiennent les dépendances propres au projet. Ils peuvent donc être transmis d'un job à l'autre sous forme d'artefacts. À l'inverse, ~/.m2 et ~/.cargo sont des caches globaux, partagés entre les projets d'une même machine. Ils ont donc davantage leur place dans cache: que dans artifacts:. Ce n'est pas qu'une différence de configuration YAML : cela change la manière dont les dépendances circulent dans le pipeline.

Vous pouvez repartir du même squelette, mais ne vous contentez pas de remplacer les commandes par celles de votre écosystème. Reprenez le raisonnement de la partie 4 et adaptez l'architecture du pipeline aux spécificités de votre chaîne de dépendances et de compilation.


Conclusion

Le principe repose sur un changement assez simple : confier la résolution des dépendances à une seule étape de la CI, puis transmettre le résultat aux jobs suivants plutôt que de leur redonner accès aux identifiants. Le secret utilisé pour accéder à la forge reste ainsi cantonné à cette étape. Les tests et la construction de l'image s'appuient sur les mêmes dépendances, et le Dockerfile de production se limite à copier les fichiers nécessaires.

Cette approche a quelques contreparties. L'image Docker ne se suffit plus à elle-même : elle dépend d'un environnement préparé en amont. Mais cet argument reste discutable, notamment au regard des optimisations que cette même séparation apporte. Les artefacts occupent également de l'espace de stockage sur la forge. Il faut donc mesurer leur volume avant de décider comment les gérer.

Les difficultés rencontrées en chemin rappellent toutefois un point essentiel : une clé de cache qui retombe sur default ou un lien symbolique perdu lors de l'archivage peuvent suffire à fausser le résultat, sans nécessairement faire échouer le pipeline.

Le vrai problème, c'est qu'un pipeline vert ne garantit pas un build fiable. Pour s'en assurer, chaque hypothèse doit être vérifiée explicitement, au moment où elle intervient dans la chaîne de build.


Références