GitLab CI/CD — Fundamentos

¿Por qué existe CI/CD?

Sin automatización, el ciclo de vida del código es manual y frágil:

  1. Desarrollador escribe código
  2. Lo sube al repo
  3. Alguien (¿quién?) ejecuta tests
  4. Alguien empaqueta el binario/imagen
  5. Alguien lo despliega al servidor

Este “alguien” es error-prone, lento y no repetible. Si el equipo crece, el caos escala.

CI (Continuous Integration) resuelve el paso 2-3: cada git push ejecuta tests automáticamente — todos saben en minutos si el código está roto.

CD (Continuous Delivery/Deployment) resuelve el paso 4-5: el pipeline empaqueta y despliega sin intervención manual (o con un click de aprobación).

GitLab CI/CD integra todo esto dentro del mismo repositorio, sin herramientas externas como Jenkins. La lógica del pipeline vive junto al código que automatiza.


Panorama: dónde encaja en GitLab

git push
    │
    ▼
GitLab detecta .gitlab-ci.yml
    │
    ▼
Pipeline creado  ──► Runner lo ejecuta
    │
    ├── Stage: build   → Job: compile, Job: docker-build
    ├── Stage: test    → Job: unit-tests, Job: lint
    └── Stage: deploy  → Job: deploy-staging

El Runner es el proceso que ejecuta el trabajo real (ver 06-runners). El archivo .gitlab-ci.yml es la declaración de qué hacer y cuándo.


El archivo .gitlab-ci.yml

Por qué en la raíz del repo

El pipeline es código — vive en el repo junto al proyecto, se versiona con Git, se revisa en Merge Requests, y cada rama puede tener su propia variante. No hay configuración escondida en un panel de administración.

Estructura mínima

# .gitlab-ci.yml en la raíz del repositorio
 
stages:          # Orden de ejecución de etapas
  - build
  - test
  - deploy
 
mi-primer-job:   # Nombre del job (arbitrario, debe ser único)
  stage: build   # A qué etapa pertenece
  script:        # Comandos a ejecutar (lista de strings)
    - echo "Hola pipeline"
    - echo "Rama: $CI_COMMIT_BRANCH"

Conceptos clave: PIPELINE, STAGES, JOBS

Pipeline

Una ejecución completa del archivo .gitlab-ci.yml, disparada por un evento (push, MR, schedule, etc.). Tiene un ID único (#123) y un estado global (passed / failed / canceled).

Stages (etapas)

Grupos ordenados de jobs. Todos los jobs de un stage corren en paralelo (si hay runners disponibles). El siguiente stage solo empieza cuando todos los jobs del anterior han pasado.

Stage build  ──[parallel]──►  job A  +  job B
                                         │
                                         ▼ (si ambos pasan)
Stage test   ──[parallel]──►  job C  +  job D

Jobs

La unidad mínima de trabajo. Cada job:

  • Tiene un stage asignado
  • Ejecuta uno o más script commands
  • Corre en un entorno limpio (contenedor Docker o VM)
  • Tiene su propio estado: passed / failed / skipped / canceled

Analogia hardware: si el pipeline es un proceso de fabricación (línea de montaje), los stages son las estaciones y los jobs son las operaciones dentro de cada estación.


La clave script

El corazón de cada job. Lista de comandos shell ejecutados en orden. Si cualquier comando devuelve código distinto de 0, el job falla y el pipeline se detiene (por defecto).

test-unitarios:
  stage: test
  script:
    - pip install -r requirements.txt   # Paso 1
    - pytest tests/ -v                  # Paso 2 — si falla aquí, para
    - echo "Tests completados"          # Paso 3 — solo si paso 2 pasó

Variantes útiles:

  script:
    - comando-que-puede-fallar || true   # Ignorar fallo de este comando
    - |                                  # Bloque multilínea (pipe)
        if [ "$CI_COMMIT_BRANCH" = "main" ]; then
          echo "En rama principal"
        fi

Imagen Docker por job (image:)

Cada job corre en un contenedor Docker. Puedes definir la imagen globalmente o por job. Esto es poder real: cada job tiene exactamente las herramientas que necesita, sin conflictos.

# Imagen global por defecto
default:
  image: ubuntu:22.04
 
build-python:
  stage: build
  image: python:3.11-slim     # Sobreescribe la global para este job
  script:
    - pip install -r requirements.txt
    - python -m build
 
build-node:
  stage: build
  image: node:20-alpine        # Otro job, otra imagen, mismo stage
  script:
    - npm ci
    - npm run build
 
test-rust:
  stage: test
  image: rust:1.78             # Sin conflicto con Python o Node
  script:
    - cargo test

Por qué es importante: en un entorno sin Docker (como un runner shell), tendrías que instalar Python, Node y Rust en la misma máquina y gestionar versiones. Con Docker, cada job trae su propio entorno.


Variables

Variables predefinidas de GitLab

GitLab inyecta automáticamente decenas de variables en cada job. Las más útiles:

VariableQué contiene
$CI_COMMIT_BRANCHNombre de la rama actual (main, feature/x)
$CI_COMMIT_SHAHash completo del commit (40 chars)
$CI_COMMIT_SHORT_SHAHash corto (8 chars) — útil para tags de imagen
$CI_PROJECT_NAMENombre del proyecto
$CI_PROJECT_URLURL completa del proyecto
$CI_PIPELINE_IDID numérico del pipeline
$CI_JOB_NAMENombre del job actual
$CI_ENVIRONMENT_NAMENombre del environment (si se define)
$CI_REGISTRYURL del Container Registry de GitLab
$CI_REGISTRY_USERUsuario para autenticarse en el registry
$CI_REGISTRY_PASSWORDToken de acceso al registry
$CI_DEFAULT_BRANCHRama por defecto del proyecto (main)
build-imagen:
  stage: build
  script:
    # Etiqueta la imagen con el hash corto del commit — trazabilidad perfecta
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .

Variables propias (definidas en el .gitlab-ci.yml)

variables:                          # Nivel global — disponibles en todos los jobs
  APP_PORT: "8080"
  DOCKER_DRIVER: overlay2
  PYTHON_VERSION: "3.11"
 
test-unitarios:
  stage: test
  variables:                        # Nivel job — solo para este job
    LOG_LEVEL: debug
  script:
    - echo "Puerto: $APP_PORT"
    - echo "Log level: $LOG_LEVEL"

Variables CI/CD (secretas) — Settings → CI/CD → Variables

Para credenciales, tokens, claves de API. No se escriben en el .gitlab-ci.yml — se configuran en la UI de GitLab y GitLab las inyecta en el entorno del job.

PropiedadQué hace
MaskedEl valor no aparece en los logs del pipeline (lo sustituye por [MASKED])
ProtectedSolo disponible en ramas/tags protegidos (no en feature branches)
ExpandablePermite referencias a otras variables dentro del valor
# En el job, las usas igual que cualquier variable
deploy-produccion:
  stage: deploy
  script:
    # DEPLOY_TOKEN viene de CI/CD Variables, no del .gitlab-ci.yml
    - curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.ejemplo.com/deploy

Regla de oro: si el valor es secreto, va en CI/CD Variables con Masked=true. Nunca hardcodes una contraseña en el .gitlab-ci.yml — ese archivo es público (o al menos versionado y visible a todos los que tienen acceso al repo).


Artifacts vs Cache

Esta distinción confunde a todo el mundo al principio.

Diferencia conceptual

ArtifactsCache
PropósitoPasar archivos entre stages / descargar resultadosAcelerar el pipeline reutilizando dependencias
Quién lo usaJobs downstream del pipeline actual + usuario finalEl mismo job en ejecuciones futuras
GarantíaSiempre disponible para jobs dependientesBest-effort (puede no existir)
Ejemplo típicoBinario compilado, informe de tests, imagennode_modules/, .venv/, caché de pip/cargo
Cuándo expiraConfigurable (días), default 30 díasConfigurable, se invalida por key

Artifacts — pasar archivos entre stages

compile:
  stage: build
  script:
    - cargo build --release
    - cp target/release/mi-app ./mi-app-binario   # Copia al workspace
  artifacts:
    paths:
      - mi-app-binario      # Este archivo se sube a GitLab
    expire_in: 1 week       # Se borra después de 7 días
 
test-integracion:
  stage: test
  # GitLab descarga los artifacts de 'compile' automáticamente
  script:
    - ./mi-app-binario --test    # El binario ya está aquí

Puedes descargar los artifacts desde la UI de GitLab (pestaña pipeline → job → Download artifacts).

Cache — acelerar dependencias

test-python:
  stage: test
  image: python:3.11-slim
  cache:
    key: "$CI_COMMIT_BRANCH-pip"   # Clave única por rama
    paths:
      - .venv/                      # Cachea el entorno virtual
  script:
    - python -m venv .venv
    - source .venv/bin/activate
    - pip install -r requirements.txt   # Rápido si cache hit
    - pytest

Cuándo NO usar cache: cuando el output tiene que ser determinista y reproducible (como en builds de producción). El cache puede enmascarar problemas de dependencias.


Reglas de ejecución — rules y only/except

Por defecto, todos los jobs corren en cada pipeline. Las reglas permiten ejecutar jobs condicionalmente.

only/except — sintaxis antigua (aún funcional, pero deprecada)

deploy-prod:
  stage: deploy
  script: ./deploy.sh
  only:
    - main          # Solo corre en la rama 'main'
  except:
    - schedules     # Nunca en pipelines programados

rules — sintaxis moderna y flexible (preferida)

rules evalúa condiciones en orden. La primera que coincide gana.

deploy-staging:
  stage: deploy
  script: ./deploy-staging.sh
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'    # Si es la rama develop
      when: on_success                          # Ejecutar si stage anterior pasó
    - if: '$CI_PIPELINE_SOURCE == "schedule"'  # Si es pipeline programado
      when: never                               # Nunca ejecutar
    - when: manual                             # En cualquier otro caso, manual
 
test-mr:
  stage: test
  script: pytest
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'  # Solo en MRs

Valores de when

ValorCuándo ejecuta
on_successSi todos los jobs anteriores pasaron (default)
on_failureSi algún job anterior falló (útil para notificaciones de error)
alwaysSiempre, sin importar el estado anterior
manualSolo si alguien hace click en “Play” en la UI
neverNunca (equivale a no definir el job en ese contexto)
delayedCon un retraso configurable (start_in: 30 minutes)

Ejemplo completo comentado

Un pipeline real para una aplicación Python con Docker:

# .gitlab-ci.yml — Pipeline completo: build → test → deploy
 
# ─── Variables globales ─────────────────────────────────────────────────────
variables:
  # Imagen Docker que construiremos; usa el registro de GitLab del proyecto
  IMAGE_NAME: $CI_REGISTRY_IMAGE
  # Tag único por commit — garantiza trazabilidad
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA
 
# ─── Imagen por defecto ─────────────────────────────────────────────────────
default:
  image: docker:24.0               # Docker-in-Docker para builds de imagen
  services:
    - docker:24.0-dind             # Daemon Docker dentro del runner
 
# ─── Orden de stages ────────────────────────────────────────────────────────
stages:
  - build
  - test
  - deploy
 
# ═══════════════════════════════════════════════════════════════════════════
# STAGE: BUILD
# ═══════════════════════════════════════════════════════════════════════════
 
build-imagen:
  stage: build
  script:
    # Autenticarse en el Container Registry de GitLab
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    # Construir la imagen
    - docker build -t $IMAGE_NAME:$IMAGE_TAG .
    # También taggear como 'latest' si es la rama principal
    - |
      if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
        docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:latest
        docker push $IMAGE_NAME:latest
      fi
    # Subir la imagen al registry
    - docker push $IMAGE_NAME:$IMAGE_TAG
  rules:
    # Solo construir en ramas (no en pipelines de tags, por ejemplo)
    - if: '$CI_COMMIT_BRANCH'
 
# ═══════════════════════════════════════════════════════════════════════════
# STAGE: TEST (corre en paralelo si hay runners disponibles)
# ═══════════════════════════════════════════════════════════════════════════
 
unit-tests:
  stage: test
  image: python:3.11-slim          # Imagen específica para este job
  cache:
    key: "$CI_COMMIT_BRANCH-pip"
    paths:
      - .venv/                     # Cachear dependencias Python
  script:
    - python -m venv .venv
    - source .venv/bin/activate
    - pip install -r requirements.txt
    - pytest tests/unit/ -v --junitxml=report.xml
  artifacts:
    when: always                   # Subir el reporte aunque los tests fallen
    reports:
      junit: report.xml            # GitLab parsea esto y muestra resultados en la UI
    expire_in: 1 week
 
lint:
  stage: test
  image: python:3.11-slim
  cache:
    key: "$CI_COMMIT_BRANCH-pip"
    paths:
      - .venv/
  script:
    - source .venv/bin/activate || python -m venv .venv && source .venv/bin/activate
    - pip install flake8 black
    - flake8 src/                  # Linter
    - black --check src/           # Verificar formato
 
# ═══════════════════════════════════════════════════════════════════════════
# STAGE: DEPLOY
# ═══════════════════════════════════════════════════════════════════════════
 
deploy-staging:
  stage: deploy
  image: alpine:3.18
  before_script:
    # Instalar herramientas necesarias para el despliegue
    - apk add --no-cache curl
  script:
    # Ejemplo: notificar a un servidor de staging via webhook
    # $STAGING_WEBHOOK viene de CI/CD Variables (masked)
    - |
      curl -X POST "$STAGING_WEBHOOK" \
        -H "Content-Type: application/json" \
        -d "{\"image\": \"$IMAGE_NAME:$IMAGE_TAG\"}"
    - echo "Desplegado $IMAGE_TAG a staging"
  environment:
    name: staging                  # Crea un 'Environment' rastreable en GitLab
    url: https://staging.ejemplo.com
  rules:
    # Solo en develop, automáticamente
    - if: '$CI_COMMIT_BRANCH == "develop"'
      when: on_success
 
deploy-produccion:
  stage: deploy
  image: alpine:3.18
  script:
    - echo "Desplegando $IMAGE_TAG a producción..."
    - curl -X POST "$PROD_WEBHOOK" -d "{\"image\": \"$IMAGE_NAME:$IMAGE_TAG\"}"
  environment:
    name: production
    url: https://app.ejemplo.com
  rules:
    # Solo en main, con aprobación manual (no automático)
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual
      allow_failure: false          # El pipeline no pasa hasta que alguien aprueba

Errores comunes

ErrorCausa habitualSolución
No stages definedEl archivo .gitlab-ci.yml tiene errores de sintaxis o está vacíoUsar el CI Lint integrado en GitLab (CI/CD → Editor → Validate)
Job falla con command not foundLa imagen Docker no tiene el comando instaladoCambiar la imagen o añadir instalación en before_script
Cache no funcionaEl runner no tiene caché configurado o la key cambióVerificar la configuración del runner y la key del cache
Artifact no encontrado en job downstreamEl job que genera el artifact fallóRevisar si el job upstream pasó; considerar dependencies: []
Variable masked aparece en logsSe usa en un contexto donde GitLab no puede mascarar (ej: dentro de un archivo)Aceptarlo o restructurar para que no se imprima
docker: command not foundEl runner no tiene Docker o falta services: docker:dindAñadir la imagen docker:XX y el service docker:dind
Pipeline no se dispara en MRLas rules no incluyen merge_request_eventAñadir if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

before_script y after_script

Comandos que corren antes o después del script principal de cada job.

default:
  before_script:
    - echo "Inicio de job: $CI_JOB_NAME"   # Ejecuta en TODOS los jobs
 
build:
  stage: build
  before_script:
    - apt-get update -qq               # Sobreescribe el before_script global
    - apt-get install -y build-tools
  script:
    - make build
  after_script:
    - echo "Job terminado con estado: $CI_JOB_STATUS"  # Siempre corre, incluso si falla

Aplícalo a tus proyectos

app web (FastAPI + React + Docker)

stages: [build, test, deploy]
 
build-backend:
  stage: build
  image: docker:24.0
  services: [docker:dind]
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHORT_SHA ./backend
    - docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHORT_SHA
 
test-backend:
  stage: test
  image: python:3.11-slim
  script:
    - pip install -r backend/requirements.txt
    - pytest backend/tests/ -v
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'

proyecto embebido (PlatformIO / C++)

build-firmware:
  stage: build
  image: python:3.11
  before_script:
    - pip install platformio
  script:
    - pio run -e esp32dev        # Compilar para el target definido
  artifacts:
    paths:
      - .pio/build/esp32dev/firmware.bin   # Guardar el binario
    expire_in: 30 days

Conexiones