DevOps, CI/CD et conteneurs
Pipelines Jenkins et Jenkinsfile : la syntaxe déclarative en profondeur
Intermédiaire9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Un Jenkinsfile transforme votre build en code versionné et relu, qui vit avec le projet. Ce guide parcourt la syntaxe des pipelines déclaratifs section par section — choix de l'agent, environnement et paramètres, stages conditionnels et parallèles, retries et timeouts, conditions post — puis aborde les shared libraries, l'échappatoire du pipeline scripté et la façon de déboguer un pipeline sans vingt commits d'essai.
Ce que vous allez apprendre
- Écrire un pipeline déclaratif avec les blocs agent, stages, steps, options, environment et post
- Choisir où s'exécute chaque stage, y compris avec des agents Docker propres à un stage
- Paramétrer les builds et injecter des credentials sans exposer de secrets dans le log
- Exécuter des stages de façon conditionnelle et en parallèle, et appliquer timeouts, retries et limites de concurrence
- Extraire la logique répétée dans une shared library et savoir quand le Groovy scripté se justifie
Un pipeline défini dans l'interface de Jenkins est invisible pour les personnes qu'il concerne. Personne ne le relit, personne ne peut le comparer à sa version du mois dernier, et il disparaît avec le controller. Déplacer cette définition dans un Jenkinsfile à la racine du dépôt règle tout cela d'un coup : le build fait partie du code, ses modifications sont relues en même temps que le code qu'elles touchent, chaque branche embarque le pipeline qui lui correspond, et un controller reconstruit récupère la définition directement depuis le dépôt.
Ce guide suppose que vous disposez d'un controller fonctionnel, avec agents et credentials — le guide d'installation de Jenkins couvre ce point — et se concentre sur le langage. La syntaxe déclarative occupe l'essentiel du propos, parce que c'est elle que vous devriez écrire ; le Groovy scripté n'apparaît qu'à la fin, là où il a sa place.
La structure d'un pipeline déclaratif
Tout pipeline déclaratif est un bloc pipeline contenant un petit nombre de sections nommées, selon une grammaire fixe que Jenkins valide avant le démarrage du build.
pipeline {
agent { label 'linux && jdk21' }
options {
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
disableConcurrentBuilds(abortPrevious: true)
timestamps()
}
environment {
MAVEN_OPTS = '-Xmx1g -Djava.awt.headless=true'
APP_VERSION = "1.4.${env.BUILD_NUMBER}"
}
stages {
stage('Build') {
steps {
sh 'mvn -B -ntp clean compile'
}
}
stage('Test') {
steps {
sh 'mvn -B -ntp verify'
}
}
}
post {
always { junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true }
failure { mail to: '[email protected]', subject: "Build ${env.JOB_NAME} #${env.BUILD_NUMBER} failed", body: env.BUILD_URL }
cleanup { cleanWs() }
}
}
Lisez-le de haut en bas. agent décide où le travail s'exécute. options configure le job lui-même : durée maximale, quantité d'historique conservée, possibilité ou non de lancer deux builds simultanément. environment définit des variables visibles par tous les steps. stages contient la liste ordonnée des blocs stage, chacun avec ses steps. post s'exécute à la fin quel que soit le résultat, avec des sous-blocs sélectionnés selon celui-ci.
La grammaire est stricte, et c'est voulu. Les sections doivent apparaître dans cet ordre, steps ne peut contenir que des steps, et une faute de frappe provoque une erreur d'analyse explicite avant même qu'un agent soit alloué. C'est cette rigueur qui rend les pipelines déclaratifs encore lisibles un an plus tard.
Choisir où s'exécute un stage
agent apparaît au niveau supérieur et, si besoin, à nouveau dans des stages individuels. Les formes courantes :
agent any // any agent with a free executor
agent none // allocate nothing at the top level
agent { label 'docker && linux' } // a label expression
agent { node { label 'build'; customWorkspace '/mnt/fast/ws' } }
agent { docker { image 'maven:3.9-eclipse-temurin-21'; args '-v $HOME/.m2:/root/.m2' } }
agent { dockerfile { dir 'ci'; filename 'Dockerfile.build' } }
agent { kubernetes { yamlFile 'ci/pod.yaml' } }
C'est l'agent docker qui change vraiment la donne au quotidien. Le stage s'exécute dans un conteneur démarré à partir de cette image, sur un agent où Docker est disponible : la toolchain vient donc de l'image et non de ce qui se trouve installé sur la machine. Deux projets peuvent utiliser des versions différentes du JDK sans aucune coordination, et reproduire un échec de CI en local revient à lancer la même image.
agent none au niveau supérieur, combiné à des agents par stage, est le modèle à suivre pour les pipelines dont les stages ont besoin d'environnements différents. Il évite aussi de monopoliser un executor pendant l'attente d'une approbation :
pipeline {
agent none
stages {
stage('Build') {
agent { docker { image 'maven:3.9-eclipse-temurin-21' } }
steps { sh 'mvn -B -ntp package' }
}
stage('Frontend') {
agent { docker { image 'node:22-alpine' } }
steps { sh 'npm ci && npm run build' }
}
}
}
Attention : chaque agent a son propre workspace. Les fichiers produits dans un stage ne sont pas visibles dans le suivant, sauf si vous les transportez avec stash/unstash ou les archivez.
Environnement, paramètres et credentials
environment définit des variables au niveau du pipeline ou d'un stage. Les entrées d'un stage l'emportent sur celles du pipeline, et les unes comme les autres sont des variables d'environnement shell ordinaires dans sh.
Jenkins injecte lui-même un grand nombre de variables : BUILD_NUMBER, BUILD_URL, JOB_NAME, WORKSPACE, BRANCH_NAME et CHANGE_ID dans les jobs multibranch, plus GIT_COMMIT et GIT_BRANCH fournies par le plugin Git. Référencez-les sous la forme env.BUILD_NUMBER en Groovy et $BUILD_NUMBER dans un step shell.
Les paramètres rendent un job interactif. Déclarez-les dans le pipeline ; à la première exécution, Jenkins les enregistre et le job gagne un bouton « Build with Parameters ».
parameters {
choice(name: 'TARGET_ENV', choices: ['dev', 'staging', 'prod'], description: 'Deployment target')
booleanParam(name: 'RUN_INTEGRATION', defaultValue: true, description: 'Run the slow suite')
string(name: 'RELEASE_VERSION', defaultValue: '', description: 'Leave blank for a snapshot build')
}
Lisez-les avec params.TARGET_ENV. Notez le problème de la poule et de l'œuf : le premier build après l'ajout d'un paramètre tourne avec les valeurs par défaut, car Jenkins ne découvre la définition qu'en exécutant le fichier.
Les credentials viennent du magasin de credentials, jamais du fichier. Il existe deux mécanismes. Le helper credentials() dans un bloc environment gère les types courants :
environment {
// secret text -> one variable
SONAR_TOKEN = credentials('sonar-token')
// username/password -> NEXUS, NEXUS_USR and NEXUS_PSW
NEXUS = credentials('nexus-deploy')
}
withCredentials est la forme générale, indispensable pour les clés SSH, les fichiers et les certificats :
steps {
withCredentials([
sshUserPrivateKey(credentialsId: 'deploy-key', keyFileVariable: 'SSH_KEY'),
file(credentialsId: 'kubeconfig-staging', variable: 'KUBECONFIG')
]) {
sh 'ssh -i "$SSH_KEY" deploy@app-01 /opt/deploy.sh'
sh 'kubectl get pods -n staging'
}
}
Mettez les variables entre guillemets dans le shell, gardez le bloc aussi court que possible, et souvenez-vous que le masquage ne cache que les correspondances exactes. Un secret transformé par un script — encodé en base64 ou en URL, découpé sur plusieurs lignes — apparaîtra en clair dans le log.
Stages conditionnels avec when
when décide si les steps d'un stage s'exécutent ou non. Par défaut, la condition est évaluée après l'entrée dans l'agent, ce qui compte pour les conditions qui inspectent le workspace.
stage('Deploy to staging') {
when {
allOf {
branch 'main'
not { changeRequest() }
expression { currentBuild.result == null }
}
}
steps { sh './deploy.sh staging' }
}
stage('Publish release') {
when {
beforeAgent true
tag pattern: 'v\\d+\\.\\d+\\.\\d+', comparator: 'REGEXP'
}
steps { sh 'mvn -B -ntp deploy -DskipTests' }
}
Parmi les conditions utiles : branch, tag, changeRequest(), environment name: 'X', value: 'y', changeset '**/*.sql' pour déclencher selon les chemins modifiés, et expression { } pour du Groovy arbitraire. Combinez-les avec allOf, anyOf et not. Ajoutez beforeAgent true dès que la condition n'a pas besoin d'un workspace : cela évite d'allouer un executor à un stage qui sera sauté, ce qui représente un gain réel sur un controller chargé.
Pour une validation humaine, input met le build en pause :
stage('Approve production') {
steps {
timeout(time: 2, unit: 'HOURS') {
input message: 'Deploy to production?', ok: 'Deploy', submitter: 'release-managers'
}
}
}
Placez input dans un stage avec agent none, sinon le build occupe un executor pendant toute la durée de l'attente. Le guide sur les pipelines de livraison examine quand une validation manuelle est réellement utile et quand elle relève du théâtre.
Exécuter le travail en parallèle
Un pipeline long l'est généralement parce que des tâches indépendantes s'exécutent l'une après l'autre. parallel règle ce problème.
stage('Verification') {
parallel {
stage('Unit tests') {
agent { label 'linux' }
steps { sh 'mvn -B -ntp test' }
}
stage('Integration tests') {
agent { label 'linux && docker' }
steps { sh 'mvn -B -ntp verify -Pintegration' }
}
stage('Static analysis') {
agent { label 'linux' }
steps { sh 'mvn -B -ntp checkstyle:check spotbugs:check' }
}
}
}
Chaque branche peut avoir son propre agent : les trois s'exécutent donc sur des machines différentes. failFast true dans le bloc parallel interrompt les branches sœurs dès que l'une échoue, ce qui économise du temps d'executor quand un échec est rédhibitoire.
matrix décline une même définition de stage sur toutes les combinaisons de valeurs de plusieurs axes — l'outil naturel pour tester plusieurs versions de JDK ou de base de données :
stage('Compatibility') {
matrix {
axes {
axis { name 'JDK'; values '17', '21' }
axis { name 'DB'; values 'postgres', 'mysql' }
}
excludes {
exclude { axis { name 'JDK'; values '17' }; axis { name 'DB'; values 'mysql' } }
}
agent { label 'linux' }
stages {
stage('Test') { steps { sh "./ci/test.sh --jdk ${JDK} --db ${DB}" } }
}
}
}
Quatre combinaisons moins une exclusion, cela fait trois cellules, exécutées en parallèle dans la limite des executors disponibles.
Robustesse : options, retry et timeout
Les builds échouent pour des raisons qui n'ont rien à voir avec le code, et un pipeline qui traite chaque échec transitoire comme un défaut apprend aux équipes à ignorer le rouge.
options {
timeout(time: 45, unit: 'MINUTES') // whole pipeline
retry(2) // retry the whole pipeline on failure
skipStagesAfterUnstable()
disableConcurrentBuilds(abortPrevious: true) // newer push cancels the older run
quietPeriod(15)
preserveStashes(buildCount: 5)
}
Au niveau des steps, retry et timeout enveloppent des actions individuelles, ce qui est bien plus précis que de relancer un build entier :
steps {
timeout(time: 5, unit: 'MINUTES') {
retry(3) { sh 'curl -fsSL https://artifacts.example.com/toolchain.tgz -o toolchain.tgz' }
}
}
Réservez les retries aux opérations réseau et d'infrastructure. Relancer un test instable masque l'instabilité au lieu de la corriger, et une suite dont l'instabilité est masquée finit toujours par échouer d'une façon que les retries ne peuvent plus rattraper.
Le bloc post
post est l'endroit où placer le reporting et le nettoyage, car il s'exécute que le build ait réussi, échoué ou été interrompu. Les conditions s'exécutent dans un ordre défini : always, puis changed, puis fixed, regression, aborted, failure, success, unstable, et enfin cleanup.
post {
always { archiveArtifacts artifacts: 'target/*.jar', fingerprint: true, allowEmptyArchive: true }
unstable { slackSend channel: '#ci', color: 'warning', message: "Tests failing on ${env.JOB_NAME}" }
fixed { slackSend channel: '#ci', color: 'good', message: "Back to green: ${env.JOB_NAME}" }
regression{ slackSend channel: '#ci', color: 'danger', message: "Broken by ${env.GIT_COMMIT}" }
cleanup { cleanWs() }
}
fixed et regression ne se déclenchent que sur un changement d'état, et c'est toute la différence entre un canal de notifications que l'on lit et un canal que l'on met en sourdine. cleanup s'exécute toujours en dernier et c'est le bon endroit pour cleanWs() ; supprimer le workspace dans always peut entrer en concurrence avec des steps qui en ont encore besoin.
Les blocs post peuvent aussi être rattachés à un stage particulier, auquel cas ils ne s'appliquent qu'à lui — pratique pour publier les rapports d'un stage juste à côté du code qui les a produits.
Shared libraries
Dès que trois dépôts contiennent les mêmes quarante lignes de pipeline, extrayez-les. Une shared library est un dépôt Git à la structure imposée :
(root)
├── vars/
│ ├── buildJavaService.groovy # a global step: buildJavaService(...)
│ └── buildJavaService.txt # help text shown in the snippet generator
├── src/
│ └── com/example/ci/Versioning.groovy
└── resources/
└── com/example/ci/pod-template.yaml
Les fichiers de vars/ deviennent des steps appelables par leur nom ; les classes de src/ sont du Groovy ordinaire que vous pouvez tester unitairement ; resources/ contient des fichiers chargés avec libraryResource. Enregistrez la bibliothèque une fois pour toutes dans Manage Jenkins → System → Global Pipeline Libraries, puis utilisez-la :
@Library('ci-lib@v3') _
standardJavaPipeline(
jdk: '21',
sonarProject: 'shop-api',
deployTo: ['staging']
)
Un fichier de vars/ qui définit def call(Map config) peut encapsuler un pipeline entier et réduire le Jenkinsfile de chaque projet à une poignée de lignes déclaratives. Versionnez la bibliothèque avec des tags et référencez un tag plutôt que @main pour tout ce qui compte — sinon une modification de la bibliothèque change silencieusement, et d'un seul coup, tous les builds de l'organisation.
Pipelines scriptés et bloc script
La syntaxe scriptée est apparue en premier : un programme Groovy avec node, stage et un contrôle impératif complet.
node('linux') {
stage('Checkout') { checkout scm }
stage('Build') {
def modules = sh(script: 'ls -d modules/*', returnStdout: true).trim().split('\n')
for (m in modules) {
sh "mvn -B -ntp -f ${m}/pom.xml package"
}
}
}
Elle est plus souple, et il est beaucoup plus facile d'y écrire du mauvais code. Rien n'est validé avant le démarrage du build, la vue des stages se dégrade, et le fichier dérive vers un programme que seul son auteur comprend. Réservez-la aux pipelines dont la structure ne peut réellement pas être connue à l'avance.
Le compromis pragmatique est un bloc script à l'intérieur d'un stage déclaratif, qui autorise du Groovy arbitraire exactement là où vous en avez besoin :
stage('Version') {
steps {
script {
def pom = readMavenPom file: 'pom.xml'
env.RELEASE_VERSION = pom.version.replace('-SNAPSHOT', '')
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.RELEASE_VERSION}"
}
}
}
Limitez ces blocs à quelques lignes. Quand l'un d'eux dépasse un écran, sa place est dans une shared library.
Déboguer sans vingt commits
Trois techniques font gagner énormément de temps.
Replay. Ouvrez un build terminé, cliquez sur Replay, modifiez le texte du pipeline et relancez-le. Le script modifié est utilisé sans toucher au dépôt. Une fois que cela fonctionne, commitez le changement.
Le linter. Validez la syntaxe avant de pousser :
curl -s -X POST -F "jenkinsfile=<Jenkinsfile" \
https://jenkins.example.com/pipeline-model-converter/validate
Ajoutez-le comme hook pre-commit ou comme vérification de CI, et toute la catégorie « faute de frappe découverte après huit minutes de build en file d'attente » disparaît.
Le snippet generator. Toute instance Jenkins expose /pipeline-syntax/, qui construit à partir d'un formulaire un appel de step correctement formé, en s'appuyant sur les plugins réellement installés sur ce controller. C'est le moyen le plus rapide de trouver les noms de paramètres d'un step que vous ne connaissez pas.
Au-delà, gardez des pipelines courts. Si votre Jenkinsfile fait 400 lignes, la logique a sa place dans un script shell ou une bibliothèque que vous pouvez exécuter hors de Jenkins — ce qui permet aussi à un développeur de reproduire le build sur son portable.
Points clés à retenir
- La syntaxe déclarative est validée en amont, s'affiche bien et se lit clairement ; écrivez-la par défaut et ne passez à
scriptque pour les lignes qui l'exigent. agentpeut être défini par stage, et un agent Docker tire la toolchain d'une image, ce qui rend les builds reproductibles et permet aux projets de diverger sans se coordonner sur les paquets installés sur les agents.- Les paramètres,
environmentet les variables injectées par Jenkins configurent un build ; les credentials viennent toujours du magasin, viacredentials()ouwithCredentials, dans des chaînes shell entre apostrophes. whenavecbeforeAgent truesaute du travail à moindre coût ;paralleletmatrixréduisent la durée réelle en répartissant les stages indépendants sur plusieurs agents.options, ainsi quetimeoutetretryau niveau des steps, rendent les pipelines résistants au bruit de l'infrastructure sans masquer les vrais échecs de tests.postgère le reporting et le nettoyage quelle que soit l'issue, etfixed/regressionproduisent des notifications que l'on lit vraiment.- Extrayez la répétition dans une shared library versionnée, épinglez-la par tag, et testez unitairement le code de
src/comme n'importe quel autre Groovy. - Utilisez Replay, le linter déclaratif et
/pipeline-syntax/plutôt que de pousser des commits spéculatifs sur une branche.
Sources et lectures complémentaires
Questions fréquentes
- Déclaratif ou scripté : lequel faut-il écrire ?
- Le déclaratif, sauf raison précise de faire autrement. Il est validé avant le démarrage du build, produit des messages d'erreur clairs, s'affiche correctement dans Blue Ocean et la vue des stages, et prend en charge les directives options, when et post que les pipelines scriptés doivent réimplémenter à la main. Quand vous butez sur une vraie limite, passez par un bloc script pour ces quelques lignes plutôt que de réécrire tout le fichier en scripté.
- Pourquoi mon pipeline échoue-t-il avec une erreur de sérialisation ?
- Le code du pipeline s'exécute sur le controller et fait l'objet d'un point de reprise après chaque step, pour qu'un build survive à un redémarrage. Les variables locales doivent donc être sérialisables, et des objets non sérialisables comme un résultat de JsonSlurper ou un matcher ne peuvent pas rester dans la portée d'un step à l'autre. Placez cette logique dans une méthode annotée @NonCPS, ou affectez null à la variable avant le step suivant.
- Comment empêcher un stage de faire échouer tout le build ?
- Enveloppez-le dans catchError avec un résultat de build, par exemple catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE'). Le stage apparaît en échec dans la visualisation, le build global devient instable au lieu d'échouer, et les stages suivants s'exécutent quand même. À utiliser avec parcimonie : un stage qui a le droit d'échouer à chaque fois est un stage que plus personne ne lit.
- Où doit vivre la logique Groovy un peu lourde ?
- Dans une shared library, pas dans le Jenkinsfile. Le code de src/ est du Groovy compilé que vous pouvez tester unitairement avec JUnit et un harnais de test de pipeline, alors qu'une centaine de lignes de logique dans un bloc script est impossible à tester et se retrouve dupliquée d'un dépôt à l'autre. Gardez le Jenkinsfile déclaratif et lisible : il doit décrire ce que fait le build, pas l'implémenter.