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
- Dónde vive el pipeline y qué lo dispara
- El primer
ci.ymlde Reservalia, línea a línea checkoutysetup-node: las dos acciones que usarás siempre- Runners alojados frente a autoalojados
- Variables de entorno y secretos
- Servicios de apoyo: un PostgreSQL real para las pruebas
- Depurar un workflow que falla
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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.
- El primer
ci.yml de Reservalia, línea a línea
ci.yml de Reservalia, línea a líneaEste 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 # 15Ahora, cada número:
name: CIes 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.on:declara los eventos que disparan el workflow. Es la pieza que convierte un script en un pipeline: nadie lo lanza a mano.pull_requestconbranches: [main]: se ejecuta cuando alguien abre un PR haciamainy 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.pushconbranches: [main]: se ejecuta también después del merge. Como vimos en la 02-01,mainpuede haber recibido otros commits mientras el PR estaba abierto, así que verificarla de nuevo no es redundante.jobs:abre la lista de unidades de trabajo. Cada una correrá en su propia máquina.test:es el identificador del job —el nombre que usarán otros jobs para depender de él conneeds:y el que configuraremos como check obligatorio en la 02-07—.name: Pruebases solo lo que se muestra en la interfaz.runs-on: ubuntu-22.04elige la máquina. Fíjate en que no usamosubuntu-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 depostgres:16.3frente apostgres:latestde la lección 01-04.timeout-minutes: 15mata 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.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.uses: actions/checkout@v4descarga 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@v4fija la versión mayor de la acción.uses: actions/setup-node@v4instala Node.js en el runner.node-version-file: .nvmrces el detalle más importante del fichero. En lugar de escribirnode-version: 20.11.0—duplicando la versión en dos sitios que se desincronizarán—, le decimos que lea el.nvmrcdel repositorio. Una única fuente de verdad para el portátil de Diego y para el runner.cache: npmguarda la caché de descargas de npm entre ejecuciones, indexada por el hash delpackage-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.run: npm ciejecuta un comando en la shell del runner.npm ci(y nonpm install) es la instalación reproducible; el porqué es materia de la 02-03.run: npm testejecuta 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.
checkout y setup-node: las dos acciones que usarás siempre
checkout y setup-node: las dos acciones que usarás siempreConviene 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.jsonPor 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-nodeporsetup-python,setup-javaosetup-go, ynpm ciporpip install -r requirements.txt,mvn -B verifyogo build ./....
- 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 | Tú |
| 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.
- 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 pasoLos 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 haceechode 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.
- 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 # 6postgres:es una etiqueta que tú eliges; también será el nombre de red del contenedor.image: postgres:16.3es exactamente la misma versión que usa eldocker-compose.ymllocal y la misma familia que RDS en producción. Que los tres coincidan es la mitad de la reproducibilidad.env:configura el contenedor. Usamos la basereservalia_test, noreservalia: nombrar distinto la base de pruebas evita accidentes el día que alguien copie una cadena de conexión.ports: - 5432:5432publica el puerto del contenedor en el runner, para que el proceso de Node pueda conectarse alocalhost:5432.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 unECONNREFUSEDintermitente —el peor tipo de fallo, porque unas veces pasa y otras no—. Con--health-cmd, la plataforma espera a quepg_isreadyresponda antes de ejecutar el primer step.DATABASE_URLapunta alocalhost, no apostgres. Los steps se ejecutan directamente en el runner, no dentro de un contenedor, así que ven el puerto publicado. (Si el job usaracontainer:, la dirección correcta seríapostgres: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
- 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 testSi 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 secretosFí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 testact 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 main— y 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/postgresEjercicio 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:
- Falta
actions/checkout@v4como primer step: el runner no tiene el código, así quenpm installno encuentrapackage.json. Es la causa raíz de que nada funcione. - Falta el healthcheck en el servicio y falta publicar el puerto. Sin
ports: - 5432:5432el puerto no es accesible desde el runner, y sin--health-cmdlos steps arrancan antes de que la base esté lista. Ambos producenECONNREFUSED. - Versiones flotantes:
ubuntu-latest,postgres:latestynode-version: 20. Deben serubuntu-22.04,postgres:16.3ynode-version-file: .nvmrc.
De regalo, dos mejoras: npm install debe ser npm ci, y falta timeout-minutes.
Solución 2. Hipótesis ordenadas:
- 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 && dateen el runner; solución:TZ: Europe/Madrida nivel de workflow, o mejor, hacer la prueba independiente del reloj inyectando la fecha. - 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.
- 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 haciamainy en cada push amain. - El job
testcorre en un runnerubuntu-22.04fijado, contimeout-minutes, y ejecuta cuatro pasos:checkout,setup-nodeleyendo la versión del.nvmrccon caché de npm,npm ciynpm 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.NOMBREsin escribirlos jamás en el YAML.TZ: Europe/Madridevita una familia entera de fallos horarios. - Un contenedor de servicio
postgres:16.3con 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_DEBUGcomo 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
- Conceptos Básicos de CI/CD
- Beneficios de CI/CD
- Herramientas Populares de CI/CD
- El Proyecto del Curso: la Aplicación que Vamos a Automatizar
- Métricas DORA: Cómo se Mide la Entrega de Software
Módulo 2: Integración Continua (CI)
- Introducción a la Integración Continua
- Configuración de un Entorno de CI
- Automatización de la Construcción
- Pruebas Automatizadas
- Calidad de Código y Análisis Estático
- Artefactos, Versionado y Promoción
- Integración con Control de Versiones
Módulo 3: Despliegue Continuo (CD)
- Introducción al Despliegue Continuo
- Automatización del Despliegue
- Infraestructura como Código y Entornos Reproducibles
- Estrategias de Despliegue
- Feature Flags, Rollback y Recuperación ante Fallos
- Monitoreo y Retroalimentación
Módulo 4: Prácticas Avanzadas de CI/CD
- Pipelines de CI/CD
- Gestión de Dependencias
- Seguridad en CI/CD
- Escalabilidad y Rendimiento
- Pipeline as Code: Plantillas, Reutilización y Pruebas del Pipeline
- Bases de Datos en el Pipeline: Migraciones Seguras
Módulo 5: Implementación de CI/CD en Proyectos Reales
- Caso de Estudio: Proyecto Web
- Caso de Estudio: Aplicación Móvil
- Caso de Estudio: Microservicios
- Caso de Estudio: Modernizar un Proyecto Legacy
Módulo 6: Herramientas y Tecnologías
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker y Kubernetes
- GitHub Actions a Fondo
- Comparativa y Criterios para Elegir Herramienta
Módulo 7: Ejercicios Prácticos
- Ejercicio 1: Configuración de un Pipeline Básico
- Ejercicio 2: Integración de Pruebas Automatizadas
- Ejercicio 3: Despliegue en un Entorno de Producción
- Ejercicio 4: Monitoreo y Retroalimentación
- Ejercicio 5: Endurecer el Pipeline con Seguridad y Secretos
- Proyecto Final: Pipeline Completo de Extremo a Extremo
