Aller au contenu
CI/CD & Automatisation medium

Pipelines dynamiques GitLab CI/CD

23 min de lecture

logo gitlab

Votre pipeline doit créer des jobs différents selon le contexte ? Les pipelines dynamiques permettent de générer la configuration CI/CD à la volée. Un job crée le YAML, un autre l’exécute.

À la fin de ce module, vous saurez :

  • Générer un pipeline YAML : script qui crée le fichier de config
  • Déclencher avec un artefact : include: artifact pour l’enfant
  • Détecter les changements : git diff pour générer les jobs nécessaires
  • Utiliser une config externe : YAML, JSON ou API comme source
  • Valider le YAML généré : éviter les erreurs de syntaxe
  • Gérer le cas vide : que faire quand aucun job n’est nécessaire

Avant de continuer, assurez-vous de maîtriser :

Pipeline classique : tout est écrit à l’avance dans .gitlab-ci.yml. GitLab lit le fichier et exécute les jobs définis.

Pipeline dynamique : un job génère un fichier YAML, puis GitLab exécute ce fichier généré.

┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ generate │───▶│ child.yml │───▶│ pipeline enfant │
│ (script) │ │ (artefact) │ │ (exécution) │
└──────────────┘ └──────────────┘ └──────────────────┘
① GÉNÈRE ② STOCKE ③ EXÉCUTE

Étape par étape :

  1. Générer : Un job (shell, Python, etc.) analyse le contexte et écrit un fichier YAML valide
  2. Stocker : Ce fichier est sauvegardé comme artefact du job
  3. Exécuter : Un job trigger utilise include: artifact: pour lancer ce pipeline comme enfant

dynamic pipeline

BesoinSolution statique (rules)Solution dynamique
3 clients fixesparallel:matrixOverkill
Clients lus depuis une base❌ Impossible✅ Script qui lit la DB
Jobs selon fichiers modifiés⚠️ changes: (limité)git diff + génération
1 job par ligne d’un fichier❌ Impossible✅ Script qui parse le fichier

Un pipeline dynamique nécessite deux jobs :

  1. Le générateur : crée le fichier YAML
  2. Le trigger : exécute le fichier généré
stages:
- generate # 1️⃣ Étape : générer le YAML
- trigger # 2️⃣ Étape : exécuter le YAML généré
# 🛠️ Job qui GÉNÈRE le pipeline
generate_pipeline:
stage: generate
image: alpine:3.18 # Image légère pour le scripting
script:
- | # Script multi-lignes
cat > child-pipeline.yml << 'EOF' # Écrit dans un fichier
stages:
- test
test_job:
stage: test
script:
- echo "Generated job!"
EOF
artifacts:
paths:
- child-pipeline.yml # 👈 CRUCIAL : sauvegarder le fichier généré
# 🚀 Job qui EXÉCUTE le pipeline généré
trigger_child:
stage: trigger
trigger: # Mot-clé spécial : ce n'est pas un job normal
include:
- artifact: child-pipeline.yml # 👈 Le fichier généré
job: generate_pipeline # 👈 Le job qui l'a créé
strategy: depend # Attend la fin du pipeline enfant

Décortiquons la magie :

  1. cat > child-pipeline.yml << 'EOF' — Crée un fichier avec le contenu entre EOF et EOF
  2. artifacts: paths: — Sauvegarde le fichier pour qu’il survive au job
  3. include: artifact: — Différent de include: local: ! Ici on référence un artefact, pas un fichier du repo
  4. job: generate_pipeline — Indique quel job a produit l’artefact

Vous avez une application SaaS avec plusieurs clients, chacun ayant sa propre configuration. Vous devez tester l’application pour chaque client avant de déployer.

Pourquoi pas parallel:matrix ?

  • La liste des clients peut changer (nouveaux clients, clients supprimés)
  • La liste vient d’un fichier de config ou d’une API, pas du YAML
  • Vous avez besoin de logique conditionnelle (certains clients ont des tests spéciaux)

Idée : Un script lit la liste des clients et génère un job par client.

.gitlab-ci.yml
stages:
- generate
- test
generate:
stage: generate
image: alpine:3.18
script:
- |
# 📋 Liste des clients (pourrait venir d'un fichier, d'une API...)
CLIENTS="client1 client2 client3"
# 🛠️ Création du fichier YAML
echo "stages:" > test-pipeline.yml
echo " - test" >> test-pipeline.yml
echo "" >> test-pipeline.yml
# 🔁 Boucle : un job par client
for client in $CLIENTS; do
cat >> test-pipeline.yml << EOF
test_${client}:
stage: test
script:
- echo "Testing for ${client}"
- ./run-tests.sh --client ${client}
variables:
CLIENT_NAME: "${client}"
EOF
done
# 👀 Affiche le résultat (utile pour debug)
echo "=== Pipeline généré ==="
cat test-pipeline.yml
artifacts:
paths:
- test-pipeline.yml
run_tests:
stage: test
trigger:
include:
- artifact: test-pipeline.yml
job: generate
strategy: depend

Le fichier test-pipeline.yml contient :

stages:
- test
test_client1:
stage: test
script:
- echo "Testing for client1"
- ./run-tests.sh --client client1
variables:
CLIENT_NAME: "client1"
test_client2:
stage: test
script:
- echo "Testing for client2"
- ./run-tests.sh --client client2
variables:
CLIENT_NAME: "client2"
test_client3:
stage: test
script:
- echo "Testing for client3"
- ./run-tests.sh --client client3
variables:
CLIENT_NAME: "client3"

Résultat dans GitLab : 3 jobs s’exécutent en parallèle, chacun testant un client différent. Si vous ajoutez client4 à la liste, un 4ème job apparaît automatiquement.

Vous avez un monorepo avec plusieurs services :

monorepo/
├── frontend/
│ └── Dockerfile
├── backend/
│ └── Dockerfile
├── api/
│ └── Dockerfile
└── docs/
└── (pas de Dockerfile)

Objectif : Ne builder que les services modifiés. Si seul frontend/ change, ne pas rebuilder backend/ ni api/.

Pourquoi pas rules: changes: ?

changes: fonctionne, mais vous devez lister tous les services manuellement. Si vous ajoutez un service, vous devez modifier le YAML.

Avec la génération dynamique, le script découvre automatiquement les services modifiés.

Détectez les services modifiés et générez leurs jobs :

stages:
- detect
- generate
- build
# 🔍 Étape 1 : Détecter ce qui a changé
detect_changes:
stage: detect
image: alpine:3.18
before_script:
- apk add --no-cache git # Git n'est pas installé dans alpine
script:
- |
# Cas spécial : premier push (BEFORE_SHA = 0000...)
if [ "$CI_COMMIT_BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "🆕 Premier push : considérer tous les dossiers"
CHANGED=$(git ls-tree -r --name-only HEAD | cut -d'/' -f1 | sort -u | tr '\n' ' ')
else
echo "🔄 Comparaison $CI_COMMIT_BEFORE_SHA → $CI_COMMIT_SHA"
CHANGED=$(git diff --name-only "$CI_COMMIT_BEFORE_SHA" "$CI_COMMIT_SHA" | cut -d'/' -f1 | sort -u | tr '\n' ' ')
fi
echo "📁 Dossiers modifiés : $CHANGED"
# Stocker le résultat pour les jobs suivants
if [ -z "$CHANGED" ]; then
echo "PIPELINE_EMPTY=true" >> detect.env
else
echo "PIPELINE_EMPTY=false" >> detect.env
echo "SERVICES=$CHANGED" >> detect.env
fi
artifacts:
reports:
dotenv: detect.env # 👈 Variables disponibles pour les jobs suivants
# 🛠️ Étape 2 : Générer le pipeline
generate_pipeline:
stage: generate
image: alpine:3.18
needs: ["detect_changes"]
rules:
- if: '$PIPELINE_EMPTY == "false"' # Ne génère que si quelque chose a changé
script:
- |
echo "stages:" > build-pipeline.yml
echo " - build" >> build-pipeline.yml
for service in $SERVICES; do
# Vérifier que c'est un service avec Dockerfile
if [ -f "${service}/Dockerfile" ]; then
echo "✅ Génération du job pour ${service}"
cat >> build-pipeline.yml << EOF
build_${service}:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- cd ${service}
- docker build -t ${service}:\$CI_COMMIT_SHA .
EOF
else
echo "⏭️ Ignoré ${service} (pas de Dockerfile)"
fi
done
echo "=== Pipeline généré ==="
cat build-pipeline.yml
artifacts:
paths:
- build-pipeline.yml
# 🚀 Étape 3 : Exécuter le pipeline généré
trigger_builds:
stage: build
needs: ["generate_pipeline"]
trigger:
include:
- artifact: build-pipeline.yml
job: generate_pipeline
strategy: depend
rules:
- if: '$PIPELINE_EMPTY == "false"'

Comment ça marche :

  1. detect_changes trouve les dossiers modifiés (frontend backend)
  2. generate_pipeline crée un job Docker build pour chaque dossier qui a un Dockerfile
  3. trigger_builds exécute le pipeline généré

Votre équipe veut définir les environnements de déploiement sans toucher au CI/CD. Ils éditent un fichier YAML simple, et le pipeline s’adapte automatiquement.

Un fichier YAML lisible par les non-experts CI/CD :

config/environments.yml
environments:
- name: staging
url: https://staging.example.com
auto_deploy: true # Déploiement automatique
- name: production
url: https://example.com
auto_deploy: false # Manuel seulement
approval_required: true # Validation obligatoire

Un script qui lit cette config et génère le pipeline :

scripts/generate-deploy-pipeline.py
#!/usr/bin/env python3
import yaml
# 📖 Lire la configuration
with open('config/environments.yml') as f:
config = yaml.safe_load(f)
# 🛠️ Construire le pipeline
pipeline = {
'stages': ['deploy'],
}
for env in config['environments']:
job_name = f"deploy_{env['name']}"
# Job de base
job = {
'stage': 'deploy',
'script': [
f"./deploy.sh {env['name']}"
],
'environment': {
'name': env['name'],
'url': env['url']
}
}
# 🔒 Si pas auto_deploy : job manuel
if not env.get('auto_deploy', True):
job['when'] = 'manual'
# ✅ Si approval_required : échec interdit
if env.get('approval_required'):
job['allow_failure'] = False
pipeline[job_name] = job
# 💾 Écrire le pipeline généré
with open('deploy-pipeline.yml', 'w') as f:
yaml.dump(pipeline, f, default_flow_style=False)
print("✅ Pipeline généré : deploy-pipeline.yml")
stages:
- generate
- deploy
generate_deploy:
stage: generate
image: python:3.11
script:
- pip install pyyaml
- python scripts/generate-deploy-pipeline.py
- cat deploy-pipeline.yml # Debug : voir le résultat
artifacts:
paths:
- deploy-pipeline.yml
deploy:
stage: deploy
trigger:
include:
- artifact: deploy-pipeline.yml
job: generate_deploy
strategy: depend

Résultat : L’équipe peut ajouter un environnement en éditant config/environments.yml. Le pipeline s’adapte sans modifier .gitlab-ci.yml.

Pour des cas complexes, utilisez des templates Jinja2 avec Ansible :

templates/test-pipeline.yml.j2
stages:
- test
{% for host in groups['all'] %}
test_{{ host }}:
stage: test
script:
- pytest --host {{ host }}
variables:
TARGET_HOST: "{{ host }}"
HOST_NAME: "{{ hostvars[host]['name'] }}"
{% endfor %}
generate-pipeline.yml
---
- hosts: localhost
gather_facts: false
tasks:
- name: Generate pipeline from template
template:
src: templates/test-pipeline.yml.j2
dest: ./generated-pipeline.yml
generate:
stage: generate
image: ansible/ansible-runner
script:
- ansible-playbook -i inventory generate-pipeline.yml
artifacts:
paths:
- generated-pipeline.yml
run_tests:
stage: test
trigger:
include:
- artifact: generated-pipeline.yml
job: generate
strategy: depend

Une erreur de syntaxe dans le YAML généré = pipeline qui échoue. Validez avant de sauvegarder :

generate:
script:
- ./generate.sh > pipeline.yml
# ✅ Valide la syntaxe YAML
- python -c "import yaml; yaml.safe_load(open('pipeline.yml'))"
artifacts:
paths:
- pipeline.yml

Quand quelque chose ne marche pas, la première question est “qu’est-ce qui a été généré ?” :

generate:
script:
- ./generate.sh > pipeline.yml
- echo "=== Pipeline généré ===" && cat pipeline.yml

Si votre script ne génère aucun job (rien n’a changé), GitLab refuse le pipeline vide. Deux solutions :

Option A : Générer un job “noop” (recommandé)

generate:
script:
- |
if [ -z "$JOBS_TO_CREATE" ]; then
# Pas de jobs → pipeline minimal
echo 'noop: { stage: test, script: ["echo Rien \u00e0 faire"] }' > pipeline.yml
else
./generate.sh > pipeline.yml
fi

Option B : Conditionner le trigger avec dotenv

generate:
script:
- |
if ./generate.sh > pipeline.yml 2>/dev/null; then
echo "HAS_JOBS=true" >> build.env
else
echo "HAS_JOBS=false" >> build.env
fi
artifacts:
reports:
dotenv: build.env
paths:
- pipeline.yml
trigger:
trigger:
include:
- artifact: pipeline.yml
job: generate
rules:
- if: '$HAS_JOBS == "true"' # Ne trigger que s'il y a des jobs
  1. Utilisez des templates réutilisables dans le pipeline généré.

Symptôme : Le trigger échoue avec une erreur de syntaxe.

Cause : Le script a généré du YAML invalide (indentation, caractères spéciaux…).

Solution :

  1. Ajoutez cat pipeline.yml pour voir le contenu
  2. Validez avec un parser : python -c "import yaml; yaml.safe_load(open('pipeline.yml'))"
  3. Attention aux variables avec : ou # (les entourer de guillemets)

Symptôme : Le trigger ne trouve pas le fichier.

Cause : Le job generate a échoué, ou le fichier n’est pas dans artifacts: paths:.

Solution :

  1. Vérifiez que generate est passé en vert
  2. Vérifiez que le nom de fichier correspond exactement
  3. Vérifiez artifacts: paths: dans le job générateur

Symptôme : Le pipeline enfant refuse de se créer (“pipeline has no jobs”).

Cause : Le YAML généré ne contient aucun job.

Solution : Générez un job “noop” par défaut (voir Bonnes pratiques).

Symptôme : exists: ignore le fichier généré.

Cause : exists: vérifie les fichiers dans le repo, pas les artefacts.

Solution : Utilisez une variable dotenv (HAS_JOBS, PIPELINE_EMPTY).

Symptôme : git diff retourne rien sur un pipeline déclenché manuellement.

Cause : CI_COMMIT_BEFORE_SHA n’existe pas ou vaut 0000....

Solution : Prévoyez un fallback :

Fenêtre de terminal
if [ "$CI_COMMIT_BEFORE_SHA" = "0000000000000000000000000000000000000000" ] || [ -z "$CI_COMMIT_BEFORE_SHA" ]; then
# Pas de commit précédent : tout considérer comme modifié
CHANGED=$(git ls-tree -r --name-only HEAD | cut -d'/' -f1 | sort -u)
else
CHANGED=$(git diff --name-only "$CI_COMMIT_BEFORE_SHA" "$CI_COMMIT_SHA" | cut -d'/' -f1 | sort -u)
fi
  1. Génération : un job crée un fichier YAML
  2. Artefact : le fichier est sauvegardé
  3. Trigger : include: artifact: exécute le pipeline
  4. strategy: depend : le parent attend la fin
  5. Validez toujours le YAML généré
  6. Limites : 1 Mo max, 3 fichiers include par child pipeline
  7. Piège exists : ne teste pas les artefacts, utilisez des variables dotenv
  8. Piège HEAD~1 : préférez CI_COMMIT_BEFORE_SHACI_COMMIT_SHA

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.