Saltar al contenido

Una librería de CI compartida: un solo pipeline para diez repos

Diez repos hermanos copiaron y pegaron las mismas ~300 líneas de YAML, y cada copia se fue rompiendo por su lado. include:, hidden jobs como API pública, un vocabulario de rules con !reference, y cómo publicar cambios cuando todos los consumidores siguen main.

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

La parte 7 terminó con pipelines disparándose entre repositorios. Esta parte trata la configuración misma. Teníamos diez microfrontends hermanos, y cada uno cargaba su propia copia del mismo .gitlab-ci.yml de unas 300 líneas. La copia original salió del primer repo; después, cada equipo la fue parchando por su cuenta durante un año. El resultado: jobs con nombres idénticos en los diez repos y comportamiento distinto en cada uno. Un repo tenía el fix del expiry de artifacts, otro seguía apuntando a un tag de runner que ya habíamos retirado, y un tercero arrastraba un ajuste de rules que se saltaba el deploy en ramas de release sin que nadie lo notara. Cualquier mejora al pipeline costaba diez merge requests, así que las mejoras prácticamente dejaron de ocurrir.

La solución es la misma que aplicarías a cualquier código duplicado: extraer una librería. GitLab CI trae las piezas — include: para jalar configuración de otro repo, hidden jobs como superficie de API, !reference para lo que extends no alcanza a cubrir. Lo que la documentación no te cuenta es la parte operativa: qué les prometes a tus consumidores, cómo publicas un cambio que aterriza en diez repos a la vez, y qué haces con el versionado cuando nadie quiere mantener tags. Esa parte nos tocó resolverla en producción.

Las cuatro variantes de include

include: fusiona otros archivos YAML con tu configuración antes de que el pipeline se cree. Hay cuatro variantes:

include:
  # un archivo del mismo repositorio
  - local: ci/deploy.yml
 
  # un archivo de otro proyecto en la misma instancia, con ref fijo
  - project: group/shared-ci
    ref: main
    file: frontend.yml
 
  # una URL que se descarga con un GET normal por HTTP(S)
  - remote: https://example.com/ci/base.yml
 
  # templates que vienen con GitLab
  - template: Jobs/SAST.gitlab-ci.yml

Cada una tiene su caso. local sirve para partir la configuración de un repo en pedazos legibles. template trae los jobs que GitLab mantiene, y el motivo habitual son los escáneres de seguridad. remote descarga por HTTP(S) plano: funciona para configuración genuinamente pública y se complica con cualquier cosa que pida credenciales. project es la variante para una librería compartida: el archivo vive en la misma instancia de GitLab, así que aplican los permisos normales de proyecto, y ref fija una rama, un tag o un SHA de commit. Todo lo que sigue usa esta última.

La fusión se comporta de forma predecible. Los archivos incluidos se combinan con el tuyo y, cuando la misma key existe en ambos, gana tu archivo. Los mapas se fusionan en profundidad; todo lo demás (los arrays incluidos) se reemplaza completo, así que redefinir el script de un job en el consumidor sustituye la versión de la librería en lugar de agregarle líneas. Los includes además se anidan: un archivo incluido puede incluir otros, con un límite por defecto de 150 archivos por pipeline.

Hidden jobs como API pública

En la parte 1 usamos hidden jobs (los que empiezan con .) para no repetir bloques dentro de un mismo archivo. Entre repositorios se convierten en algo más deliberado: la API pública de la librería. El repo compartido no define ni un solo job ejecutable, únicamente jobs ocultos, y cada consumidor instancia los que necesita con extends:

# group/shared-ci — frontend.yml
.frontend_base:
  image: node:22-slim
  tags: [frontend-k8s]
  before_script:
    - corepack enable
    - pnpm install --frozen-lockfile
 
.frontend_build:
  extends: .frontend_base
  stage: build
  script: pnpm build
  artifacts:
    paths: [dist/]
    expire_in: 1 day

Con eso, el archivo completo de un consumidor se reduce a un include y una lista de jobs de una línea:

# my-app — .gitlab-ci.yml
include:
  - project: group/shared-ci
    ref: main
    file: frontend.yml
 
build:
  extends: .frontend_build
 
deploy-preview:
  extends: .frontend_deploy_preview
  variables:
    APP_NAME: my-app

Nuestros consumidores pasaron de ~300 líneas a ~130, y la mayoría de esas 130 tienen exactamente esta forma: un nombre, un extends y, de vez en cuando, una variable. La documentación es explícita en que combinar extends con include es la manera soportada de reutilizar configuración entre archivos. La fusión es un deep merge inverso: las keys definidas en el job que extiende pisan a las de la base, y los arrays como script se intercambian completos, nunca se concatenan. Ese reemplazo total es a la vez la válvula de escape del consumidor y el mayor riesgo de la librería: un consumidor que define script: en un job extendido acaba de descartar, en silencio, todo lo que hacía el script original.

Una convención que nos rindió bien: todos los hidden jobs de la librería llevan el prefijo .frontend_, de modo que nunca chocan con un hidden job que un consumidor defina localmente para sus propios fines.

Un vocabulario de rules con !reference

La parte 5 fue sobre rules, y las rules eran justo la zona más degradada de las diez copias: strings de if: largos como $CI_MERGE_REQUEST_EVENT_TYPE == "merged_result" pegados en cada archivo, cada copia a un typo de distancia de un job que jamás vuelve a correr. Dentro de un solo archivo eso se resuelve con anchors de YAML, pero los anchors solo valen en el archivo donde se definieron y no cruzan la frontera de un include. Para eso existe el tag !reference: selecciona una sección de cualquier job, oculto o no, incluidos los que llegaron vía include.

Así que la librería define un vocabulario. Un hidden job, .shared_vars, guarda fragmentos de rules con nombres descriptivos:

# group/shared-ci — frontend.yml
.shared_vars:
  we_are_in_merge_result_pipeline:
    - if: '$CI_MERGE_REQUEST_EVENT_TYPE == "merged_result"'
  we_are_pushing_to_default_branch:
    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  this_is_a_scheduled_run:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'

GitLab nunca valida los hidden jobs como jobs, así que esas keys inventadas son legales; es un namespace gratis. Tanto los jobs de la librería como los consumidores usan el vocabulario por referencia:

.frontend_deploy_preview:
  rules:
    - !reference [.shared_vars, we_are_in_merge_result_pipeline]

La regla se lee como una oración, y el string de la condición existe en un único lugar para los diez repositorios. Cuando después movimos los microfrontends a pipelines de merged results (parte 6), el predicado cambió una sola vez, en la librería, y todos los consumidores lo heredaron. Un consumidor también puede mezclar entradas del vocabulario con rules propias en la misma lista; GitLab aplana la lista referenciada en su lugar.

El contrato con los consumidores

Una línea de include crea una dependencia, y las dependencias necesitan contrato. El nuestro vive en el README de shared-ci y tiene cuatro cláusulas:

  • Variables de CI/CD obligatorias. Cada consumidor define APP_NAME y sus variables de deploy en los settings del proyecto. Si faltan, los jobs de la librería fallan de inmediato con un mensaje claro, en vez de fallar a la mitad con uno críptico.
  • Archivos obligatorios en el repo. Un package.json que exponga los scripts build, lint y test, más el lockfile. La librería llama a los scripts por nombre y nunca intenta adivinar el tooling de cada repo.
  • Overrides documentados. Qué keys puede sobrescribir un consumidor sin peligro (variables, rules adicionales) y cuáles quedan bajo su propia responsabilidad (el script de los jobs de deploy).
  • Seguro por defecto. Los escaneos de seguridad corren en todos los consumidores sin que nadie los pida. Salirse exige una variable explícita, SKIP_DEPENDENCY_SCAN: "true", escrita en el YAML del propio consumidor.

El diseño del opt-out importa más de lo que aparenta. Como la salida de emergencia es un string literal en el archivo del consumidor, un solo grep sobre el grupo lista quién está saltándose el escáner, sin dashboards de por medio, y cada opt-out pasó por un merge request revisado.

Publicar cambios cuando todos siguen main

Nunca versionamos la librería con tags. Todos los consumidores fijan ref: main, lo que significa que un merge en shared-ci llega a diez repos de inmediato: barato de operar y exactamente tan peligroso como suena. La disciplina que lo volvió seguro es un rollout de piloto y regreso:

  1. Abres una rama en shared-ci con el cambio.
  2. Eliges un consumidor como piloto y apuntas su ref: a la rama nueva, en un merge request.
  3. Dejas correr pipelines reales en el repo piloto e iteras sobre la rama de la librería hasta que queden en verde. El cambio se valida con jobs reales contra un proyecto real antes de que lo vea nadie más.
  4. Regresas el ref: del piloto a main, haces merge de la rama de la librería, y los otros nueve repos lo reciben en su siguiente pipeline.

El rollout que confirmó el modelo fue una migración de runners. Movimos la flota del tag frontend-ci a frontend-k8s, algo que en la era del copy-paste habría sido diez merge requests y una semana de rezagados. Con la librería fue una edición de tags: en .frontend_base, una validación piloto y un merge. Ningún consumidor tocó su propio YAML; el cambio viajó por la herencia de extends.

Seguir main tiene un corolario defensivo. El bloque default: de un consumidor aplica a todos los jobs de su pipeline, incluidos los que vinieron de la librería, siempre que el job no defina la key por sí mismo. Por eso la librería sobreespecifica a propósito: tags, cache y before_script van escritos explícitamente en los jobs que importan, incluso donde una base compartida habría bastado, porque un consumidor que agrega un default: inocente no debe redirigir en silencio el deploy de la librería hacia otro runner.

El repo de la librería también corre su propio pipeline: yamllint sobre sus templates en cada merge request. La configuración es modesta — el preset relaxed, con line-length ampliado, porque las líneas de !reference [.shared_vars, ...] son largas y partirlas no ayuda a nadie:

# .yamllint
extends: relaxed
rules:
  line-length:
    max: 160

El bloque de cache que viajó comentado

El bloque más instructivo de la librería es uno que no hace nada:

.frontend_base:
  # Cache deshabilitado a propósito. Nuestros runners no tienen
  # backend de object storage ([runners.cache] sin configurar), así
  # que archivar el store de pnpm en cache local del pod resultó ser
  # puro desperdicio medido: ~90s por job y cero hits cuando el pod
  # se recicla. Ver la parte 1. Cuando exista el backend, restaurar:
  # cache:
  #   key:
  #     files: [pnpm-lock.yaml]
  #   paths: [.pnpm-store]
  cache: []

Esta es la trampa del cache en runners self-hosted de la parte 1, cuatro años después en tiempo de la serie, todavía sin arreglo a nivel de infraestructura — y la configuración compartida lo admite por escrito. cache: [] fuerza el cache apagado aunque un consumidor traiga un default: cache:, el comentario le explica el porqué a quien lea cualquiera de los diez repos, y la configuración real está ahí mismo, esperando. El día que el backend de object storage quede configurado en el runner, descomentar ese bloque es un solo merge request, y calienta diez repositorios de una vez.

Con qué te quedas

Diez repos consumen ahora una sola definición de pipeline: hidden jobs como API, un vocabulario de rules compartido vía !reference, y un ritual de piloto y regreso en lugar de tags de versión. Los archivos de los consumidores bajaron de ~300 líneas a ~130, y una migración de runners que antes costaba diez merge requests hoy cuesta uno.

La librería funciona porque los diez repos tienen la misma forma: mismo stack, mismos stages, misma historia de deploy. La parte 9 entra al repo donde esa premisa se rompe: un monorepo con ~50 paquetes y 8 apps, donde no puedes reconstruir el mundo en cada commit y el pipeline tiene que averiguar qué cambió realmente.

Referencias