Jenkins resolvía el problema de "quiero automatizar cualquier cosa" y dejaba todo lo demás —repositorio, registro, entornos, seguridad— en manos de plugins o de otros productos. GitLab parte de la posición contraria: un solo producto que contiene el repositorio, la revisión de código, el CI/CD, el registro de contenedores, los entornos, el análisis de seguridad, los issues y la wiki, con todo conectado de fábrica. Esa apuesta arquitectónica —lo que en su marketing llaman DevOps platform y aquí llamaremos simplemente plataforma integrada— es lo que hay que entender para evaluarla bien, porque explica tanto sus mayores virtudes como sus costes. Además, GitLab CI es históricamente importante: su .gitlab-ci.yml (2015) fue de los primeros en normalizar el pipeline as code integrado en el repositorio, y su modelo de stages con jobs dentro es el que muchos ingenieros llevan en la cabeza cuando piensan "pipeline". En esta lección veremos ese modelo, su evolución de etapas fijas a grafo con needs, el pipeline de Reservalia traducido por segunda vez, la distinción entre cache y artifacts que más confusión genera, los runners y sus executors, los entornos y review apps, los mecanismos de reutilización, y qué cuesta operar una instancia propia.

Contenido

  1. La plataforma integrada: qué es y qué implica
  2. El modelo de ejecución: stages, jobs y el paso al grafo
  3. El pipeline de Reservalia en .gitlab-ci.yml
  4. cache frente a artifacts: la distinción que más confunde
  5. Paralelización: parallel y parallel: matrix
  6. Runners y executors
  7. Variables, secretos y protección
  8. Entornos, despliegue manual y review apps
  9. Reutilización: extends, include, componentes y pipelines padre-hijo
  10. El registro de contenedores integrado
  11. Auto DevOps, con criterio
  12. SaaS frente a autoalojado, y el coste real
  13. Cuándo GitLab CI/CD es la elección correcta
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. La plataforma integrada: qué es y qué implica

En una organización típica con GitHub, la cadena de herramientas se compone: GitHub para el código, GitHub Actions para el CI, ECR o Docker Hub para el registro, Snyk o Dependabot para las dependencias, Jira para los issues, algo aparte para los entornos. En GitLab, todo eso son pestañas del mismo proyecto.

Lo que se gana, y no es retórica:

  • Cero integraciones que mantener entre piezas. No hay tokens cruzados entre el CI y el registro, ni webhooks que se rompen, ni "el bot de Jira perdió permisos". El job de CI empuja al registro del mismo proyecto con una variable que ya existe.
  • Trazabilidad de extremo a extremo. Del issue al merge request, al pipeline, al artefacto, al entorno donde está desplegado. La pregunta "¿qué versión está en producción y qué issues incluye?" tiene respuesta en la interfaz sin construir nada.
  • Un solo modelo de permisos. Quien puede fusionar, puede desplegar a staging pero no a producción, y eso se configura una vez.
  • Funcionalidades que solo existen porque las piezas están juntas: los informes de seguridad que aparecen dentro del merge request comparando la rama con main, los entornos que muestran qué commit está desplegado, las review apps con su enlace en el MR.

Lo que se paga, con la misma franqueza:

  • Acoplamiento fuerte. Salir de GitLab significa salir de todo a la vez: repositorio, CI, registro e historial de entornos. Es el coste de cambio más alto del módulo, y la 06-07 lo cuantifica.
  • Las funcionalidades interesantes están escalonadas por nivel de suscripción. Buena parte de lo que se cuenta en artículos y conferencias —aprobaciones múltiples, algunos análisis de seguridad, ciertos controles de cumplimiento— no está en el nivel gratuito. No conviene diseñar una arquitectura sobre una funcionalidad y descubrir después en qué nivel vive.
  • Si te lo autoalojas, operas una plataforma entera, no un servidor de CI: base de datos, almacenamiento de objetos, Redis, Gitaly, el registro, los runners. Apartado 12.

Una nota de vocabulario útil: en GitLab, merge request (MR) es lo que en GitHub es pull request. El concepto es el mismo que la 02-07 describió.

  1. El modelo de ejecución: stages, jobs y el paso al grafo

El modelo original de GitLab CI es de etapas secuenciales con jobs paralelos dentro:

flowchart LR
    subgraph S1["stage: preparar"]
      A["preparar"]
    end
    subgraph S2["stage: verificar"]
      B["calidad"]
      C["test 1/4"]
      D["test 2/4"]
      E["test 3/4"]
      F["test 4/4"]
    end
    subgraph S3["stage: construir"]
      G["build"]
      H["seguridad"]
    end
    subgraph S4["stage: publicar"]
      I["publicar"]
    end
    S1 --> S2 --> S3 --> S4

La regla clásica: todos los jobs de una etapa corren en paralelo, y la etapa siguiente no empieza hasta que la anterior termina entera. Es un modelo fácil de razonar y con un defecto claro: si test 4/4 tarda ocho minutos y los demás dos, build espera ocho minutos aunque solo dependiera de preparar. Es exactamente el problema de la barrera de etapa que la 04-01 describió.

La corrección llegó con needs, que convierte el pipeline en un DAG: un job con needs arranca en cuanto sus dependencias concretas terminan, sin esperar a su etapa. Con needs, las stages pasan de ser barreras a ser sobre todo agrupación visual.

Modelo por stages Modelo por needs (DAG)
Arranque de un job Cuando termina toda la etapa anterior Cuando terminan sus dependencias
Facilidad de lectura Muy alta Media: hay que seguir el grafo
Tiempo total Suma de los máximos por etapa Camino crítico real
Riesgo Esperas innecesarias Grafos enmarañados si nadie los revisa

Vocabulario de GitLab traducido:

GitLab Equivalente en el curso
Pipeline Ejecución de workflow
Stage Etapa (agrupación)
Job Job
Script (una línea de script:) Step
Runner Runner / agente
Executor Cómo materializa el runner el entorno (shell, docker, kubernetes)
Artifacts Artefactos entre jobs y descargables
Environment Entorno de despliegue con historial

  1. El pipeline de Reservalia en .gitlab-ci.yml

Segunda traducción del mismo pipeline: instalar con caché → lint y test en paralelo → build de imagen → publicar por digest.

# .gitlab-ci.yml — Reservalia · CI
stages: [preparar, verificar, construir, publicar, desplegar]   # 1

default:                                                        # 2
  image: node:22-bookworm
  interruptible: true                                           # cancela si llega un push nuevo
  retry:
    max: 2
    when: [runner_system_failure, stuck_or_timeout_failure]     # 3 · reintentar solo fallos de infraestructura

variables:
  NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"                      # 4 · caché dentro del workspace
  IMAGEN: "$CI_REGISTRY_IMAGE/api"
  FF_USE_FASTZIP: "true"

workflow:                                                       # 5
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - when: never                                               # nada más dispara pipeline

# ---------------------------------------------------------------- plantillas
.node:                                                          # 6 · plantilla reutilizable
  cache:
    key:
      files: [package-lock.json]                                # 7 · clave por lockfile
    paths: [.npm/]
    policy: pull                                                # solo lee; el job "preparar" la escribe
  before_script:
    - npm ci --prefer-offline --no-audit

# ---------------------------------------------------------------- jobs
preparar:
  stage: preparar
  extends: .node
  cache:
    key:
      files: [package-lock.json]
    paths: [.npm/]
    policy: pull-push                                           # 8 · este sí actualiza la caché
  script:
    - echo "Dependencias instaladas y caché poblada"

calidad:
  stage: verificar
  extends: .node
  needs: [preparar]                                             # 9 · DAG
  script:
    - npx prettier --check .
    - npm run lint
    - npm run typecheck

test:
  stage: verificar
  extends: .node
  needs: [preparar]
  parallel: 4                                                   # 10 · sharding
  services:                                                     # 11 · PostgreSQL como servicio
    - name: postgres:16-alpine
      alias: db
  variables:
    POSTGRES_DB: reservalia_test
    POSTGRES_PASSWORD: test
    DATABASE_URL: "postgres://postgres:test@db:5432/reservalia_test"
  script:
    - npm run migrate
    - npm test -- --shard=$((CI_NODE_INDEX))/$CI_NODE_TOTAL
  artifacts:                                                    # 12
    when: always
    expire_in: 1 week
    reports:
      junit: informes/junit-*.xml
      coverage_report:
        coverage_format: cobertura
        path: cobertura/cobertura.xml

build-web:
  stage: construir
  extends: .node
  needs: [preparar]
  script:
    - npm run build --workspace apps/web
  artifacts:
    paths: [apps/web/dist/]                                     # 13 · esto sí es un artefacto
    expire_in: 1 day

build-api:
  stage: construir
  needs: [preparar]
  image: docker:27
  services: [docker:27-dind]                                    # 14 · Docker-in-Docker
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - |
      docker buildx build \
        --file apps/api/Dockerfile \
        --cache-from type=registry,ref=$IMAGEN:cache \
        --cache-to   type=registry,ref=$IMAGEN:cache,mode=max \
        --tag $IMAGEN:$CI_COMMIT_SHA \
        --push .
    - docker buildx imagetools inspect $IMAGEN:$CI_COMMIT_SHA
        --format '{{.Manifest.Digest}}' > digest.env.tmp
    - echo "DIGEST=$(cat digest.env.tmp)" > build.env
  artifacts:
    reports:
      dotenv: build.env                                         # 15 · outputs entre jobs

seguridad:
  stage: construir
  extends: .node
  needs: [preparar]
  script:
    - npm audit --audit-level=high
    - gitleaks detect --no-git --exit-code 1
  allow_failure: false

publicar:
  stage: publicar
  needs: [calidad, test, build-api, build-web, seguridad]        # 16 · fan-in
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH                # solo en la rama por defecto
  image: docker:27
  services: [docker:27-dind]
  script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - docker buildx imagetools create --tag $IMAGEN:estable $IMAGEN@$DIGEST   # promoción por digest

desplegar-staging:
  stage: desplegar
  needs: [publicar]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  environment:                                                   # 17
    name: staging
    url: https://staging.reservalia.example
  script:
    - ./scripts/desplegar.sh staging "$IMAGEN@$DIGEST"

desplegar-produccion:
  stage: desplegar
  needs: [desplegar-staging]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual                                               # 18 · aprobación
      allow_failure: false
  environment:
    name: produccion
    url: https://app.reservalia.example
  script:
    - ./scripts/desplegar.sh produccion "$IMAGEN@$DIGEST"
  1. stages declara el orden. Un job sin stage cae en test por defecto, lo cual sorprende; decláralo siempre.
  2. default aplica a todos los jobs lo que antes se repetía. interruptible: true con la opción de auto-cancelación del proyecto es el cancel-in-progress de la 04-04.
  3. retry selectivo. Reintentar cualquier fallo enmascara los flaky de la 02-04 y convierte el pipeline en un generador de verde falso; reintentar solo fallos de infraestructura del runner es legítimo. Esa distinción es la diferencia entre resiliencia y autoengaño.
  4. La caché tiene que estar dentro de $CI_PROJECT_DIR: GitLab solo puede cachear rutas del workspace. Por eso se redirige la caché de npm ahí en vez de dejarla en ~/.npm. Es el error número uno de caché en GitLab.
  5. workflow:rules decide si se crea pipeline. Sin esto, un push a una rama con MR abierto genera dos pipelines (uno de rama y otro de MR): duplica el gasto y confunde la señal.
  6. Una plantilla es un job que empieza por punto: GitLab no lo ejecuta y sirve para extends.
  7. cache:key:files genera la clave a partir del hash del lockfile: cambia el lockfile, cambia la caché. Es el hashFiles de la 04-02 con otro nombre.
  8. policy es la pieza que casi nadie usa y que ahorra mucho tiempo: pull solo descarga, pull-push descarga y sube al final. Con seis jobs subiendo la misma caché idéntica se malgastan minutos; aquí solo la escribe preparar.
  9. needs convierte el pipeline en grafo. Con needs: [] un job arranca de inmediato, ignorando su etapa.
  10. parallel: 4 crea cuatro instancias del job con CI_NODE_INDEX (1..N) y CI_NODE_TOTAL. Es el sharding de la 02-04; el reparto por tiempos históricos que verás en la 06-03 no viene de serie.
  11. services levanta contenedores auxiliares accesibles por su alias, igual que el PostgreSQL de la 02-02.
  12. artifacts:reports son artefactos con semántica: GitLab los interpreta y los muestra en el MR (resultados de test, cobertura, hallazgos de seguridad). when: always es imprescindible: los informes de test importan sobre todo cuando el job falla.
  13. Distinción de fondo, que desarrolla el apartado 4: dist/ es un artefacto —salida del pipeline, se pasa a otros jobs—; .npm/ es caché —acelera, y perderla no rompe nada—.
  14. Docker-in-Docker es el patrón habitual para construir imágenes en GitLab, y tiene implicaciones de seguridad que la 06-05 desarrolla: el servicio dind requiere runners privilegiados.
  15. artifacts:reports:dotenv es el mecanismo de outputs entre jobs: las variables del fichero quedan disponibles en los jobs que dependen de este. Es el equivalente del needs.<job>.outputs de GitHub Actions.
  16. El fan-in de la 04-01: publicar espera a las cinco señales.
  17. environment registra el despliegue: GitLab guarda qué commit está en staging, desde cuándo, y ofrece el botón de volver a desplegar una versión anterior.
  18. when: manual con allow_failure: false es la puerta de la 03-01: el pipeline se detiene esperando a que una persona pulse el botón, y quién puede pulsarlo lo determina la protección del entorno.

Comparado con el ci.yml original, la traducción es casi uno a uno. Las diferencias reales: el sharding es un número en vez de una matriz, los outputs entre jobs pasan por un fichero dotenv en vez de por outputs, la caché exige configurar rutas y política a mano, y el registro de contenedores y los entornos son parte del mismo producto en lugar de servicios externos con credenciales propias.

  1. cache frente a artifacts: la distinción que más confunde

Los dos guardan ficheros y los dos los restauran en otro job. No son lo mismo y confundirlos produce pipelines lentos o incorrectos.

cache artifacts
Propósito Acelerar (dependencias, compilaciones intermedias) Transportar resultados entre jobs y hacerlos descargables
Si desaparece El job es más lento, pero funciona El pipeline falla o el resultado se pierde
Dónde se guarda En el runner (o en almacenamiento compartido, si se configura) Siempre en el servidor de GitLab
Alcance Compartida entre pipelines y ramas, según key De la ejecución concreta del pipeline
Se recupera Por key, best-effort, sin garantías Automáticamente desde los jobs que lo necesitan
Caducidad Política del runner expire_in, explícito
Contenido típico .npm/, ~/.gradle, vendor/ dist/, .war, informes JUnit, SBOM

Regla mental que resuelve el 100 % de los casos: si borrar eso rompe el pipeline, es artefacto; si solo lo hace más lento, es caché.

Dos mecanismos relacionados:

# Descargar SOLO los artefactos que este job necesita
publicar:
  needs:
    - job: build-web
      artifacts: true          # descarga dist/
    - job: seguridad
      artifacts: false         # solo la dependencia de orden, sin transferir ficheros

Sin esto, un job descarga por defecto los artefactos de todos los jobs de etapas anteriores, y en un pipeline grande eso son cientos de megabytes movidos sin razón: una de las causas de pipeline lento más frecuentes y menos diagnosticadas. dependencies: [] es la forma antigua de decir "no me descargues nada".

Y expire_in no es opcional: los artefactos consumen almacenamiento facturable en SaaS y disco en autoalojado. La política de retención de la 02-06 aquí es una línea por job.

  1. Paralelización: parallel y parallel: matrix

# Sharding simple: N copias idénticas que se reparten por índice
test:
  parallel: 4
  script: [ "npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL" ]

# Matriz: combinaciones de variables, como el matrix de la 02-04
test-compatibilidad:
  parallel:
    matrix:
      - NODE: ["20", "22"]
        POSTGRES: ["15", "16"]      # → 4 jobs
      - NODE: ["22"]                 # bloques adicionales se suman
        POSTGRES: ["17"]
        EXPERIMENTAL: "true"
  image: node:$NODE
  services: [ "postgres:$POSTGRES-alpine" ]
  script: [ "npm test" ]

Diferencias con el matrix de GitHub Actions que conviene tener claras: no existe exclude —se modela añadiendo bloques en vez de restar—, y no hay fail-fast global: el control es por job con allow_failure. A cambio, parallel: matrix sí permite variar image y services, lo que da bastante juego.

  1. Runners y executors

Un runner es el proceso que ejecuta jobs. Se registra contra una instancia y se le asignan jobs según sus tags.

Tipo de runner Alcance Cuándo
Compartido (shared) Toda la instancia El caso normal en SaaS; minutos consumibles
De grupo Todos los proyectos de un grupo Runners propios compartidos por un departamento
Específico de proyecto Un proyecto Hardware especial, acceso a redes concretas

Y el executor determina cómo materializa el entorno:

Executor Cómo ejecuta Aislamiento Cuándo usarlo
shell Comandos en la máquina del runner Ninguno Casi nunca; hereda el problema del agente contaminado de la 06-01
docker Un contenedor por job, desde image: Bueno El caso normal
docker+machine Crea una VM por job y la destruye Muy bueno Autoescalado en la nube
kubernetes Un Pod por job Muy bueno Si ya tienes clúster
ssh, custom Máquinas remotas, integraciones propias Variable Hardware raro
# config.toml de un runner autoalojado con executor docker
concurrent = 8                                  # jobs simultáneos en esta máquina
check_interval = 3

[[runners]]
  name = "runner-reservalia-1"
  url = "https://gitlab.example.com/"
  token = "glrt-..."                            # token de registro
  executor = "docker"

  [runners.docker]
    image = "node:22-bookworm"                  # imagen por defecto si el job no la indica
    privileged = false                          # true SOLO si hace falta dind (ver 06-05)
    volumes = ["/cache", "/certs/client"]
    memory = "4g"
    cpus = "2"

  [runners.cache]                               # caché compartida entre runners
    Type = "s3"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "s3.eu-west-1.amazonaws.com"
      BucketName = "reservalia-ci-cache"
      AuthenticationType = "iam"                # rol de instancia, sin claves de larga vida

Tres puntos operativos con consecuencias:

  • privileged = true para Docker-in-Docker es un agujero real: un job con acceso al daemon privilegiado puede escapar del contenedor y comprometer la máquina, incluidos los secretos de otros jobs que corran ahí. Alternativas sin privilegios en la 06-05 (Kaniko, Buildah, BuildKit rootless).
  • La caché con almacenamiento compartido cambia el rendimiento por completo. Con caché local en el runner y varios runners, cada uno tiene su copia y la tasa de acierto se desploma. Con S3 compartido, todos comparten.
  • concurrent es la palanca de coste: dimensionar mal deja jobs en cola (que se percibe como "el CI es lento", aunque el tiempo de ejecución sea el mismo) o desperdicia máquinas.

  1. Variables, secretos y protección

GitLab tiene un solo mecanismo —variables de CI/CD— con atributos que cambian su comportamiento:

Atributo Efecto Cuándo activarlo
Protected Solo se expone en pipelines de ramas y tags protegidos Siempre, para cualquier credencial de staging o producción
Masked Su valor se sustituye por [MASKED] en los logs Siempre, para secretos
File Se materializa como fichero y la variable contiene la ruta Kubeconfig, claves, certificados
Environment scope Valor distinto por entorno (produccion, staging, *) Configuración por entorno de la 03-02
Expanded Se interpolan otras variables dentro Desactivar en secretos con $

Dos advertencias importantes. Masked tiene restricciones de formato —longitud mínima, sin espacios ni ciertos caracteres—; un secreto que no cumple simplemente no se enmascara, y GitLab lo dice en un aviso que es fácil pasar por alto. Y el enmascarado tiene la misma limitación que en Jenkins: cubre la coincidencia literal, no la transformada. La conclusión de la 04-03 no cambia por herramienta.

Protected es la protección de verdad, y la que más se olvida. Sin ella, cualquiera que abra un MR desde una rama arbitraria puede escribir un .gitlab-ci.yml que imprima las credenciales de producción. Con la variable marcada como protegida, esa variable no existe en pipelines de ramas no protegidas. Regla: toda credencial de despliegue, protegida; sin excepciones.

Para identidad federada, GitLab emite tokens OIDC por job con id_tokens, exactamente el patrón sin claves de larga vida de la 03-02:

desplegar-produccion:
  id_tokens:
    AWS_TOKEN:
      aud: https://gitlab.example.com          # audiencia que espera el proveedor de identidad
  script:
    - >
      export $(aws sts assume-role-with-web-identity
      --role-arn "$AWS_ROLE_ARN"
      --role-session-name "gitlab-$CI_JOB_ID"
      --web-identity-token "$AWS_TOKEN"
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text | awk '{print "AWS_ACCESS_KEY_ID="$1"\nAWS_SECRET_ACCESS_KEY="$2"\nAWS_SESSION_TOKEN="$3}')
    - ./scripts/desplegar.sh produccion

La condición de confianza en AWS se define sobre atributos del token (proyecto, rama, entorno), de modo que solo un pipeline de main desplegando en produccion puede asumir el rol. Es el mismo diseño de la 03-02 con otro emisor.

  1. Entornos, despliegue manual y review apps

environment: es una de las mejores piezas de GitLab y no tiene un equivalente exacto de serie en todas las herramientas: convierte un job en un despliegue registrado, con historial de qué commit está desplegado, enlace a la URL, y botones para volver a desplegar o revertir a un despliegue anterior.

review:
  stage: desplegar
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  environment:
    name: review/$CI_COMMIT_REF_SLUG              # 1 · un entorno por rama
    url: https://$CI_COMMIT_REF_SLUG.review.reservalia.example
    on_stop: parar-review                         # 2
    auto_stop_in: 3 days                          # 3
  script:
    - ./scripts/desplegar-review.sh "$CI_COMMIT_REF_SLUG" "$IMAGEN@$DIGEST"

parar-review:
  stage: desplegar
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop                                   # 4
  variables:
    GIT_STRATEGY: none                             # no hace falta el código para destruir
  script:
    - ./scripts/destruir-review.sh "$CI_COMMIT_REF_SLUG"
  1. Un entorno dinámico por rama: son las review apps, el equivalente directo de las previsualizaciones por PR de la 02-07. GitLab publica el enlace dentro del merge request, así que Nuria puede probar el cambio sin bajarse la rama.
  2. on_stop enlaza el job que destruye el entorno; se ejecuta automáticamente al cerrar o fusionar el MR.
  3. auto_stop_in es la línea que evita la factura sorpresa: sin ella, cada rama abandonada deja un entorno corriendo indefinidamente. Es el problema que la 05-01 señalaba con las previsualizaciones huérfanas, aquí resuelto con una línea.
  4. action: stop marca el job como el que apaga el entorno.

Sobre aprobaciones: when: manual detiene el job hasta que alguien lo lance, y quién puede hacerlo se controla protegiendo el entorno (Deployments → Protected environments), que es el equivalente de los revisores de Environments de la 03-02. En los niveles de pago existen aprobaciones múltiples y separación de funciones —quien aprueba no puede ser quien escribió el cambio—, que es lo que suelen exigir las auditorías.

  1. Reutilización: extends, include, componentes y pipelines padre-hijo

GitLab ofrece más mecanismos de reutilización que ninguna otra herramienta del módulo, y conviene saber cuál toca.

# a) Anchors YAML: sustitución textual, sin conocimiento de GitLab
.base: &base
  image: node:22
  retry: 1
calidad:
  <<: *base
  script: [ "npm run lint" ]

# b) extends: lo mismo pero consciente de la estructura (fusiona mapas en profundidad)
.node:
  image: node:22
  cache: { key: { files: [package-lock.json] }, paths: [.npm/] }
calidad:
  extends: .node                    # preferible a los anchors: fusiona bien y admite herencia múltiple
  script: [ "npm run lint" ]

# c) include: traer YAML de fuera
include:
  - local: '/ci/plantillas/node.yml'                              # mismo repositorio
  - project: 'reservalia/plantillas-ci'                           # otro proyecto de la instancia
    ref: 'v3.2.0'                                                 # ¡anclado a tag, no a main!
    file: '/plantillas/build-publicar.yml'
  - remote: 'https://ejemplo.com/plantilla.yml'                   # URL: sin control de versión → evitar
  - template: 'Security/SAST.gitlab-ci.yml'                       # plantilla oficial de GitLab
  - component: gitlab.com/reservalia/componentes/[email protected]      # componente con inputs tipados
    inputs:
      dockerfile: apps/api/Dockerfile
      publicar: true

extends frente a anchors: los anchors son YAML puro, se resuelven antes de que GitLab entienda nada y no funcionan entre ficheros incluidos; extends fusiona mapas en profundidad, admite cadenas y sí atraviesa include. Usa extends.

Los componentes (CI/CD components) son la evolución más reciente y la más parecida a lo que la 04-05 defendía: unidades con inputs tipados y versión propia, publicadas en un catálogo, en vez de YAML incluido a ciegas. Un include: template no valida nada; un componente falla al analizar el fichero si le pasas un booleano donde espera una cadena.

Y la regla de la 04-03 aplica igual aquí: include de otro proyecto anclado a main significa que un commit ajeno cambia tu pipeline sin que tú hayas fusionado nada. Ancla a tag o a SHA.

Pipelines padre-hijo para monorepos, que es el problema de ejecución selectiva de la 04-04:

# .gitlab-ci.yml del padre
generar-hijos:
  stage: preparar
  script:
    - node scripts/generar-pipeline.js > hijo.yml     # decide qué construir según lo que cambió
  artifacts: { paths: [hijo.yml] }

ejecutar-api:
  stage: verificar
  rules:
    - changes: [ "apps/api/**/*", "packages/compartido/**/*" ]   # solo si cambió esto
  trigger:
    include:
      - artifact: hijo.yml                             # pipeline generado dinámicamente
        job: generar-hijos
    strategy: depend                                   # el padre espera y refleja el resultado

Y trigger:project para disparar el pipeline de otro proyecto —el caso multiproyecto de la 05-03—, con strategy: depend si el padre debe esperar al hijo.

Mecanismo Qué reutiliza Cuándo
Anchors YAML Fragmentos, mismo fichero Casi nunca: usa extends
extends Configuración de job Repetición dentro de un proyecto
include: local Ficheros del repositorio Partir un .gitlab-ci.yml grande
include: project Plantillas entre proyectos Estandarizar en la organización
Componente Unidad versionada con inputs Lo recomendable hoy para plantillas compartidas
Pipeline hijo Un pipeline entero Monorepo, pipelines dinámicos
trigger: project Pipeline de otro proyecto Multiproyecto, microservicios

  1. El registro de contenedores integrado

Cada proyecto de GitLab lleva su propio registro de contenedores, y eso elimina el trámite de credenciales que en el ci.yml de Reservalia ocupa un job entero:

publicar:
  image: docker:27
  services: [docker:27-dind]
  script:
    # Estas tres variables existen sin configurarlas: las inyecta GitLab por job
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - docker build -t "$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHA" -f apps/api/Dockerfile .
    - docker push "$CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHA"

CI_REGISTRY_PASSWORD es un token efímero con permiso solo sobre el registro de ese proyecto y con la vida del job. Es mínimo privilegio (04-03) sin haber configurado nada, y es un buen ejemplo de lo que la integración regala.

El registro incluye políticas de limpieza por edad y por patrón de tag, que es la retención de la 02-06 como formulario. Sin ellas, el almacenamiento crece sin freno: un pipeline que publica por commit genera decenas de imágenes al día. Configúralas el primer día y conserva siempre lo referenciado por un entorno activo, porque borrar la imagen que está desplegada convierte el rollback por digest de la 03-05 en imposible.

También hay registro de paquetes (npm, Maven, PyPI…) y registro de dependencias proxy, que resuelve el caso de los registros privados de la 04-02 sin montar Nexus o Artifactory aparte.

  1. Auto DevOps, con criterio

Auto DevOps es un pipeline completo preconfigurado: detecta el lenguaje, construye con buildpacks, ejecuta tests, análisis SAST, dependencias, licencias, contenedor, DAST, y despliega en Kubernetes con revisión, staging y producción con canary. Se activa con una casilla.

Valoración honesta: es una demostración excelente y una base de producción discutible. A favor: para un proyecto nuevo y estándar, en diez minutos tienes un pipeline con más puertas de calidad que muchas empresas después de un año. En contra: el pipeline es una caja negra grande que hay que entender para modificar, asume Kubernetes con una topología concreta, la construcción por buildpacks produce imágenes que no controlas —y la 06-05 explicará por qué querrías controlarlas—, y en cuanto necesitas algo particular acabas sobrescribiendo tantas variables que habría salido más barato escribir el pipeline.

Uso recomendado: como catálogo de ideas y como punto de partida a desactivar pronto. Actívalo, mira qué jobs genera, copia los que te sirvan a tu propio .gitlab-ci.yml y desactívalo. Lo que sí merece la pena adoptar por separado son las plantillas de seguridad (include: template: Security/...), que son piezas sueltas, entendibles y ajustables.

  1. SaaS frente a autoalojado, y el coste real

GitLab.com (SaaS) Autoalojado
Quién opera GitLab
Modelo de coste Por usuario y mes, con minutos de cómputo incluidos y consumibles adicionales Licencia por usuario (según nivel) más infraestructura más personas
Actualizaciones Continuas Tuyas, con ventana y ensayo
Datos Fuera de tu red Donde tú decidas
Runners Compartidos, o los tuyos Los tuyos
Punto de fallo El proveedor Tu instancia

Autoalojar GitLab no es autoalojar un servidor de CI: es operar una plataforma con PostgreSQL, Redis, Gitaly (el servicio de Git), almacenamiento de objetos, el registro y los runners. Las tareas recurrentes reales: actualizaciones —GitLab publica a ritmo alto y saltarse versiones intermedias no siempre está soportado—, copias de seguridad con restauración ensayada, crecimiento del almacenamiento (los artefactos y el registro son los que se disparan), y dimensionado de runners. En la práctica es al menos media persona a tiempo parcial en una organización mediana, y no es un coste que desaparezca con el tiempo.

Sobre las cifras: los precios por usuario, los minutos incluidos y qué funcionalidad está en qué nivel cambian con frecuencia y por región. Lo que importa aquí es el modelo: pagas por usuario, no por proyecto; el cómputo se factura por minuto con multiplicadores según el tipo de máquina; el almacenamiento de artefactos y registro también cuenta. Consulta las tarifas vigentes antes de decidir y, sobre todo, verifica en qué nivel está la funcionalidad concreta sobre la que quieres construir antes de diseñar nada.

  1. Cuándo GitLab CI/CD es la elección correcta

Sí, con bastante claridad, cuando:

  • Tu código ya está en GitLab. Es el factor que decide la mayoría de los casos y la 06-07 lo pone el primero por una razón: usar otro CI contra un repositorio de GitLab significa sincronizar identidades, webhooks y permisos para obtener menos.
  • Quieres una plataforma en vez de una cadena de herramientas, y valoras no mantener integraciones ni conciliar permisos entre cinco productos.
  • Necesitas autoalojar todo por normativa, y quieres una experiencia integrada que Jenkins no da. Es probablemente el mejor punto del cuadrante autoalojado.
  • Los entornos y las review apps te importan: el modelo de environment con historial, on_stop y auto_stop_in es maduro y ahorra código propio.
  • Trabajas en monorepo y necesitas pipelines dinámicos: padre-hijo con generación de YAML es una solución de primera para el problema de la 04-04.

No, o piénsalo dos veces, cuando:

  • Tu código está en GitHub. Espejar repositorios para usar GitLab CI es una fuente permanente de fricción.
  • Tu equipo es pequeño y no quiere operar nada: SaaS resuelve, pero el coste por usuario con muchos colaboradores ocasionales pesa.
  • Dependes de funcionalidad de niveles altos: comprueba el nivel antes de diseñar, no después.
  • Necesitas mucho macOS o hardware exótico: es viable con runners propios, pero el ecosistema móvil está más rodado en otras herramientas (06-03, 05-02).

Errores Comunes y Consejos

Cachear rutas fuera de $CI_PROJECT_DIR. La caché no se guarda y no hay error visible, solo lentitud. Redirige NPM_CONFIG_CACHE, GRADLE_USER_HOME o equivalentes al workspace.

Todos los jobs con policy: pull-push. Suben una y otra vez la misma caché. Solo el job que la genera necesita escribirla.

Pipelines duplicados en los MR. Un push a una rama con MR abierto crea dos pipelines. workflow:rules lo arregla y ahorra la mitad del cómputo.

Confundir cache con artifacts. Poner dist/ en caché produce despliegues con un build de otra rama; poner node_modules en artifacts sube cientos de megabytes por job al servidor. La regla: si perderlo rompe el pipeline, es artefacto.

No poner expire_in. El almacenamiento crece hasta que alguien recibe la factura o se llena el disco.

Descargar artefactos que no necesitas. Por defecto llegan los de todas las etapas anteriores. Usa needs con artifacts: false donde solo hace falta orden.

Variables de despliegue sin marcar como protegidas. Cualquier rama puede leerlas desde un .gitlab-ci.yml modificado en un MR. Es la vía más directa de exfiltración de credenciales en GitLab.

Review apps sin on_stop ni auto_stop_in. Entornos huérfanos acumulándose y facturando.

retry sin when. Reintentar todo esconde los flaky y produce verde falso (02-04).

include de otro proyecto apuntando a main. Un cambio ajeno modifica tu pipeline sin revisión. Ancla a tag.

Runners privilegiados por costumbre. privileged = true solo donde de verdad hace falta dind, y evalúa alternativas sin daemon (06-05).

Ejercicios

Ejercicio 1. El pipeline de Reservalia en GitLab tarda 19 minutos. Observas: la etapa verificar termina en 6 min pero construir no empieza hasta el minuto 9; cada uno de los seis jobs sube 400 MB de caché; el job publicar descarga 1,2 GB de artefactos y solo usa un fichero digest; y un push a una rama con MR abierto lanza dos pipelines. Escribe las correcciones concretas y estima el efecto de cada una.

Ejercicio 2. Nuria quiere review apps para apps/web: un entorno por MR con URL propia, comentario automático en el MR, destrucción al cerrarlo y caducidad automática a los tres días. Escribe los jobs completos y explica qué protege cada línea. Añade qué hacer con los secretos, sabiendo que un MR puede venir de una rama de cualquier miembro del equipo.

Ejercicio 3. La empresa que compró Gestor Citas 4 (05-04) tiene su código en GitLab autoalojado 14.x, con pipelines que usan only/except, sin needs y con un runner shell único que ejecuta todo en la máquina del servidor. Escribe el plan de modernización en fases, con qué se gana en cada una y qué riesgos hay.

Soluciones

Solución 1.

Problema Causa Corrección Efecto estimado
construir espera 3 min Barrera de etapa: espera a que termine el job más lento de verificar needs: [preparar] en build-web y build-api −3 min de camino crítico
6 × 400 MB de subida de caché Todos con policy: pull-push Solo preparar con pull-push; el resto pull −2 GB de tráfico; ~1 min por job
publicar descarga 1,2 GB Descarga implícita de artefactos de etapas previas needs explícito con artifacts: false salvo el dotenv del build −1 a 2 min
Pipelines duplicados Falta workflow:rules Reglas de MR y rama por defecto, when: never en el resto −50 % del gasto de cómputo
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - when: never

.node:
  cache:
    key: { files: [package-lock.json] }
    paths: [.npm/]
    policy: pull                      # solo lectura

preparar:
  extends: .node
  cache:
    key: { files: [package-lock.json] }
    paths: [.npm/]
    policy: pull-push                 # el único que escribe
  script: [ "npm ci --prefer-offline" ]

build-api:
  needs: [preparar]                   # no espera a la etapa verificar
  # ...

publicar:
  needs:
    - job: build-api
      artifacts: true                 # solo el dotenv con el digest
    - job: build-web
      artifacts: true                 # dist/ sí hace falta
    - job: calidad
      artifacts: false                # solo orden
    - job: test
      artifacts: false
    - job: seguridad
      artifacts: false

Estimación total: de 19 min a alrededor de 12-13 en el camino crítico, y aproximadamente la mitad del cómputo facturado por el arreglo de los pipelines duplicados. Ese último punto es el más rentable de los cuatro y el que más se pasa por alto, porque no se manifiesta como lentitud sino como factura: es exactamente la lección de la 04-04 de que optimizar empieza por medir dónde se va el tiempo y el dinero, no por adivinar.

Solución 2.

review:
  stage: desplegar
  needs: [build-web]
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes: [ "apps/web/**/*", "packages/compartido/**/*" ]   # solo si afecta a la web
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_COMMIT_REF_SLUG.review.reservalia.example
    on_stop: parar-review
    auto_stop_in: 3 days
  script:
    - aws s3 sync apps/web/dist/ "s3://reservalia-review/$CI_COMMIT_REF_SLUG/" --delete
    - |
      curl -sS --request POST \
        --header "PRIVATE-TOKEN: $TOKEN_BOT_MR" \
        --data-urlencode "body=Previsualización lista: $CI_ENVIRONMENT_URL (commit $CI_COMMIT_SHORT_SHA)" \
        "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes"

parar-review:
  stage: desplegar
  needs: []
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
  environment: { name: review/$CI_COMMIT_REF_SLUG, action: stop }
  variables: { GIT_STRATEGY: none }
  script:
    - aws s3 rm "s3://reservalia-review/$CI_COMMIT_REF_SLUG/" --recursive

Qué protege cada línea: changes evita levantar entornos por cambios que no tocan la web; auto_stop_in acota el coste de las ramas abandonadas; on_stop garantiza limpieza al cerrar el MR; GIT_STRATEGY: none acelera la destrucción y reduce superficie; needs: [] hace que el job de parada no dependa de nada y funcione aunque el pipeline original fallara.

Sobre secretos: el entorno review/* debe usar credenciales propias y limitadas —un rol que solo puede escribir en el prefijo reservalia-review/ de un bucket sin datos reales—, nunca las de staging o producción. Las de producción van marcadas como protegidas y su environment scope es produccion, así que ni existen en este job. TOKEN_BOT_MR es un token de proyecto con permiso mínimo de comentar. Y la regla general que aplica igual que en la 04-03: un entorno de previsualización se construye asumiendo que su contenido es público, porque su URL es adivinable y no lleva autenticación real.

Solución 3. Cuatro fases, cada una con valor propio —el enfoque de la 05-04—:

Fase 0, inventario y quitar riesgo inmediato (1-2 semanas). El runner shell en la máquina del servidor es el problema urgente: cualquier job ejecuta comandos como el usuario del runner en el mismo host que la instancia, así que un .gitlab-ci.yml en un MR puede leer la base de datos de GitLab. Se cambia primero, antes que ninguna mejora de rendimiento: runner nuevo con executor docker en una máquina distinta, y el shell se apaga. En paralelo, inventario de variables sin marcar como protegidas —rotar las que hayan podido ser expuestas— y de proyectos con .gitlab-ci.yml.

Fase 1, actualizar GitLab (2-4 semanas de calendario). 14.x está muy atrás: hay que subir por las versiones de actualización obligatorias, no de un salto, y ensayar la restauración de la copia en una instancia de pruebas antes de tocar la real. Se gana: seguridad, componentes de CI/CD, mejoras de rules y de entornos. Riesgo principal: ventana de indisponibilidad y migraciones de base de datos largas; se mitiga ensayando sobre una copia y anunciando la ventana.

Fase 2, only/exceptrules (1 semana, proyecto a proyecto). only/except sigue funcionando pero no se puede combinar con rules en el mismo job y no expresa condiciones compuestas. La traducción es mecánica:

Antiguo Moderno
only: [main] rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
only: [merge_requests] rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"
only: changes: [src/**] rules: - changes: [src/**]
except: [tags] rules: - if: $CI_COMMIT_TAG when: never + regla positiva

Se hace un proyecto de prueba primero y se compara que el conjunto de pipelines disparados es el mismo antes y después, que es la única verificación que de verdad demuestra la equivalencia.

Fase 3, needs y caché compartida (1-2 semanas). Añadir needs para pasar de etapas a grafo, y configurar caché en S3 compartida entre runners. Se gana tiempo de pipeline. Va la última a propósito: es la fase más visible y la menos importante, y ponerla antes que la fase 0 sería optimizar la velocidad de un sistema con un agujero de seguridad abierto. Ese orden —riesgo, luego mantenibilidad, luego rendimiento— es el mismo que gobernaba los incrementos de la 05-04.

Conclusión

GitLab CI/CD es lo que ocurre cuando el CI se diseña dentro de la plataforma en vez de junto a ella. El .gitlab-ci.yml nació siendo pipeline as code, sin la herencia de la interfaz que arrastra Jenkins; el modelo evolucionó de etapas secuenciales a grafo con needs sin romper lo anterior; y la integración regala cosas que en otras cadenas cuestan trabajo: credenciales de registro por job, informes de test y seguridad dentro del merge request, entornos con historial y rollback, y review apps con caducidad en una línea. A cambio se paga acoplamiento —salir es salir de todo—, funcionalidad escalonada por nivel de suscripción, y una operación considerable si te lo autoalojas.

De lo visto, tres cosas se llevan a cualquier otra herramienta: la distinción caché frente a artefacto —si perderlo rompe el pipeline, es artefacto— es universal aunque el nombre cambie; marcar como protegidas las credenciales de despliegue es la defensa concreta contra el MR malicioso, y su ausencia es un agujero real en muchas instalaciones; y anclar los include a una versión es la misma regla que las Shared Libraries de Jenkins y las acciones fijadas por SHA de la 04-03.

La siguiente herramienta ataca una dimensión distinta. Donde GitLab compite por amplitud, CircleCI compite por profundidad en una sola cosa: la velocidad del pipeline. Trae la caché más explícita y controlable del módulo, un sistema de reutilización empaquetado —los orbs— y, sobre todo, el reparto de pruebas por tiempos históricos, que es la respuesta más madura al problema de sharding que arrastramos desde la 02-04. Veremos el pipeline de Reservalia por tercera vez y qué se paga por esa velocidad.

Curso de CI/CD: Integración y Despliegue Continuo

Módulo 1: Introducción a CI/CD

Módulo 2: Integración Continua (CI)

Módulo 3: Despliegue Continuo (CD)

Módulo 4: Prácticas Avanzadas de CI/CD

Módulo 5: Implementación de CI/CD en Proyectos Reales

Módulo 6: Herramientas y Tecnologías

Módulo 7: Ejercicios Prácticos

Módulo 8: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados