DevOps, CI/CD y contenedores
Pipelines de Jenkins y el Jenkinsfile: la sintaxis declarativa a fondo
Intermedio9 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Un Jenkinsfile convierte tu build en código versionado y revisado que vive junto al proyecto. Esta guía recorre la sintaxis del pipeline declarativo sección a sección —elección del agent, entorno y parámetros, stages condicionales y paralelos, reintentos y timeouts, condiciones post— y después trata las shared libraries, la vía de escape del pipeline scripted y cómo depurar un pipeline sin veinte commits de prueba.
Lo que aprenderás
- Escribir un pipeline declarativo con los bloques agent, stages, steps, options, environment y post
- Elegir dónde se ejecuta cada stage, incluidos agents Docker por stage
- Parametrizar builds y vincular credenciales sin exponer secretos en el log
- Ejecutar stages de forma condicional y en paralelo, y aplicar timeouts, reintentos y límites de concurrencia
- Extraer la lógica repetida a una shared library y saber cuándo está justificado el Groovy scripted
Un pipeline definido en la interfaz de Jenkins es invisible para quienes se preocupan por él. Nadie lo revisa, nadie puede compararlo con la versión del mes pasado y desaparece junto con el controller. Llevar esa definición a un Jenkinsfile en la raíz del repositorio lo arregla todo de una vez: el build pasa a formar parte del código, los cambios se revisan junto con el código al que afectan, cada rama lleva el pipeline que le corresponde y un controller reconstruido recupera la definición desde el repositorio.
Esta guía da por hecho que tienes un controller funcionando con agents y credenciales —la guía de instalación de Jenkins cubre esa parte— y se centra en el lenguaje. La sintaxis declarativa ocupa casi todo el espacio porque es la que deberías escribir; el Groovy scripted aparece al final, que es donde le corresponde.
La estructura de un pipeline declarativo
Todo pipeline declarativo es un bloque pipeline que contiene unas pocas secciones con nombre, según una gramática fija que Jenkins valida antes de que empiece el 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() }
}
}
Léelo de arriba abajo. agent decide dónde se ejecuta el trabajo. options configura el propio job: cuánto puede durar, cuánto historial se conserva, si pueden ejecutarse dos builds a la vez. environment define variables visibles para todos los steps. stages contiene la lista ordenada de bloques stage, cada uno con sus steps. post se ejecuta al final sea cual sea el resultado, con subbloques que se eligen según ese resultado.
La gramática es estricta a propósito. Las secciones deben aparecer en este orden, steps solo puede contener steps y una errata produce un error de análisis claro antes incluso de asignar un agent. Esa rigidez es lo que hace que un pipeline declarativo siga siendo legible un año después.
Elegir dónde se ejecuta un stage
agent aparece en el nivel superior y, opcionalmente, de nuevo dentro de stages concretos. Las formas habituales:
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' } }
El agent docker es el que cambia la experiencia de los builds. El stage se ejecuta dentro de un contenedor arrancado a partir de esa imagen en un agent que tenga Docker, de modo que la toolchain sale de la imagen y no de lo que haya instalado en la máquina. Dos proyectos pueden usar versiones distintas del JDK sin coordinarse, y reproducir en local un fallo de CI es cuestión de ejecutar la misma imagen.
agent none en el nivel superior con agents por stage es el patrón para pipelines cuyos stages necesitan entornos distintos. Además, evita ocupar un executor mientras se espera una aprobación:
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' }
}
}
}
Ten en cuenta que cada agent tiene su propio workspace. Los archivos generados en un stage no son visibles en el siguiente a menos que los traslades con stash/unstash o los archives.
Entorno, parámetros y credenciales
environment define variables a nivel de pipeline o de stage. Las entradas del stage prevalecen sobre las del pipeline, y ambas son variables de entorno de shell normales dentro de sh.
Jenkins inyecta muchas variables propias: BUILD_NUMBER, BUILD_URL, JOB_NAME, WORKSPACE, BRANCH_NAME y CHANGE_ID en los jobs multibranch, además de GIT_COMMIT y GIT_BRANCH que aporta el plugin de Git. Haz referencia a ellas como env.BUILD_NUMBER en Groovy y como $BUILD_NUMBER dentro de un step de shell.
Los parámetros hacen que un job sea interactivo. Decláralos en el pipeline; en la primera ejecución Jenkins los registra y el job gana un botón «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')
}
Léelos con params.TARGET_ENV. Ojo con el problema del huevo y la gallina: el primer build tras añadir un parámetro se ejecuta con los valores por defecto, porque Jenkins solo conoce la definición al ejecutar el archivo.
Las credenciales salen del almacén de credenciales, nunca del archivo. Hay dos mecanismos. El helper credentials() en un bloque environment cubre los tipos más comunes:
environment {
// secret text -> one variable
SONAR_TOKEN = credentials('sonar-token')
// username/password -> NEXUS, NEXUS_USR and NEXUS_PSW
NEXUS = credentials('nexus-deploy')
}
withCredentials es la forma general y es obligatoria para claves SSH, archivos y certificados:
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'
}
}
Entrecomilla las variables en el shell, mantén el bloque lo más pequeño posible y recuerda que el enmascaramiento solo oculta coincidencias exactas. Un secreto que un script transforma —codificado en base64 o para URL, partido en varias líneas— aparecerá en el log sin enmascarar.
Stages condicionales con when
when decide si los steps de un stage llegan a ejecutarse. Por defecto se evalúa después de entrar en el agent, lo que importa en las condiciones que inspeccionan el 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' }
}
Entre las condiciones útiles están branch, tag, changeRequest(), environment name: 'X', value: 'y', changeset '**/*.sql' para disparar según las rutas modificadas y expression { } para Groovy arbitrario. Combínalas con allOf, anyOf y not. Añade beforeAgent true siempre que la condición no necesite un workspace: evita asignar un executor a un stage que se va a saltar, y en un controller con mucha carga el ahorro se nota.
Para una aprobación humana, input pausa el build:
stage('Approve production') {
steps {
timeout(time: 2, unit: 'HOURS') {
input message: 'Deploy to production?', ok: 'Deploy', submitter: 'release-managers'
}
}
}
Pon input en un stage con agent none; de lo contrario, el build retiene un executor durante toda la espera. La guía sobre pipelines de entrega analiza cuándo una aprobación manual es realmente útil y cuándo es puro teatro.
Ejecutar trabajo en paralelo
Los pipelines largos suelen serlo porque trabajo independiente se ejecuta en secuencia. parallel lo soluciona.
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' }
}
}
}
Cada rama puede tener su propio agent, así que las tres se ejecutan en máquinas distintas. failFast true dentro del bloque parallel aborta las ramas hermanas en cuanto una falla, lo que ahorra tiempo de executor cuando un fallo es definitivo.
matrix despliega una misma definición de stage sobre todas las combinaciones de valores de varios ejes: lo natural para probar varias versiones de JDK o de base de datos:
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}" } }
}
}
}
Cuatro combinaciones menos una exclusión dan tres celdas, que se ejecutan en paralelo según lo permitan los executors.
Robustez: options, retry y timeout
Los builds fallan por motivos que no tienen nada que ver con el código, y un pipeline que trata cada fallo transitorio como un defecto acostumbra a la gente a ignorar el rojo.
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)
}
A nivel de step, retry y timeout envuelven acciones concretas, algo mucho más preciso que reintentar un build entero:
steps {
timeout(time: 5, unit: 'MINUTES') {
retry(3) { sh 'curl -fsSL https://artifacts.example.com/toolchain.tgz -o toolchain.tgz' }
}
}
Usa los reintentos solo para operaciones de red e infraestructura. Reintentar un test inestable oculta la inestabilidad en vez de corregirla, y una suite con inestabilidad oculta acaba fallando de una forma que ningún reintento puede salvar.
El bloque post
post es el sitio del reporting y la limpieza, porque se ejecuta tanto si el build pasó como si falló o se abortó. Las condiciones se ejecutan en un orden definido: always, luego changed, luego fixed, regression, aborted, failure, success, unstable y, por último, 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 y regression solo se disparan cuando cambia el estado, y esa es la diferencia entre un canal de notificaciones que la gente lee y uno que silencia. cleanup siempre se ejecuta el último y es el lugar adecuado para cleanWs(); borrar el workspace en always puede competir con steps que todavía lo necesitan.
Los bloques post también pueden asociarse a un stage concreto, y entonces solo se aplican a ese stage: muy práctico para publicar los informes de un stage junto al código que los generó.
Shared libraries
En cuanto tres repositorios contengan las mismas cuarenta líneas de pipeline, extráelas. Una shared library es un repositorio Git con una estructura fija:
(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
Los archivos de vars/ se convierten en steps invocables por su nombre; las clases de src/ son Groovy normal que puedes probar con tests unitarios; resources/ guarda archivos que se cargan con libraryResource. Registra la librería una sola vez en Manage Jenkins → System → Global Pipeline Libraries y después úsala:
@Library('ci-lib@v3') _
standardJavaPipeline(
jdk: '21',
sonarProject: 'shop-api',
deployTo: ['staging']
)
Un archivo de vars/ que define def call(Map config) puede encapsular un pipeline entero y reducir el Jenkinsfile de cada proyecto a unas pocas líneas declarativas. Versiona la librería con tags y referencia un tag en lugar de @main para todo lo importante; si no, un cambio en la librería altera en silencio, y de golpe, todos los builds de la organización.
Pipelines scripted y el bloque script
La sintaxis scripted llegó primero: un programa Groovy con node, stage y control imperativo total.
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"
}
}
}
Es más flexible y muchísimo más fácil de escribir mal. No hay validación antes de que empiece el build, la vista de stages se degrada y el archivo deriva hacia un programa que solo entiende su autor. Resérvala para pipelines cuya estructura de verdad no se puede conocer de antemano.
El término medio pragmático es un bloque script dentro de un stage declarativo, que permite Groovy arbitrario justo donde lo necesitas:
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}"
}
}
}
Limita esos bloques a unas pocas líneas. Cuando uno ocupa más de una pantalla, su sitio es una shared library.
Depurar sin veinte commits
Tres técnicas ahorran muchísimo tiempo.
Replay. Abre un build terminado, pulsa Replay, edita el texto del pipeline y vuelve a ejecutarlo. Se usa el script editado sin tocar el repositorio. Cuando funcione, haz commit del cambio.
El linter. Valida la sintaxis antes de hacer push:
curl -s -X POST -F "jenkinsfile=<Jenkinsfile" \
https://jenkins.example.com/pipeline-model-converter/validate
Añádelo como hook de pre-commit o como comprobación de CI y desaparecerá toda la categoría de «errata descubierta después de que el build pasara ocho minutos en cola».
El snippet generator. Toda instancia de Jenkins expone /pipeline-syntax/, que construye a partir de un formulario una invocación de step bien formada usando los plugins realmente instalados en ese controller. Es la forma más rápida de averiguar los nombres de los parámetros de un step que no conoces.
Más allá de eso, mantén los pipelines cortos. Si tu Jenkinsfile tiene 400 líneas, la lógica debería estar en un script de shell o en una librería que puedas ejecutar fuera de Jenkins, lo que además permite a cualquier desarrollador reproducir el build en su portátil.
Ideas clave
- La sintaxis declarativa se valida de antemano, se visualiza bien y se lee con claridad; escríbela por defecto y recurre a
scriptsolo para las líneas que lo necesiten. agentse puede definir por stage, y un agent Docker toma la toolchain de una imagen, lo que hace los builds reproducibles y permite que los proyectos difieran sin coordinarse sobre los paquetes de los agents.- Los parámetros,
environmenty las variables que inyecta Jenkins configuran un build; las credenciales salen siempre del almacén mediantecredentials()owithCredentials, en cadenas de shell con comillas simples. whenconbeforeAgent truese salta trabajo a bajo coste;parallelymatrixreducen el tiempo real repartiendo los stages independientes entre agents.options, junto contimeoutyretrya nivel de step, hacen que los pipelines resistan el ruido de la infraestructura sin ocultar fallos reales de los tests.postse encarga del reporting y la limpieza con cualquier resultado, yfixed/regressiongeneran notificaciones que la gente sí lee.- Extrae la repetición a una shared library versionada, fíjala por tag y prueba el código de
src/como cualquier otro Groovy. - Usa Replay, el linter declarativo y
/pipeline-syntax/en lugar de subir commits especulativos a una rama.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Declarativo o scripted? ¿Cuál debería escribir?
- Declarativo, salvo que tengas un motivo concreto para no hacerlo. Se valida antes de que empiece el build, da mensajes de error claros, se visualiza correctamente en Blue Ocean y en la vista de stages, y admite las directivas options, when y post que en los pipelines scripted hay que programar a mano. Cuando te topes con un límite real, recurre a un bloque script para esas pocas líneas en lugar de reescribir todo el archivo en scripted.
- ¿Por qué falla mi pipeline con un error de serialización?
- El código del pipeline se ejecuta en el controller y se guarda un punto de control tras cada step para que el build sobreviva a un reinicio. Por eso las variables locales deben ser serializables, y los objetos no serializables, como el resultado de un JsonSlurper o un matcher, no pueden seguir en el ámbito de un step al siguiente. Mueve esa lógica a un método anotado con @NonCPS, o asigna null a la variable antes de que se ejecute el siguiente step.
- ¿Cómo evito que un stage haga fallar todo el build?
- Envuélvelo en catchError con un resultado de build, por ejemplo catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE'). El stage aparece como fallido en la visualización, el build global queda inestable en lugar de fallido y los stages posteriores se siguen ejecutando. Úsalo con moderación: un stage al que se le permite fallar siempre es un stage que nadie lee.
- ¿Dónde debe vivir la lógica Groovy pesada?
- En una shared library, no en el Jenkinsfile. El código de src/ es Groovy compilado que puedes probar con JUnit y un arnés de pruebas de pipelines, mientras que cien líneas de lógica dentro de un bloque script no se pueden probar y acaban duplicadas en varios repositorios. Mantén el Jenkinsfile declarativo y legible: debe describir lo que hace el build, no implementarlo.