En la lección anterior el equipo de Reservalia acordó sus seis reglas sin abrir un editor. Ahora toca lo contrario: escribir el primer fichero de configuración real y dejar que la máquina empiece a trabajar. Al final de esta lección, cada pull request abierto contra main disparará automáticamente un pipeline que descarga el código en una máquina limpia, instala exactamente Node 20.11.0, levanta un PostgreSQL 16.3 de verdad y ejecuta las pruebas. Vamos a construir ese fichero desde cero y línea a línea, sin copiar plantillas mágicas de internet, porque entender qué hace cada palabra clave es la diferencia entre mantener un pipeline y rezarle. Además veremos dónde se ejecuta realmente ese trabajo —runners alojados frente a autoalojados, con su coste y sus implicaciones—, cómo se pasan variables y secretos, y —quizá lo más útil de todo— cómo depurar un workflow que falla cuando el log no dice nada evidente.

Contenido

  1. Dónde vive el pipeline y qué lo dispara
  2. El primer ci.yml de Reservalia, línea a línea
  3. checkout y setup-node: las dos acciones que usarás siempre
  4. Runners alojados frente a autoalojados
  5. Variables de entorno y secretos
  6. Servicios de apoyo: un PostgreSQL real para las pruebas
  7. Depurar un workflow que falla
  8. Errores Comunes y Consejos
  9. Ejercicios
  10. Conclusión

  1. Dónde vive el pipeline y qué lo dispara

GitHub Actions busca los ficheros YAML en una ruta fija: .github/workflows/. El nuestro será .github/workflows/ci.yml. Que el pipeline viva dentro del repositorio tiene tres consecuencias:

  • Se versiona con el código. Cambiar el pipeline es un commit revisable y reversible.
  • Cada rama puede tener su versión. El workflow que se ejecuta en un PR es el que hay en esa rama, lo que permite probar cambios del propio pipeline en un PR.
  • La configuración y el código evolucionan juntos. Si añades una dependencia que necesita otra herramienta, ambos cambios entran en el mismo commit.

  1. El primer ci.yml de Reservalia, línea a línea

Este es el fichero completo con el que arranca Reservalia. Está deliberadamente reducido a un solo job: preferimos algo pequeño que esté verde hoy antes que algo completo que nunca llegue a funcionar.

# .github/workflows/ci.yml
name: CI                                  # 1

on:                                       # 2
  pull_request:
    branches: [main]                      # 3
  push:
    branches: [main]                      # 4

jobs:                                     # 5
  test:                                   # 6
    name: Pruebas
    runs-on: ubuntu-22.04                 # 7
    timeout-minutes: 15                   # 8
    steps:                                # 9
      - name: Descargar el código
        uses: actions/checkout@v4         # 10

      - name: Preparar Node.js
        uses: actions/setup-node@v4       # 11
        with:
          node-version-file: .nvmrc       # 12
          cache: npm                      # 13

      - name: Instalar dependencias
        run: npm ci                       # 14

      - name: Ejecutar pruebas
        run: npm test                     # 15

Ahora, cada número:

  1. name: CI es la etiqueta que aparecerá en la pestaña Actions y en el pull request. Es puramente cosmética, pero un nombre claro ahorra confusión cuando tengas cinco workflows.
  2. on: declara los eventos que disparan el workflow. Es la pieza que convierte un script en un pipeline: nadie lo lanza a mano.
  3. pull_request con branches: [main]: se ejecuta cuando alguien abre un PR hacia main y en cada nuevo push a la rama de ese PR. Esta es la ejecución que implementa la regla 3 del acuerdo: no se fusiona nada en rojo.
  4. push con branches: [main]: se ejecuta también después del merge. Como vimos en la 02-01, main puede haber recibido otros commits mientras el PR estaba abierto, así que verificarla de nuevo no es redundante.
  5. jobs: abre la lista de unidades de trabajo. Cada una correrá en su propia máquina.
  6. test: es el identificador del job —el nombre que usarán otros jobs para depender de él con needs: y el que configuraremos como check obligatorio en la 02-07—. name: Pruebas es solo lo que se muestra en la interfaz.
  7. runs-on: ubuntu-22.04 elige la máquina. Fíjate en que no usamos ubuntu-latest: esa etiqueta cambia de sistema operativo sin avisar y un buen día tu build se rompe sin que nadie haya tocado nada. Es el mismo principio de postgres:16.3 frente a postgres:latest de la lección 01-04.
  8. timeout-minutes: 15 mata el job si se cuelga. Sin esto, una prueba que espera una conexión que nunca llega puede consumir seis horas de runner. Ponlo siempre, aunque sea generoso.
  9. steps: son los pasos secuenciales dentro del job. Si uno devuelve un código de salida distinto de 0, los siguientes no se ejecutan y el job se marca en rojo.
  10. uses: actions/checkout@v4 descarga el código del repositorio en el runner. Sin este paso el disco está vacío: el runner no sabe nada de tu proyecto. El @v4 fija la versión mayor de la acción.
  11. uses: actions/setup-node@v4 instala Node.js en el runner.
  12. node-version-file: .nvmrc es el detalle más importante del fichero. En lugar de escribir node-version: 20.11.0 —duplicando la versión en dos sitios que se desincronizarán—, le decimos que lea el .nvmrc del repositorio. Una única fuente de verdad para el portátil de Diego y para el runner.
  13. cache: npm guarda la caché de descargas de npm entre ejecuciones, indexada por el hash del package-lock.json. Como el lockfile es único en el monorepo (lección 01-04), una sola caché cubre todo el proyecto. Lo desarrollamos en la 02-03.
  14. run: npm ci ejecuta un comando en la shell del runner. npm ci (y no npm install) es la instalación reproducible; el porqué es materia de la 02-03.
  15. run: npm test ejecuta las pruebas de todos los workspaces. Es exactamente el mismo comando que Diego escribe en su portátil, y esa es la idea: el pipeline no inventa comandos, solo los ejecuta en una máquina limpia.

Con estas 20 líneas, Reservalia ya cumple la práctica 3 de la lección anterior: build automatizada en cada cambio.

  1. checkout y setup-node: las dos acciones que usarás siempre

Conviene entender qué es una acción. Un run: ejecuta un comando de shell; un uses: invoca un componente reutilizable publicado en un repositorio, con sus propios parámetros bajo with:. Son las piezas de Lego del ecosistema.

      - name: Descargar el código
        uses: actions/checkout@v4
        with:
          fetch-depth: 0        # clona TODO el historial, no solo el último commit

      - name: Preparar Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm
          cache-dependency-path: package-lock.json

Por defecto, checkout hace un clon superficial de un solo commit: es más rápido y suficiente casi siempre. Necesitarás fetch-depth: 0 cuando algo del pipeline lea el historial: git describe para versionar (lección 02-06), el cálculo del lead time de la 01-05 o los análisis que comparan con la rama base (lección 02-05).

En setup-node, cache-dependency-path indica qué fichero determina la clave de caché. En Reservalia es el package-lock.json de la raíz; en un monorepo con varios lockfiles habría que enumerarlos.

Consejo transferible. El patrón descargar código → preparar runtime en la versión fijada → instalar dependencias de forma reproducible → ejecutar comando es idéntico en cualquier lenguaje. Cambia setup-node por setup-python, setup-java o setup-go, y npm ci por pip install -r requirements.txt, mvn -B verify o go build ./....

  1. Runners alojados frente a autoalojados

El runner es la máquina que ejecuta un job. Hay dos formas de conseguirla.

Alojado (GitHub) Autoalojado (tuyo)
Quién lo mantiene El proveedor
Estado inicial Máquina limpia cada vez El que tú garantices
Puesta en marcha Cero configuración Instalar, registrar y mantener el agente
Coste Por minuto consumido Coste de la máquina, corra o no
Acceso a red privada No, salvo túnel Sí, está dentro de tu red
Hardware especial Limitado al catálogo El que quieras (GPU, macOS, ARM)
Riesgo de seguridad Aislado y efímero Persistente: lo que deja un job puede verlo el siguiente

Un runner autoalojado compensa en cuatro situaciones: necesitas acceso a recursos que no están expuestos a internet; tu build requiere hardware que el catálogo no ofrece o que resulta desproporcionadamente caro por minuto; consumes tantos minutos que una máquina propia sale más barata; o una norma exige que el código no salga de tu infraestructura.

El aviso de seguridad que no se puede omitir: un runner autoalojado nunca debe ejecutar workflows de pull requests que vengan de forks. Un desconocido abre un PR, modifica el workflow y su código se ejecuta en una máquina de tu red interna. Como los runners autoalojados son persistentes, un job malicioso puede dejar ficheros o credenciales que verá el siguiente. La mitigación mínima es ejecutar cada job en un contenedor efímero y aislar el runner en su propia subred. El endurecimiento serio del pipeline —permisos del token, OIDC, aislamiento de secretos— es materia de la lección 04-03.

La decisión de Reservalia: runners alojados. Son 340 negocios de pago y tres personas; los minutos son baratos comparados con el tiempo de Nuria manteniendo máquinas. Es la decisión correcta para la inmensa mayoría de los equipos pequeños.

  1. Variables de entorno y secretos

El pipeline necesita valores de configuración. Unos son públicos (la región de AWS) y otros no (una contraseña). GitHub Actions los distingue.

env:                                    # variables para TODO el workflow
  NODE_ENV: test
  TZ: Europe/Madrid
jobs:
  test:
    runs-on: ubuntu-22.04
    env:                                # variables solo para este job
      DATABASE_URL: postgres://reservalia:ci@localhost:5432/reservalia_test
    steps:
      - run: npm run test:integracion
        env: { LOG_LEVEL: debug }       # variables solo para este paso

Los tres ámbitos se combinan: el más específico gana. TZ: Europe/Madrid merece atención especial: los runners corren en UTC, y una aplicación de reservas de cita está llena de lógica horaria. Fijar la zona horaria explícitamente evita el clásico "la prueba falla solo en CI y solo después de las 22:00".

Los secretos se declaran en la configuración del repositorio (o de la organización) y se leen con el contexto secrets, por ejemplo SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} dentro del env: de un step. Reglas básicas de uso, sin entrar todavía en el endurecimiento serio:

  • Nunca se escriben en el YAML (el fichero está en el repositorio; el secreto, no) y se inyectan como variables de entorno o parámetros with:, nunca concatenados en una cadena que luego se imprime.
  • La plataforma enmascara los valores conocidos en los logs, sustituyéndolos por ***. No confíes en eso como única protección: si tu script hace echo de un JSON que contiene el secreto transformado (por ejemplo, en base64), el enmascarado no lo detecta.
  • Los secretos no llegan a los workflows disparados por PR desde forks. Es una medida de seguridad deliberada, y explica por qué un PR externo puede fallar en pasos que necesitan credenciales.

En Reservalia, por ahora, solo hay dos secretos: SONAR_TOKEN (lección 02-05) y AWS_ROLE_CI (lección 02-06). La contraseña de PostgreSQL en CI no es un secreto: es una base de datos efímera que vive nueve minutos dentro del runner y muere. Tratar como secreto algo que no lo es genera ruido y hace más difícil proteger lo que sí importa.

  1. Servicios de apoyo: un PostgreSQL real para las pruebas

Las pruebas de integración de Reservalia consultan una base de datos de verdad. En local eso lo resuelve el docker-compose.yml de la lección 01-04; en CI se resuelve con services:, que levanta contenedores auxiliares junto al job.

jobs:
  test:
    runs-on: ubuntu-22.04
    services:
      postgres:                                    # 1
        image: postgres:16.3                       # 2
        env:                                       # 3
          POSTGRES_USER: reservalia
          POSTGRES_PASSWORD: ci
          POSTGRES_DB: reservalia_test
        ports:
          - 5432:5432                              # 4
        options: >-                                # 5
          --health-cmd "pg_isready -U reservalia -d reservalia_test"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10
    steps:
      # ... checkout, setup-node y npm ci, igual que en el apartado 2 ...
      - name: Pruebas de integración
        run: npm run test:integracion --workspace apps/api
        env:
          DATABASE_URL: postgres://reservalia:ci@localhost:5432/reservalia_test   # 6
  1. postgres: es una etiqueta que tú eliges; también será el nombre de red del contenedor.
  2. image: postgres:16.3 es exactamente la misma versión que usa el docker-compose.yml local y la misma familia que RDS en producción. Que los tres coincidan es la mitad de la reproducibilidad.
  3. env: configura el contenedor. Usamos la base reservalia_test, no reservalia: nombrar distinto la base de pruebas evita accidentes el día que alguien copie una cadena de conexión.
  4. ports: - 5432:5432 publica el puerto del contenedor en el runner, para que el proceso de Node pueda conectarse a localhost:5432.
  5. options: son opciones de Docker. El healthcheck es imprescindible: sin él, los steps arrancan en cuanto el contenedor existe, no cuando la base de datos está lista para aceptar conexiones. El resultado sería un ECONNREFUSED intermitente —el peor tipo de fallo, porque unas veces pasa y otras no—. Con --health-cmd, la plataforma espera a que pg_isready responda antes de ejecutar el primer step.
  6. DATABASE_URL apunta a localhost, no a postgres. Los steps se ejecutan directamente en el runner, no dentro de un contenedor, así que ven el puerto publicado. (Si el job usara container:, la dirección correcta sería postgres:5432, el nombre del servicio. Es una de las confusiones más frecuentes del ecosistema.)
flowchart LR
    subgraph RUNNER["Runner ubuntu-22.04 (efímero)"]
        S["steps: node + npm test"] -- "localhost:5432" --> P["servicio postgres:16.3"]
    end

  1. Depurar un workflow que falla

Tarde o temprano el workflow se pondrá rojo por algo que no entiendes. Este es el orden de ataque, del método más barato al más caro.

Paso 1: leer el log del step correcto. Suena obvio y casi nadie lo hace bien. Despliega el primer step en rojo (los siguientes no se ejecutan) y busca la primera línea de error, no la última: la última suele ser el resumen inútil Process completed with exit code 1. Paso 2: comprobar si es un problema de entorno o de código. La pregunta que separa los dos mundos: ¿este mismo commit pasa en mi portátil?

git checkout a3f9c21          # el commit exacto que falló en CI
rm -rf node_modules           # imitar la máquina limpia
npm ci                        # la misma instalación que hace el runner
npm test

Si en local pasa y en CI no, la diferencia está en el entorno: versión de Node, zona horaria, variables ausentes, ficheros no versionados que solo existen en tu disco, orden de las pruebas o dependencia de una base de datos con datos previos.

Paso 3: activar los logs de diagnóstico. Define en el repositorio dos variables de tipo secreto: ACTIONS_STEP_DEBUG: true (detalle interno de cada step: entradas de las acciones, comandos ejecutados) y ACTIONS_RUNNER_DEBUG: true (preparación de la máquina, red, caché). Al relanzar, los logs incluirán líneas ##[debug]. Es muchísimo texto: úsalo cuando el log normal no baste y desactívalo después.

Paso 4: imprimir el estado del runner. Un step temporal de diagnóstico resuelve un porcentaje sorprendente de casos:

      - name: Diagnóstico
        run: |
          node --version            # ¿coincide con .nvmrc?
          echo "TZ=$TZ  fecha=$(date)"
          pwd && ls -la             # ¿está el código donde crees?
          env | sort | grep -v -i 'token\|secret\|password'   # variables, sin filtrar secretos

Fíjate en el grep -v: nunca vuelques el entorno completo en un log público.

Paso 5: reproducir el pipeline en local.

act pull_request -W .github/workflows/ci.yml     # opción A: simular el workflow

docker run --rm -it -v "$PWD":/repo -w /repo \
  node:20.11.0-bookworm-slim bash                # opción B: el mismo contenedor base
#   dentro: npm ci && npm test

act es cómodo para iterar sobre la estructura del YAML, pero su imagen no es idéntica a la del runner real y algunas acciones no funcionan igual. La opción B es más laboriosa y reproduce mejor la realidad.

Paso 6: el fallo intermitente. Si el mismo commit pasa unas veces y falla otras, no estás ante un problema de configuración sino ante una prueba inestable. No lo resuelvas relanzando: anótalo y aplica la política de cuarentena de la lección 02-04.

Errores Comunes y Consejos

Error 1: usar ubuntu-latest y versiones flotantes. El día que la etiqueta apunta a otra versión del sistema, tu build se rompe sin que nadie haya tocado el repositorio, y perderás medio día buscando en tu diff una causa que no está ahí. Fija ubuntu-22.04, fija postgres:16.3, lee la versión de Node del .nvmrc. Error 2: olvidar el checkout, el clásico de principiante: el runner arranca con el disco vacío y npm ci falla con un desconcertante "no existe package.json".

Error 3: services: sin healthcheck. El síntoma es una prueba de integración que falla con ECONNREFUSED una de cada cinco ejecuciones. La causa no es la red: es que los steps arrancaron antes de que PostgreSQL estuviera listo.

Error 4: duplicar la versión de Node. Poner node-version: 20 en el YAML mientras el .nvmrc dice 20.11.0 funciona hasta que deja de funcionar. Una versión, un sitio.

Error 5: no poner timeout-minutes. Un job colgado consume minutos facturables hasta el límite por defecto de la plataforma, que se mide en horas.

Consejo 1: empieza con un job y hazlo crecer. El ci.yml de este capítulo tiene un solo job y ya aporta valor real. En las siguientes lecciones le añadiremos build, calidad y publicar.

Consejo 2: prueba los cambios del pipeline en un PR —el workflow se lee de la rama del PR, así que puedes iterar sin ensuciar mainy nombra siempre los steps: - name: Instalar dependencias frente a un run desnudo es la diferencia entre un log legible y un muro de comandos.

Ejercicios

Ejercicio 1

Este workflow falla siempre en el paso de pruebas con ECONNREFUSED 127.0.0.1:5432. Encuentra los tres problemas y corrígelos.

name: CI
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:latest
        env: { POSTGRES_PASSWORD: ci }
    steps:
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install
      - run: npm run test:integracion --workspace apps/api
        env:
          DATABASE_URL: postgres://postgres:ci@localhost:5432/postgres

Ejercicio 2

Diego dice: "En mi portátil npm test pasa siempre; en CI falla la prueba calcula huecos del día siguiente una de cada tres veces, y sobre todo por la tarde". Enumera tres hipótesis ordenadas por probabilidad y di qué comprobarías en cada caso.

Ejercicio 3

Reservalia quiere que el pipeline no se ejecute cuando el PR solo cambia ficheros de infra/terraform/ o el README.md. Escribe el bloque on: correspondiente y explica un riesgo de esta optimización.

Soluciones

Solución 1. Los tres problemas:

  1. Falta actions/checkout@v4 como primer step: el runner no tiene el código, así que npm install no encuentra package.json. Es la causa raíz de que nada funcione.
  2. Falta el healthcheck en el servicio y falta publicar el puerto. Sin ports: - 5432:5432 el puerto no es accesible desde el runner, y sin --health-cmd los steps arrancan antes de que la base esté lista. Ambos producen ECONNREFUSED.
  3. Versiones flotantes: ubuntu-latest, postgres:latest y node-version: 20. Deben ser ubuntu-22.04, postgres:16.3 y node-version-file: .nvmrc.

De regalo, dos mejoras: npm install debe ser npm ci, y falta timeout-minutes.

Solución 2. Hipótesis ordenadas:

  1. Zona horaria. Es la más probable, y el "por la tarde" es la pista definitiva: el runner corre en UTC y el portátil de Diego en Europe/Madrid. Una prueba sobre "el día siguiente" ejecutada a las 23:30 de Madrid cae en un día distinto en UTC. Comprobación: echo $TZ && date en el runner; solución: TZ: Europe/Madrid a nivel de workflow, o mejor, hacer la prueba independiente del reloj inyectando la fecha.
  2. Estado compartido entre pruebas. Si el orden de ejecución varía o hay paralelismo, una prueba puede dejar citas en la base que otra encuentra. Comprobación: ejecutar solo esa prueba de forma aislada y con la base recién creada.
  3. Datos que dependen del calendario. Festivos o fines de semana: la prueba pasa de lunes a jueves y falla el viernes porque "el día siguiente" es sábado y el negocio cierra. Comprobación: fijar una fecha concreta en la prueba.

Solución 3.

on:
  pull_request:
    branches: [main]
    paths-ignore: ['infra/terraform/**', '**/*.md']
  push:
    branches: [main]
    paths-ignore: ['infra/terraform/**', '**/*.md']

El riesgo: si el check test está configurado como obligatorio para fusionar (lección 02-07), un PR que solo toca README.md nunca ejecutará el check y quedará bloqueado para siempre esperando un resultado que no llegará. La solución habitual es un workflow gemelo que devuelva verde inmediatamente para esas rutas, o usar filtros a nivel de job en lugar de a nivel de evento. Volveremos sobre ello en la 02-07.

Conclusión

Reservalia ya tiene Integración Continua de verdad:

  • El pipeline vive en .github/workflows/ci.yml, versionado con el código, y se dispara solo en cada pull request hacia main y en cada push a main.
  • El job test corre en un runner ubuntu-22.04 fijado, con timeout-minutes, y ejecuta cuatro pasos: checkout, setup-node leyendo la versión del .nvmrc con caché de npm, npm ci y npm test.
  • Los runners alojados son la elección correcta para un equipo pequeño; los autoalojados solo compensan por acceso a red privada, hardware especial, volumen o cumplimiento normativo, y traen consigo un problema de aislamiento que hay que tomarse en serio.
  • Las variables se declaran en tres ámbitos (workflow, job, step) y los secretos se leen con secrets.NOMBRE sin escribirlos jamás en el YAML. TZ: Europe/Madrid evita una familia entera de fallos horarios.
  • Un contenedor de servicio postgres:16.3 con healthcheck da a las pruebas de integración una base de datos real, idéntica a la de local y de la misma familia que producción.
  • Y tienes un método de depuración de seis pasos, del log al contenedor reproducido en local, con ACTIONS_STEP_DEBUG como artillería intermedia.

Lo que todavía no hace este pipeline es construir nada: ejecuta las pruebas sobre el código fuente y ahí se acaba. En la siguiente lección, Automatización de la Construcción, veremos qué significa realmente "construir", en qué orden hay que compilar los paquetes de un monorepo, por qué npm ci y el lockfile son los pilares de una build reproducible, cómo empaquetar apps/api en un Dockerfile multi-etapa con usuario no root, y cómo funcionan las cachés —incluida la razón por la que una caché mal invalidada es peor que no tener ninguna—. Al final, el ci.yml tendrá su segundo job: build.

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