Downstream pipelines: hacer que dos repos se hablen
Mergear un cambio del frontend debería correr los tests E2E que viven en otro repo, mantenido por otro equipo. Trigger jobs, pipelines multi-project frente a parent-child, cómo reflejar el estado del pipeline hijo, y un allowlist de variables que además funciona como frontera de secretos.
- gitlab
- ci-cd
- devops
Entre las partes 5 y 6 el pipeline de un repo quedó completamente bajo control: rules decide qué jobs corren y workflow decide qué pipelines existen. El siguiente problema ya no vive en tu repo. Cuando se mergea un cambio en my-app debería correr la suite E2E, pero esa suite está en group/qa-suite, la mantiene el equipo de QA, y tiene su propio pipeline, sus propias dependencias y sus propios runners. Copiar los tests a tu repo significa mantener dos copias para siempre, así que esa opción muere rápido.
Lo que quieres en realidad es que el pipeline de my-app arranque el pipeline de qa-suite, le pase un poco de contexto y le importe el resultado. GitLab llama a esto downstream pipelines, y vienen en dos sabores que en YAML se ven casi iguales y se comportan distinto.
Dos formas de disparar otro pipeline
Un trigger job (la interfaz lo llama bridge) es un job cuyo único trabajo es arrancar un pipeline aguas abajo. Si lo apuntas a otro proyecto, obtienes un pipeline multi-project:
run-e2e:
stage: e2e
trigger:
project: group/qa-suite
branch: mainbranch es opcional y, si lo omites, se usa la rama por defecto del proyecto destino. El pipeline disparado corre por completo en el mundo del otro proyecto: su .gitlab-ci.yml, sus runners, sus settings de CI/CD y sus secretos.
Si en lugar de un proyecto apuntas el trigger a un archivo, obtienes un pipeline parent-child:
build-child:
stage: build
trigger:
include: ci/child-pipeline.ymlMismo proyecto, misma ref, mismos permisos. El hijo es un segundo pipeline recortado de tu propio repo, útil para partir una configuración gigante o para generar YAML de pipeline al vuelo. La línea divisoria es la propiedad: si la configuración que quieres correr vive en tu repo, usa trigger:include; si pertenece a otro equipo en otro proyecto, usa trigger:project. Para el problema de E2E toca lo segundo.
Hay una restricción que conviene conocer desde el inicio. Los trigger jobs solo aceptan un conjunto limitado de keywords (trigger, variables, rules, when, needs, stage, allow_failure y pocos más). No existe script, así que un bridge no puede «preparar» nada antes de disparar: lo que el pipeline destino necesite tiene que existir ya, o viajar como variable.
Esperar el veredicto
Por defecto, un trigger job se marca como exitoso apenas el pipeline aguas abajo queda creado, no cuando termina. Para un disparo tipo «lanza y olvida» está bien. Como gate de merge es inútil: el bridge se pone verde mientras la suite E2E todavía está instalando navegadores.
strategy hace que el bridge espere y adopte el resultado del hijo:
run-e2e:
stage: e2e
trigger:
project: group/qa-suite
branch: main
strategy: dependdepend es el valor histórico y el que vas a encontrar en la mayoría de configuraciones existentes. GitLab 18.2 agregó strategy: mirror, que la documentación ahora recomienda en su lugar: refleja el estado del pipeline hijo de forma exacta, mientras que depend tiene algunas aristas (por ejemplo, muestra running cuando el hijo solo está esperando un job manual). Si tu instancia es más vieja, depend es lo que hay, y para los casos comunes alcanza.
Dos detalles que aplican con cualquiera de los dos valores. Un job manual bloqueante en el pipeline hijo mantiene el bridge abierto hasta que alguien lo ejecute; los manuales opcionales se ignoran. Y un job del hijo que falla con allow_failure: true cuenta como pipeline exitoso, así que el bridge también muestra éxito.
Las variables también cruzan la frontera
Las variables declaradas en el trigger job se reenvían al pipeline aguas abajo. Y también se reenvía tu bloque global de variables: completo, porque todos los jobs de un pipeline heredan las variables por defecto (el trigger job incluido) y todo lo que el trigger job tenga encima cruza la frontera.
Vuelve a leer eso desde la silla del equipo de QA: todo lo que cualquier repo upstream tenga en su bloque global de variables: aterriza en su pipeline sin anunciarse. En el mejor caso es ruido. En el peor, un token que alguien estacionó en el bloque global ahora se puede leer desde un proyecto con otras reglas de acceso, o una variable choca por nombre con una que el hijo define y la pisa sin que nadie se entere.
inherit:variables: false convierte esa manguera abierta en un allowlist:
run-e2e:
stage: e2e
inherit:
variables: false
variables:
UPSTREAM_PROJECT_SLUG: my-app
UPSTREAM_BRANCH: $CI_COMMIT_BRANCH
trigger:
project: group/qa-suite
branch: main
strategy: dependCon la herencia apagada, el trigger job solo tiene lo que declara, y por lo tanto a qa-suite solo llegan esas dos variables. inherit:variables también acepta una lista de nombres, por si prefieres dejar pasar algunas globales específicas. En cualquiera de las dos formas, el bridge se vuelve la interfaz documentada entre los dos repos: el pipeline hijo puede apoyarse exactamente en estas variables y en ninguna otra, y el acoplamiento entre equipos queda tan angosto como el YAML que lo expresa.
(La preocupación inversa, que las variables que alguien escribió al lanzar un pipeline manual fluyan hacia abajo, está apagada por defecto; trigger:forward la controla si algún día necesitas cambiarla.)
Lo que ve el pipeline hijo
En un pipeline disparado desde otro proyecto, todos los jobs ven $CI_PIPELINE_SOURCE == "pipeline". En un pipeline parent-child el valor es parent_pipeline. Esa única variable le permite al repo de QA distinguir «una app upstream quiere E2E» de sus propios pushes a ramas, y sirve en las dos direcciones:
e2e:
rules:
- if: $CI_PIPELINE_SOURCE == "pipeline"
script:
- npx playwright test --project "$UPSTREAM_PROJECT_SLUG"
pages:
rules:
- if: $CI_PIPELINE_SOURCE == "pipeline"
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- ./scripts/publish-report-site.shLa primera regla hace que la suite corra únicamente en pipelines disparados. La segunda es el espejo, y es fácil de olvidar: el repo de QA tiene jobs propios (aquí, uno estilo pages que publica un sitio de reportes) que no tienen ningún sentido en una corrida disparada. Sin el guard de when: never, cada merge de cada app upstream también redesplegaría el sitio de reportes del equipo de QA. La semántica de rules es la parte 5; el único ingrediente nuevo es el valor del pipeline source.
Un repo de QA, varias apps
Esto no lo armamos para una sola app. Varios frontends disparan el mismo qa-suite, y el enrutamiento es esa única variable UPSTREAM_PROJECT_SLUG: la configuración de Playwright define un project por cada app upstream, y el job de E2E del hijo lo selecciona con --project "$UPSTREAM_PROJECT_SLUG". El bridge de cada app es idéntico salvo por dos líneas, y para soportar una app nueva el equipo de QA solo agrega un project de Playwright con el nombre correspondiente.
Es un fan-in limpio, con exactamente una suposición cargando todo el peso: que el string del slug en el .gitlab-ci.yml de cada app coincida con un nombre de project en la configuración de Playwright de qa-suite. Dos repos, un string, y ningún compilador en el medio.
El rename que nadie notó
Esa suposición nos falló de la manera más aburrida posible. Durante una limpieza en el repo de QA, alguien renombró el project de Playwright de una de las apps — el tipo de cambio que parece puramente interno. La app upstream siguió mandando el slug viejo.
Nada se puso rojo. Nuestro script de arranque mapeaba el slug a un subconjunto de tests, y cuando el slug no coincidía con nada, caía a un set mínimo de smoke tests compartidos. Ese default nos pareció razonable cuando lo escribimos, con la lógica de que correr algo es mejor que no correr nada. El resultado: el pipeline hijo corría un puñado de checks genéricos que no tocaban ni un flujo de esa app, pasaba, y el bridge reflejaba obedientemente el verde hacia arriba. Durante semanas, los merges de esa app recibieron teatro de E2E en lugar de cobertura de E2E, y todos los dashboards coincidían en que todo estaba bien.
El arreglo fue borrar el fallback. Un slug desconocido ahora hace fallar el primer job del hijo de inmediato, imprimiendo la lista de slugs conocidos, lo que convierte un rename en un repo en una falla ruidosa en el otro — el único lugar donde el desajuste siquiera se puede observar. También empezamos a tratar los renames de slug como lo que son: un cambio a un contrato entre dos repos, que se hace en ambos lados en una sola pasada coordinada, con los slugs válidos listados en el README del repo de QA como referencia.
E2E sin bloquear a nadie
No todos los equipos upstream quieren que E2E bloquee cada merge; la suite es lenta y los navegadores tienen sus días. El bridge se combina con las herramientas de la parte 5:
run-e2e:
stage: e2e
when: manual
allow_failure: true
inherit:
variables: false
variables:
UPSTREAM_PROJECT_SLUG: my-app
trigger:
project: group/qa-suite
branch: main
strategy: dependCon when: manual, E2E es opcional: un botón en la vista del pipeline para cuando el cambio lo amerita. Con allow_failure: true, el job manual no deja el pipeline colgado, y si alguien lo corre y falla, el pipeline muestra una advertencia en lugar de fallar, así que la señal sigue visible sin frenar a nadie. Cada equipo elige su postura: gate estricto (ninguno de los dos keywords), señal opcional (ambos), o siempre activo pero sin bloquear (allow_failure solo). Una salvedad propia de los bridges: a diferencia de un job manual normal, en un trigger job manual no puedes escribir variables extra antes de ejecutarlo, así que todo lo configurable tiene que estar en el YAML.
Con qué te quedas
my-app ya puede arrancar el pipeline de qa-suite, esperar su veredicto y pasarle exactamente las variables que declara — y el hijo sabe cuándo lo están disparando y se comporta en consecuencia. Los repos se hablan.
Lo que esto no arregla es la repetición entre repos: esos varios frontends que disparan qa-suite también comparten unas trescientas líneas de YAML de pipeline casi idénticas, copiadas y pegadas, cada una derivando por su lado. Deduplicar eso — una sola configuración de CI sirviendo a diez repos vía include — es la parte 8.