Saltar al contenido

Rules en GitLab CI: controlar cuándo corre cada job

Cada job corre en cada push: los jobs de deploy se cuelan en las ramas de feature y las suites caras corren aunque nada relevante haya cambiado. Rules a nivel de job (if, changes, exists), gates manuales y un regex que coincidía en silencio con casi cualquier mensaje de commit.

5 min de lectura
  • gitlab
  • ci-cd
  • devops

La parte 4 dejó el pipeline rápido: needs convirtió la secuencia de stages en un grafo de dependencias y parallel repartió los jobs más lentos entre varios runners. Lo que todavía nada controla es cuándo corre cada cosa. Haz push a una rama de feature y el job de deploy a producción aparece igual en el pipeline, listo para publicar código que nadie ha mergeado. Cambia solo la carpeta de docs y la batería completa de build y tests corre como si hubieras reescrito la aplicación entera.

La respuesta a nivel de job se llama rules: una lista de condiciones que se evalúa al crear el pipeline y decide, job por job, si ese job entra en este pipeline concreto y en qué modo. Lo de «a nivel de job» no es un detalle menor: también existe control a nivel de pipeline, y la diferencia entre ambos importa más de lo que suena; ese es el tema de la parte 6.

La primera regla que coincide gana

La regla más simple que vale la pena escribir mantiene el deploy fuera de las ramas de feature:

deploy-production:
  stage: deploy
  script: ./deploy.sh
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Las condiciones de if son expresiones sobre variables de CI/CD, casi siempre las predefinidas: CI_COMMIT_BRANCH es la rama que se está construyendo, CI_DEFAULT_BRANCH es como se llame la rama principal del repo, y CI_PIPELINE_SOURCE dice qué creó el pipeline (push, merge_request_event, schedule, entre otros).

Todo el keyword se apoya en dos mecánicas. La primera: las reglas se evalúan de arriba hacia abajo y la primera que coincide decide qué pasa con el job; el resto se ignora. La segunda: si ninguna coincide, el job no se agrega al pipeline. Tampoco queda registrado como «skipped» en la interfaz: simplemente no aparece en la lista.

Esa segunda mecánica vuelve importante el orden. Las exclusiones van primero:

deploy-production:
  stage: deploy
  script: ./deploy.sh
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

En un pipeline programado, la primera regla coincide, when: never se dispara y el job queda fuera, aunque la segunda regla también hubiera coincidido. Invierte el orden de las dos entradas y los pipelines programados empiezan a hacer deploy.

Cuando un job que esperabas no aparece, la vista del pipeline no te va a decir qué regla lo excluyó. Toca recorrer la lista a mano, de arriba hacia abajo, con los valores concretos de ese pipeline: la rama, el CI_PIPELINE_SOURCE, todo visible en los detalles del pipeline.

changes: reaccionar a lo que tocó el push

La segunda palanca es correr jobs únicamente cuando cambiaron rutas relevantes:

build-docs:
  stage: build
  script: pnpm --filter docs build
  rules:
    - changes:
        - docs/**/*

Si el push no tocó nada bajo docs/, el job no entra al pipeline. Las rutas son patrones glob y puedes listar varias.

Una misma regla también puede combinar keywords, y se combinan con AND: todo lo que aparece en la entrada tiene que cumplirse a la vez.

docker-build:
  stage: build
  script: docker build -t my-app .
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      changes:
        - Dockerfile
        - docker/**/*

Ese job construye la imagen únicamente en la rama principal, y únicamente si se movieron archivos relacionados con Docker. La base de comparación de changes (contra qué se calcula exactamente el diff del push) se convierte en una pregunta seria en los monorepos, y ahí es donde rules:changes:compare_to gana su lugar; la parte 9 se mete de lleno en eso.

exists: reaccionar a lo que contiene el repo

changes mira lo que modificó este push; exists mira lo que el repositorio tiene:

docker-build:
  stage: build
  script: docker build -t my-app .
  rules:
    - exists:
        - Dockerfile

Dentro de un solo repo que tú controlas parece inútil, porque ya sabes si hay un Dockerfile o no. Empieza a rendir cuando una misma configuración de CI sirve a muchos repos (la parte 7 construye exactamente eso): el job se define una sola vez para todos y solo se materializa donde existe un Dockerfile. Invertido con when: never funciona como guardia: salta un job cuando cierto archivo de configuración está presente y corre una alternativa cuando no lo está.

when: gates manuales y gates suaves

Cuando una regla coincide y no dice nada más, el job entra con when: on_success: corre cuando los jobs de los que depende terminaron bien, el comportamiento normal que tienes desde la parte 1. never ya lo viste en acción. El tercer valor que vas a usar de verdad es manual:

deploy-staging:
  stage: deploy
  script: ./deploy.sh staging
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
      allow_failure: true

Un job manual entra al pipeline como un botón: nada corre hasta que una persona lo presiona. Lo que decide allow_failure es si el resto del pipeline espera ese clic. Con allow_failure: true, el pipeline sigue de largo y puede terminar en verde sin que nadie presione nada; es una acción opcional, un gate suave. Con allow_failure: false, el job manual bloquea: los jobs de los stages siguientes se quedan esperando hasta que alguien lo ejecute y termine bien. Eso ya es un gate de aprobación, y es lo que quieres delante de un deploy a producción.

Los dos usos son legítimos; lo importante es saber cuál estás escribiendo, porque dentro de rules el default es allow_failure: false. Fuera de rules, un job con when: manual a secas tiene default allow_failure: true. El mismo keyword cambia de default según dónde aparece, que es justo el tipo de detalle que conviene verificar en la documentación en lugar de confiar en la memoria. Existen dos valores más, delayed y on_failure; los vas a necesitar rara vez y la referencia oficial los cubre.

La regla que coincidía con todo

Durante mucho tiempo tuvimos un job pesado con una salida de emergencia: escribe [skip ci] en el mensaje del commit y una regla deja el job fuera del pipeline. La regla era esta:

expensive-suite:
  stage: test
  script: ./run-suite.sh
  rules:
    - if: $CI_COMMIT_MESSAGE =~ /[skip ci]/
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Los regex de if usan sintaxis RE2, y en RE2, como en cualquier dialecto de regex, los corchetes sin escapar forman una clase de caracteres. /[skip ci]/ coincide con cualquier mensaje de commit que contenga al menos uno de los caracteres s, k, i, p, c o un espacio. Es decir, con casi cualquier mensaje de commit jamás escrito.

Así que la regla coincidía con casi todo, when: never se disparaba en casi todos los pipelines, y el job dejó de correr en silencio durante mucho tiempo. Nadie lo notó, porque un job excluido por rules se ve idéntico a una regla haciendo su trabajo: sin error, sin X roja, sin línea en ningún log, solo una ausencia en una lista que nadie estaba contando. Una regla rota no lanza errores; cambia qué jobs existen.

El arreglo costó cinco caracteres, escapar los corchetes (/\[skip ci\]/). Lo caro fue el diagnóstico: alguien terminó preguntando por qué la suite no tenía corridas recientes. Prueba tus reglas contra valores reales antes de confiar en ellas: toma mensajes de commit y nombres de rama de pipelines recientes y verifica a mano, o con un job desechable que solo imprima las variables, qué regla le tocaría a cada uno.

Con qué te quedas

Cada job ahora justifica su presencia: los deploys solo donde corresponden, las suites caras solo cuando se movieron archivos relevantes, y una persona en el circuito donde un clic debe decidir. Pero rules opera job por job, y hay problemas que viven por encima de los jobs: haz push a una rama que tiene un merge request abierto y GitLab puede crear tranquilamente dos pipelines casi idénticos para el mismo commit, con las reglas de cada uno funcionando exactamente como se escribieron. Controlar eso requiere reglas a nivel de pipeline, workflow: rules, y elegir un tipo de pipeline: la parte 6.

Referencias