Saltar al contenido

Tu primer pipeline en GitLab CI: jobs, stages y un cache que sí funciona

Una guía desde cero de .gitlab-ci.yml: jobs, stages, runners, variables y un cache de dependencias basado en el lockfile. Incluye la trampa del cache en runners self-hosted que nos costó 90 segundos por job a cambio de nada.

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

Haces push y no pasa nada. Nadie corre el lint, nadie corre los tests, nadie hace el build. Si el código funciona o no, te vas a enterar después, casi siempre porque alguien más lo encontró roto. También existe el escenario contrario, que es igual de común: el repo ya tiene su .gitlab-ci.yml, un archivo de cuatrocientas líneas que escribió alguien que ya no está en la empresa, y a estas alturas la estrategia del equipo es no tocarlo jamás, no vaya a ser que se rompa.

Los dos problemas se arreglan igual: entendiendo el puñado de primitivas sobre las que está construido GitLab CI, que son menos de las que parece. En este post vamos a armar un pipeline para una app de Node partiendo de un archivo vacío: jobs, stages, runners, variables, una primera pasada de reutilización y un cache de dependencias. Todos los ejemplos vienen de configuración real que corre en producción (limpiada de datos internos), incluida la historia del cache que pasó meses sin hacer absolutamente nada.

Un pipeline son jobs agrupados en stages

Cuando haces push, GitLab busca el archivo .gitlab-ci.yml en la raíz del repo y crea un pipeline: un conjunto de jobs organizados en stages. El job es la unidad de trabajo. Ejecuta tus comandos en un entorno limpio y termina en verde o en rojo. Así se ve un pipeline mínimo (y real) para una app con pnpm:

image: node:24-slim
 
stages:
  - quality
  - build
 
lint:
  stage: quality
  script:
    - corepack enable
    - pnpm install --frozen-lockfile
    - pnpm lint
 
typecheck:
  stage: quality
  script:
    - corepack enable
    - pnpm install --frozen-lockfile
    - pnpm typecheck

Luego el stage build, que solo arranca cuando quality pasa:

build-app:
  stage: build
  script:
    - corepack enable
    - pnpm install --frozen-lockfile
    - pnpm build

Las reglas del juego:

  • Cualquier clave de primer nivel que tenga un script es un job. El nombre lo eliges tú.
  • stages define el orden de ejecución. Los jobs de un mismo stage corren en paralelo, si hay runners disponibles: lint y typecheck avanzan al mismo tiempo.
  • Un stage arranca solo cuando el anterior terminó completo y en verde. Si falla lint o falla typecheck, build-app nunca corre. Justamente para eso sirven los stages: los chequeos baratos van primero, y el trabajo caro se ejecuta solo cuando lo barato ya pasó.
  • Cada job parte de un checkout limpio del repo, dentro de un contenedor nuevo de image. Nada sobrevive de un job al siguiente, salvo que lo conserves a propósito, y para eso existen el cache y los artifacts (el cache lo vemos más abajo).

Esa última regla es el modelo mental que explica la mayoría de las confusiones con CI: un job es una máquina desechable que clona tu repo, ejecuta tu script y desaparece, no una terminal que dejaste abierta.

Menos repetición: default y before_script

Tres jobs, tres preámbulos idénticos. Para eso GitLab tiene before_script, un lugar dedicado para los comandos de preparación, y un bloque default del que todos los jobs heredan:

default:
  image: node:24-slim
  before_script:
    - corepack enable
    - pnpm install --frozen-lockfile
 
stages:
  - quality
  - build
 
lint:
  stage: quality
  script: pnpm lint
 
typecheck:
  stage: quality
  script: pnpm typecheck
 
build-app:
  stage: build
  script: pnpm build

before_script corre antes de script en el mismo shell, así que todo lo que prepare queda disponible para tus comandos. Y cualquier job puede sobrescribir cualquier default declarando su propio valor. Hoy eso es pura comodidad; cuando empieces a compartir configuración de CI entre varios repos se vuelve un arma de doble filo.

Dónde corren los jobs: runners

Un runner es el proceso que toma los jobs y los ejecuta. Si tu proyecto vive en GitLab.com, los instance runners vienen incluidos y funcionan sin configurar nada; por eso el archivo de arriba corre tal cual. Pero en la mayoría de las empresas te vas a topar con runners propios (self-hosted), ya sea por costo, por velocidad o porque los jobs necesitan acceso de red a sistemas internos. Esos runners se registran con tags, y los jobs los seleccionan por tag:

default:
  image: node:24-slim
  tags:
    - frontend-k8s

Si un job se queda en pending eternamente, revisa primero los tags: casi siempre significa que ningún runner activo coincide con lo que pediste.

Sobre la elección de image: usa la imagen más pequeña que tenga tu toolchain y fija la versión (node:24-slim, no node:latest), para que tus builds no cambien sin que te enteres solo porque alguien publicó una imagen nueva.

Variables: los tres lugares donde viven

Vas a definir variables en tres lugares, de lo más público a lo más sensible:

Globales, en el archivo. Visibles para todos los jobs. Perfectas para rutas y flags:

variables:
  STORE_DIR: $CI_PROJECT_DIR/.pnpm-store

Por job. La misma sintaxis dentro del job, con alcance solo para él:

build-app:
  stage: build
  variables:
    NODE_OPTIONS: --max-old-space-size=4096
  script: pnpm build

En la configuración del proyecto, para los secretos. Settings → CI/CD → Variables en la interfaz de GitLab. Los valores viven fuera del repo, y puedes marcarlos como masked (se ocultan en los logs) y protected (solo se exponen en ramas protegidas). Tokens de deploy, API keys, tokens de npm: todo eso va ahí, nunca en el YAML.

Además de las tuyas, GitLab inyecta docenas de variables predefinidas en cada job. Tres que vas a usar todo el tiempo:

  • CI_COMMIT_BRANCH: la rama que se está construyendo.
  • CI_DEFAULT_BRANCH: normalmente main. Comparar estas dos es el clásico chequeo de «¿estoy en main?».
  • CI_PROJECT_DIR: la ruta absoluta del checkout, útil para construir otras rutas (la acabas de ver en STORE_DIR).

Reutilizar sin copy-paste: hidden jobs y extends

Un job cuyo nombre empieza con punto nunca se ejecuta. Eso lo convierte en una plantilla, y con extends los jobs reales heredan de él:

.node-defaults:
  image: node:24-slim
  tags:
    - frontend-k8s
  before_script:
    - corepack enable
    - pnpm install --frozen-lockfile
 
lint:
  extends: .node-defaults
  stage: quality
  script: pnpm lint

Esto se parece a lo que ya hacía default, y para una sola configuración default necesita menos sintaxis. Los hidden jobs empiezan a valer la pena cuando necesitas dos familias de defaults en el mismo archivo (por ejemplo, jobs de Node y jobs de deploy con imágenes distintas), o cuando las plantillas viven en otro repo y diez proyectos heredan de ellas. A ese escenario llegamos en la parte 8 de la serie, y extends es lo que lo hace posible.

Los anchors de YAML (&nombre / *nombre) resuelven el mismo problema y los vas a encontrar en configs ajenas, pero solo funcionan dentro de un mismo archivo, mientras que extends también funciona entre archivos incluidos. Yo uso extends por defecto.

Cache desde cero: deja de reinstalar el mundo

Cada job de nuestro pipeline corre pnpm install desde cero. En una app real eso significa minutos por job, multiplicados por cada job, en cada push. La solución es un cache: directorios que GitLab archiva al final de un job y restaura al inicio del siguiente.

La decisión crítica es la key del cache. Conviene basarla en el lockfile: mientras las dependencias no cambien se reutiliza el mismo cache, y cuando cambian se genera uno nuevo:

default:
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - .pnpm-store
  before_script:
    - corepack enable
    - pnpm config set store-dir $CI_PROJECT_DIR/.pnpm-store
    - pnpm install --frozen-lockfile

Dos detalles que importan:

  • Las rutas en paths tienen que vivir dentro del directorio del proyecto. El store de pnpm por defecto queda en el home del usuario, y por eso lo redirigimos al checkout con store-dir.
  • key: files: calcula un hash del lockfile. Cuando el lockfile cambia, cambia la key y los jobs arrancan un cache nuevo; mientras no cambie, se sigue reutilizando el anterior. Nunca invalidas nada a mano.

El primer job con una key nueva paga el precio completo y sube el cache. Todos los siguientes deberían mostrar Restoring cache en el log e instalar en segundos. Antes de confiar en eso, verifícalo en el log de un job real.

La trampa del cache en runners self-hosted

En los instance runners de GitLab.com el cache es distribuido: se guarda en object storage y cualquier runner puede restaurar lo que otro guardó. Como funciona sin configurar nada, te malacostumbra: terminas creyendo que con escribir cache: en el YAML ya está.

Con runners self-hosted esa suposición se rompe, y se rompe en silencio. Por defecto, un runner self-hosted guarda el cache localmente, en el lugar donde se ejecutó el job. Con el executor de Docker eso significa un volumen en esa máquina: el cache funciona por máquina, y hay misses cada vez que otra máquina toma tu job. Con el executor de Kubernetes la cosa es peor: el cache se escribe dentro del pod del job, el pod se destruye al terminar, y no queda nada. Cada job es un cache miss, para siempre.

Y nada se ve roto. El YAML es idéntico al que funciona en GitLab.com, los jobs salen en verde, y el log hasta imprime Saving cache justo antes de que el pod se evapore con el archivo adentro.

Esto no se arregla desde .gitlab-ci.yml. La configuración vive en el config.toml del propio runner, así que le toca a quien lo opera. El cache necesita un backend de object storage:

[runners.cache]
  Type = "s3"
  Shared = true
  [runners.cache.s3]
    ServerAddress = "s3.amazonaws.com"
    BucketName = "ci-runner-cache"
    BucketLocation = "us-east-1"

Type puede ser s3, gcs o azure. Las credenciales pueden ir en la config o, en AWS, salir del perfil IAM de la instancia. Shared = true permite que todos los runners lean y escriban el mismo bucket, que es lo que hace posibles los cache hits entre máquinas y pods. La referencia completa está en la documentación del runner.

Lo que nos mordió en producción

Corrimos exactamente este setup (cache declarado en el YAML, executor de Kubernetes, sin backend de cache) durante meses sin darnos cuenta, porque nadie cuestiona un pipeline que está en verde.

Lo que nos hizo mirar no fue un error. Los jobs simplemente tardaban más de lo que tenía sentido, así que nos pusimos a cronometrar las secciones del log, y ahí apareció el número raro: unos 90 segundos al final de cada job, gastados en Saving cache, archivando el store de pnpm, que son más o menos 165 000 archivos pequeños.

La pregunta obvia era cuándo se restauraba todo eso. Fuimos a buscar un Restoring cache que viniera de una ejecución anterior y no existía ninguno, en ningún job, en ninguna rama. No podía existir: el cache se escribía dentro del pod, y el pod moría segundos después. Cada job pagaba 90 segundos para calentar un cache que ningún job iba a poder leer jamás.

El arreglo provisional fue asumir la realidad: deshabilitamos el cache por completo (nuestra config compartida resuelve el bloque a cache: []) y dejamos la configuración real comentada, lista para el día en que el runner tenga su backend de object storage. Instalar es más lento que con un cache que funciona, pero dejamos de pagar 90 segundos por job a cambio de nada.

No le creas al bloque cache:, créele al log. Revisa que aparezca Restoring cache, que restaure de una ejecución anterior a la tuya, y que el tiempo de instalación de verdad haya bajado. Cinco minutos haciendo eso nos habrían ahorrado meses.

Con qué te quedas

A estas alturas ya puedes leer casi cualquier .gitlab-ci.yml que te cruces, y escribir uno desde cero que no reinstale el mundo en cada job.

Los siguientes problemas aparecen apenas el pipeline crece, y las próximas tres partes los atacan en orden: la parte 2 vuelve eficiente el cache (políticas, fallback keys y un cache partido entre ramas protegidas y normales que GitLab no anuncia), la parte 3 elimina las recompilaciones con artifacts, y la parte 4 reemplaza las barreras de stage por un grafo de dependencias de verdad.

Referencias