Workflow y tipos de pipeline en GitLab CI: un pipeline por commit
Haz push a una rama con un merge request abierto y aparecen dos pipelines para el mismo commit: el doble de costo y dos estados que interpretar. Los tipos de pipeline, workflow rules como portero, y lo que de verdad cuesta correr solo branch pipelines.
- gitlab
- ci-cd
- devops
En la parte 5 le dimos a cada job su bloque de rules, así que ahora cada job decide por su cuenta si le toca correr. Pero haz push a una rama que tiene un merge request abierto y mira la pestaña de pipelines: hay dos pipelines para el mismo SHA. Uno aparece etiquetado como branch pipeline y el otro como merge request pipeline. Los dos corren la suite completa, los dos consumen minutos de runner, y cada uno reporta su propio estado.
Ninguno de los dos es un bug: ambos hacen exactamente lo que el archivo, por omisión, les dijo que hicieran. GitLab tiene varios tipos de pipeline, un solo push puede producir más de uno de forma legítima, y nada de lo que escribimos hasta ahora dice cuáles quiere este repo. Esa elección es la decisión de mayor consecuencia en todo el archivo, y vive en un bloque que todavía no tocamos: workflow.
De dónde salen los dos pipelines
Un push a una rama crea un branch pipeline. Ahí, CI_PIPELINE_SOURCE vale "push" y CI_COMMIT_BRANCH trae el nombre de la rama. Es el único tipo que usamos de la parte 1 a la 5.
Si esa rama además tiene un merge request abierto, GitLab puede crear también un merge request pipeline para el mismo commit, con CI_PIPELINE_SOURCE igual a "merge_request_event". Los MR pipelines existen porque traen contexto que un branch pipeline no tiene: variables predefinidas como CI_MERGE_REQUEST_IID y la rama destino, de las que dependen las herramientas orientadas a MRs. Eso sí, corren sobre el contenido de la rama de origen y nada más, así que el código que prueban es el mismo que el del branch pipeline; lo que cambia es la metadata.
Hay un detalle importante: un MR pipeline solo se crea si tu configuración lo pide, es decir, si alguna regla (de job o de workflow) coincide con CI_PIPELINE_SOURCE == "merge_request_event". El problema de los pipelines duplicados aparece justo cuando tus reglas coinciden con ambos orígenes a la vez, que en la práctica suele ser una mezcla de jobs con rules pensadas para MRs y jobs sin reglas. A partir de ahí, cada push a una rama con MR abierto paga dos pipelines casi idénticos.
workflow rules: el portero del pipeline
Las rules de un job responden «¿este job corre en este pipeline?». workflow: rules responde la pregunta un nivel más arriba: «¿este pipeline se crea o no?». Va en la parte superior del archivo, se evalúa antes que cualquier job, y usa las mismas condiciones if/changes/exists de la parte 5, con una restricción: aquí when solo puede ser always o never. Gana la primera regla que coincide, y si ninguna coincide, el pipeline no corre.
Este es el patrón que la propia documentación de GitLab recomienda para alternar limpiamente entre los dos tipos:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCHLéelo de arriba hacia abajo. Un evento de MR siempre obtiene pipeline. Un push a una rama se suprime cuando esa rama tiene un MR abierto (CI_OPEN_MERGE_REQUESTS solo existe en ese caso). Y un push a una rama sin MR conserva su branch pipeline. El resultado: MR pipelines mientras hay un MR abierto, branch pipelines en el resto de los casos, y nunca los dos juntos.
Un archivo sin bloque workflow significa que esta decisión se está tomando job por job, por accidente. El bloque va al inicio del .gitlab-ci.yml, antes de los stages, para que quien abra el archivo vea la política de pipelines antes que cualquier definición de jobs.
Los tipos de pipeline que existen
Con seis tipos cubres casi todo lo que te vas a cruzar. Lo que los distingue es quién los dispara y qué código prueban:
- Branch pipelines: los dispara un push,
CI_PIPELINE_SOURCE == "push". Prueban la rama tal como se subió. - Merge request pipelines: se crean para commits en una rama con MR abierto,
CI_PIPELINE_SOURCE == "merge_request_event". Prueban el contenido de la rama de origen, con el contexto del MR adjunto. - Merged results pipelines: una mejora de los MR pipelines (tier Premium). GitLab construye un commit temporal que fusiona la rama de origen con la destino y prueba eso. Si la fusión tiene conflictos, cae de vuelta a un MR pipeline normal.
- Merge trains: una cola encima de merged results (también Premium). Cada MR del tren se prueba contra la rama destino más todos los MRs que van delante; si un pipeline falla, ese MR sale del tren y los pipelines de atrás se reinician.
- Scheduled pipelines: los dispara un cron,
CI_PIPELINE_SOURCE == "schedule". E2E nocturnos, jobs que calientan cache. - Triggered pipelines: los arranca otro pipeline o una llamada a la API. Son la forma en que los repos se hablan entre sí; la parte 7 se dedica por completo a ellos.
Los primeros cuatro son alternativas: eliges un carril por repositorio. Los últimos dos son aditivos y conviven con el carril que hayas elegido. Merged results y merge trains traen además requisitos previos: los MR pipelines tienen que estar ya configurados en el archivo, y el proyecto tiene que vivir en GitLab (no funciona con espejos de repositorios externos).
Lo que corremos nosotros
Dos configuraciones de producción, sanitizadas, porque llegaron a respuestas opuestas.
El monorepo grande corre únicamente branch pipelines. El bloque de workflow mata todo lo demás:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCHCada push produce exactamente un pipeline, haya MR abierto o no. El atractivo es operativo: un solo tipo de pipeline significa un solo modelo mental para las keys de cache (con el split de ramas protegidas de la parte 2 ya tuvimos suficiente), un solo juego de variables predefinidas, y CI que funciona desde el primer push, antes de que exista cualquier MR.
El costo es que el contexto del MR desaparece. CI_MERGE_REQUEST_IID no existe en un branch pipeline, y herramientas como Danger necesitan saber sobre qué MR van a comentar. Esa factura la pagamos con una consulta a la API: resolvemos el MR abierto a partir del nombre de la rama y le entregamos a la herramienta la variable que espera.
danger:
stage: quality
rules:
- if: $CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH
script:
- |
MR_IID=$(curl --silent --header "PRIVATE-TOKEN: ${DANGER_API_TOKEN}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests?source_branch=${CI_COMMIT_REF_NAME}&state=opened" \
| jq -r '.[0].iid // empty')
- |
if [ -z "$MR_IID" ]; then
echo "No hay MR abierto para ${CI_COMMIT_REF_NAME}, saltando"
exit 0
fi
- export CI_MERGE_REQUEST_IID="$MR_IID"
- npx danger ciLos repos separados de microfrontends tomaron el camino contrario: merged results pipelines. Sus MRs entran a un flujo de release compartido donde un conflicto semántico (dos MRs que pasan cada uno por separado pero se rompen combinados con una rama destino que avanzó) sale caro de descubrir después del merge. Merged results atrapa esa clase de fallo antes del botón de merge, porque el pipeline prueba el estado fusionado en lugar de la rama tal como se bifurcó.
El tradeoff, dicho con honestidad: los branch pipelines no necesitan MR ni configuración extra, pero un pipeline verde solo demuestra que el estado previo al merge estaba bien; la rama destino pudo haber avanzado mientras tanto. Merged results demuestra exactamente lo que estás a punto de crear, pero exige un flujo centrado en MRs (sin MR no hay pipeline), una licencia Premium, y aceptar que un conflicto degrada la corrida a un MR pipeline normal sin hacer ruido. Las dos elecciones se pueden defender. Lo que a nosotros nos dolió fue mezclarlas entre los repos de un mismo equipo: cada cambio de contexto venía con su desvío de «¿por qué no hay pipeline?» o «¿por qué hay dos?».
Cancelar lo que un push nuevo volvió obsoleto
Aun con un pipeline por commit, los pipelines se apilan cuando alguien hace push cuatro veces en diez minutos. Los tres primeros están respondiendo preguntas que ya nadie hace.
interruptible: true marca un job como seguro de cancelar cuando su pipeline queda redundante, y workflow:auto_cancel:on_new_commit define la política a nivel de pipeline:
workflow:
auto_cancel:
on_new_commit: interruptible
default:
interruptible: true
deploy-prod:
interruptible: false
resource_group: productionCon on_new_commit: interruptible, un push nuevo cancela exactamente los jobs marcados como interrumpibles (lint, tests, builds), mientras que todo lo marcado con interruptible: false sigue corriendo. La política por defecto, conservative, es más tosca: cancela el pipeline redundante solo si ningún job no-interrumpible ha empezado; en cuanto uno arranca, el pipeline viejo entero corre hasta el final. Poner interruptible: true bajo default: y excluir los deploys uno por uno falla menos que acordarse de marcar cada job nuevo.
Un deploy a la vez
Esa línea de resource_group: production del ejemplo anterior resuelve el problema opuesto: jobs que nunca deben correr en paralelo, ni siquiera desde pipelines distintos. Dos merges caen con minutos de diferencia, ambos pipelines llegan al job de deploy, y sin coordinación se genera una carrera: el pipeline más viejo puede terminar al último y dejar código desactualizado en producción.
Un resource group funciona como un mutex: entre todos los pipelines del proyecto, solo un job con resource_group: production corre a la vez; el resto hace fila. Qué job de la fila sigue lo decide el process mode: unordered (el default), oldest_first para orden estricto, o newest_first/newest_ready_first para saltar directo al build más reciente cuando tus deploys son idempotentes. No existe keyword de YAML para el process mode; se cambia por resource group a través de la API.
Con qué te quedas
Un commit ahora produce un solo pipeline deliberado, del tipo que tú elegiste; las corridas redundantes se cancelan solas y los deploys van en fila. Dentro de un repositorio, esa es toda la historia de control.
El siguiente problema ya no cabe dentro de un repositorio: un merge en el repo del frontend necesita correr tests E2E que viven en otro repo, de otro equipo, con su propio pipeline. Hacer que un pipeline arranque otro, y lo espere, es la parte 7.