Saltar al contenido

Pipelines de monorepo en GitLab CI: todo selectivo

Cincuenta paquetes y ocho apps en un solo repo: recompilar el mundo en cada commit no es una opción. Detección de cambios con rules:changes y compare_to, un anchor de YAML por app, un task runner que ya conoce el grafo de dependencias, y la semana en que los runners se quedaron sin disco.

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

La parte 8 cerró con una librería de CI compartida: diez repos casi idénticos, una sola definición de pipeline, adiós al drift. El monorepo es la otra respuesta al mismo problema. En vez de diez repos que incluyen una config, tienes un repo que contiene las diez apps. En nuestro caso eran ocho apps desplegables y alrededor de cincuenta paquetes internos que todas comparten.

Lo que cambia es la economía. Con repos separados, cada pipeline viene acotado de fábrica: un push al repo del storefront solo puede construir el storefront. En un monorepo, corregir un typo en docs/ vive en el mismo repo que todo lo demás, y un pipeline ingenuo construye, lintea y testea el mundo entero sin pestañear. Con cincuenta paquetes, eso es un pipeline de cuarenta minutos por un commit de una línea.

Así que el juego completo se reduce a seleccionar: ejecutar exactamente lo que el commit afecta y nada más. Cada sección de este post es una capa distinta de esa selección.

Ejecutar solo lo que cambió

En la parte 5, rules:changes era una condición más entre varias. En un monorepo se convierte en la pared maestra, y su comportamiento por defecto tiene un filo que conviene entender antes de apoyarse en él. Qué significa «cambió» depende del tipo de pipeline: en los pipelines de merge request, GitLab compara contra la rama destino del MR; en los pipelines de rama, contra el commit anterior de esa rama; y en ramas nuevas, o en pipelines sin evento de push (tags, schedules, ejecuciones manuales), changes siempre evalúa a verdadero y el job corre sí o sí.

La comparación contra el commit anterior muerde fuerte. Haces push de un commit que toca apps/storefront, el deploy falla, y haces push de un segundo commit que solo corrige un typo en la documentación. Ese segundo pipeline compara únicamente contra el commit anterior, no ve ningún cambio en el storefront, y el job de deploy desaparece en silencio justo del pipeline que debía reintentarlo. rules:changes:compare_to lo arregla fijando la base de comparación:

build-storefront:
  script: pnpm turbo run build --filter=storefront...
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - changes:
        paths:
          - apps/storefront/**/*
          - packages/**/*
          - pnpm-lock.yaml
        compare_to: $CI_DEFAULT_BRANCH

Con compare_to, todos los pipelines de una rama de feature ven el diff completo de la rama contra main, así que un commit de seguimiento ya no puede perder jobs por el camino. La primera regla existe porque comparar main contra sí misma da un diff vacío: en la rama principal saltamos la detección de cambios y corremos todo.

Dos notas sensibles a la versión. Durante años compare_to no expandía variables de CI/CD, y en cada hilo sobre monorepos circulaba algún workaround para no hardcodear nombres de rama; esa limitación ya no existe. Desde GitLab 17.2, variables como $CI_DEFAULT_BRANCH funcionan sin problema (el issue #369916 está cerrado). La otra: compare_to se comporta de forma inesperada en merged results pipelines, porque ahí la base de comparación es un commit interno que GitLab fabrica; nuestro monorepo corre solo pipelines de rama (la decisión que contamos en la parte 6), lo cual esquiva ese problema por completo. Y dos límites prácticos para tener presentes: una sección de rules:changes acepta máximo 50 patrones, y las rutas se comparan como strings exactos, así que ./apps/storefront no matchea nada.

Un anchor por app

Ocho apps, cada una con su job de build y su job de deploy, y cada uno de esos jobs necesita los mismos tres datos: qué rutas pertenecen a la app, qué paquete filtrar, a dónde desplegar. Copiar y pegar esos datos en dieciséis bloques de rules recrea el problema de drift de la parte 8, solo que dentro de un mismo archivo. Un anchor de YAML concentra los datos de cada app en un único bloque:

.storefront: &storefront
  APP_NAME: storefront
  APP_PATH: apps/storefront
  DEPLOY_TARGET: storefront-production
 
.app-job:
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - changes:
        paths:
          - $APP_PATH/**/*
          - packages/**/*
          - pnpm-lock.yaml
        compare_to: $CI_DEFAULT_BRANCH

Cada job de una app extiende el esqueleto compartido y apunta al anchor que le corresponde:

build-storefront:
  extends: .app-job
  variables: *storefront
  script: pnpm turbo run build --filter="$APP_NAME..."
 
deploy-storefront:
  extends: .app-job
  stage: deploy
  variables: *storefront
  script: ./scripts/deploy.sh "$DEPLOY_TARGET"

Esto funciona porque las rutas de rules:changes sí expanden variables de CI/CD, incluidas las definidas a nivel de job; la documentación de rules:changes muestra exactamente este patrón. Sumar una novena app cuesta un anchor y dos jobs de cuatro líneas, y como las rutas de cambio, el filtro de build y el destino de deploy salen del mismo bloque, no pueden contradecirse entre sí. Una advertencia: los anchors no cruzan los límites de un include (por eso la librería compartida de la parte 8 necesitaba !reference). En una config de monorepo que vive en un solo archivo, el anchor es la herramienta más liviana.

El task runner ya conoce el grafo

La detección de cambios decide si los jobs del storefront corren. No dice nada sobre lo que pasa adentro. El storefront depende de unos veinte de los cincuenta paquetes, varios de esos paquetes dependen entre sí, y los tests necesitan que sus dependencias estén compiladas primero. Ese grafo no quieres expresarlo en YAML, porque ya existe en los package.json del repo. Nuestro stack es pnpm workspaces más Turborepo, donde las dependencias entre tareas se declaran una sola vez:

{
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
    "lint": {},
    "typecheck": { "dependsOn": ["^build"] },
    "test": { "dependsOn": ["lint", "typecheck", "^build"] }
  }
}

^build significa «compila primero mis dependencias». Con eso declarado, el script de CI queda en un solo comando, pnpm turbo run test --filter=storefront..., donde el sufijo ... es la sintaxis de filtros de pnpm para «este paquete y todo aquello de lo que depende». Ese único comando compila los paquetes afectados en orden de grafo, y después lintea, verifica tipos y testea, paralelizando donde el grafo lo permite. El pipeline elige la app; el task runner elige el trabajo. Cuando alguien agrega una dependencia entre dos paquetes no hay que tocar ningún YAML: el grafo que lee el task runner es el mismo package.json que acaba de editar.

Que el CI se quede tonto es deliberado. Cada script es un comando filtrado, y toda la inteligencia sobre orden y omisión vive en la herramienta que también corre en las laptops, así que las corridas locales y las de CI no pueden divergir.

Un solo build alimenta todo

El patrón de compilar una vez, que vimos en la parte 3, vuelve aquí a otra escala. Incluso con filtros, ocho jobs de app compilando cada uno los paquetes compartidos significa que el mismo grafo de packages/* se compila hasta ocho veces por pipeline. Un job al frente lo compila una sola vez y reparte el resultado como artifacts:

validate-and-build:
  stage: build
  script:
    - pnpm install --frozen-lockfile
    - pnpm turbo run build --filter="./packages/*"
  artifacts:
    paths:
      - packages/*/dist
    expire_in: 3 hours
 
test-storefront:
  stage: test
  needs: [validate-and-build]
  script: pnpm turbo run test --filter=storefront...

Los jobs de abajo declaran needs sobre el hub (parte 4), reciben packages/*/dist extraído en su directorio de trabajo, y Turborepo encuentra outputs frescos y se salta esas compilaciones. El expire_in: 3 hours es corto a propósito: son outputs intermedios que se consumen minutos después de producirse, y cualquier reintento dentro de la jornada todavía los encuentra. Ese número no siempre fue de tres horas; la sección del incidente explica por qué dejó de ser una semana.

Cuatro capas de cache

Aun con ejecución selectiva y un hub de build, queda trabajo repetido entre pipelines, y ningún cache lo cubre todo por sí solo. Lo que nos quedó al final fue una estructura en capas:

  • El store de pnpm, con la key basada en el lockfile y su fallback key: el montaje de la parte 2, que a escala de monorepo no cambia en nada.
  • Caches de herramientas por rama (el cache de ESLint, el del test runner) con key sobre $CI_COMMIT_REF_SLUG, para que el segundo push a una rama solo re-lintee lo que cambió.
  • Un cache de build del framework por app (el directorio incremental estilo .next/cache), con key por app y por rama.
  • El cache remoto del task runner: Turborepo hashea los inputs de cada tarea y guarda los outputs en un cache HTTP compartido, así que un paquete que no cambió desde cualquier corrida anterior, en cualquier rama y en cualquier runner, reproduce su log de build en lugar de recompilar.
test-storefront:
  cache:
    - key:
        files: [pnpm-lock.yaml]
      paths: [.pnpm-store]
      policy: pull
    - key: lint-$CI_COMMIT_REF_SLUG
      paths: [.eslintcache]

GitLab admite hasta cuatro entradas de cache por job, que aquí alcanzan justo. La capa interesante es el cache remoto de tareas, porque vive completamente fuera del mecanismo de cache de GitLab: su key es un hash de contenido y no una rama, así que no le aplica la separación protected/non-protected de la parte 2, y un paquete compilado en main sí calienta de verdad las ramas de feature.

La semana en que los runners se quedaron sin disco

Toda esta selección multiplicó la cantidad de jobs por pipeline, y unos meses después los jobs empezaron a morir con ENOSPC: no space left on device. Jobs al azar, proyectos al azar, casi siempre a mitad del install. Los runners eran compartidos entre equipos, así que todos los equipos lo sufrían y ninguno podía reproducirlo.

La medición fue la parte incómoda: cada job nuestro dejaba alrededor de 11 GB de residuos en el host del runner. Artifacts de dist/ extraídos, directorios de build del framework por app, caches desempacados en el árbol de trabajo. Y como el executor reutiliza directorios de trabajo entre jobs por velocidad, los restos se acumulaban hasta llenar el disco, y entonces moría cada job agendado en ese host, fuera de quien fuera.

Tres arreglos, ninguno ingenioso:

default:
  after_script:
    - rm -rf apps/*/dist packages/*/dist .turbo node_modules/.cache
 
variables:
  GIT_CLEAN_FLAGS: -ffdx

La limpieza en after_script corre incluso cuando el job falla, así que los outputs pesados nunca sobreviven al job que los produjo. GIT_CLEAN_FLAGS controla el git clean que el runner ejecuta al inicio de cada job; con -ffdx barre los archivos sin trackear e ignorados que dejó lo que corrió antes en ese directorio. Está documentado en la configuración de runners, y es también la palanca que alguien pone en none para «ahorrar tiempo», que es exactamente cómo los residuos se vuelven inmortales. Por último, la expiración de artifacts pasó de una semana a tres horas, porque outputs que se consumían en minutos estaban ocupando almacenamiento durante siete días, multiplicado por cada pipeline y cada app. Las gráficas de disco se aplanaron y se quedaron planas.

Con qué te quedas

El pipeline ahora detecta qué apps toca un commit, delega el trabajo real en un task runner que conoce el grafo de dependencias, compila el código compartido una sola vez y limpia lo que ensucia. Construir y testear de forma selectiva es, a estas alturas, un problema resuelto.

Publicar no lo es. Esos cincuenta paquetes también se versionan y publican desde este mismo pipeline, y un job de release que empuja el commit del bump de versión al repo dispara un pipeline nuevo, que quiere publicar otra vez. La parte 10 trata de publicar paquetes desde CI sin dispararte el pipeline a ti mismo.

Referencias