La lección anterior terminaba con una frase que ahora hay que cumplir: "lo que queda es hacerlo tú". Durante seis módulos has leído el ci.yml de Reservalia línea a línea, has visto cómo se promociona un artefacto por digest y cómo se vuelve atrás en cuatro minutos. Todo eso era el pipeline de otra persona. A partir de aquí el pipeline es tuyo: lo escribes, lo rompes a propósito, lo arreglas y lo vuelves a romper hasta que entiendas por qué falla cada cosa. Este primer laboratorio construye desde cero un proyecto llamado Mini-Reservalia —una versión reducida y autocontenida de Reservalia, con el mismo dominio de citas y disponibilidad pero sin base de datos, sin AWS y sin dependencias de producción— y le monta encima su primer workflow de integración continua, en cuatro incrementos que puedes ver ejecutarse uno a uno. Al acabar tendrás un repositorio en GitHub donde ningún cambio puede llegar a main sin que las pruebas pasen en verde, y lo habrás comprobado intentando saltártelo.

El proyecto que crees aquí no se tira: las lecciones 07-02 a 07-06 lo amplían. Tómate en serio el código de este primer apartado, porque es el sustrato de todo el módulo.

Contenido

  1. Objetivo, requisitos previos y punto de partida
  2. Preparar la máquina y verificar las herramientas
  3. Crear Mini-Reservalia desde cero
  4. Inicializar el repositorio y subirlo a GitHub
  5. Incremento 1: el workflow mínimo que solo se ejecuta
  6. Incremento 2: instalar, comprobar y probar
  7. Incremento 3: dos jobs en paralelo con needs
  8. Incremento 4: caché de dependencias y medición del efecto
  9. Provocar un fallo a propósito y leer el rojo
  10. El flujo real: rama, pull request, checks, merge
  11. Proteger main y comprobar que bloquea
  12. El badge de estado en el README
  13. Verificación final
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. Objetivo, requisitos previos y punto de partida

Objetivo. Al terminar esta lección tendrás un repositorio en GitHub con un proyecto Node.js funcional y un workflow de GitHub Actions que, en cada push y en cada pull request, instala dependencias de forma reproducible, ejecuta el análisis estático y las pruebas en paralelo, y bloquea el merge a main si algo falla.

Requisitos previos.

Requisito Por qué Cómo comprobarlo
Node.js 20.6 o superior El proyecto usa el runner de test nativo (node:test) y ESM node --version
npm 10 o superior npm ci y el lockfile v3 npm --version
Git 2.30 o superior Ramas, remotos git --version
Cuenta de GitHub gratuita Actions incluye minutos gratis en repositorios públicos Entra en github.com
Docker (opcional) Solo se usa a partir de la 07-03 docker --version

Punto de partida. Un directorio vacío. No hay nada previo: este es el kilómetro cero.

Coste. Cero. GitHub Actions es gratuito e ilimitado en minutos para repositorios públicos. Si haces el repositorio privado consumirás de la cuota gratuita mensual del plan Free, que sobra de largo para este módulo. Recomendación: hazlo público.

Equivalente real en Reservalia. El repositorio de Reservalia es privado, con Actions sobre runners hospedados de mayor tamaño para los jobs de test. Nada de lo que haces aquí cambia conceptualmente: cambia la factura.

  1. Preparar la máquina y verificar las herramientas

Antes de escribir una línea, comprueba el entorno. Un porcentaje incómodo de los "no me funciona el pipeline" del mundo real son en realidad "tengo Node 16 en local y 20 en CI".

# Paso 0: verificacion del entorno
node --version    # esperado: v20.6.0 o superior (v22.x tambien vale)
npm --version     # esperado: 10.x o superior
git --version     # esperado: 2.30 o superior

Qué debes ver: tres versiones que cumplan los mínimos. Si node --version muestra v18.x o inferior, instala una versión reciente (con nvm install 20 && nvm use 20, con el instalador oficial o con el gestor de paquetes de tu sistema). El runner de test nativo existe desde Node 18, pero la opción --test-shard que usaremos en la 07-02 requiere Node 20.6+.

Configura además tu identidad de Git si no la tienes, porque los commits sin autor dan problemas al calcular métricas más adelante:

git config --global user.name "Tu Nombre"
git config --global user.email "[email protected]"

Opcionalmente, instala la CLI de GitHub (gh). No es obligatoria —todo se puede hacer desde la web— pero acorta mucho los pasos y en la 07-04 la usaremos para calcular las métricas DORA:

gh --version
gh auth login   # elige GitHub.com > HTTPS > autenticar con navegador
gh auth status  # esperado: "Logged in to github.com as <tu-usuario>"

  1. Crear Mini-Reservalia desde cero

Mini-Reservalia resuelve el problema central de Reservalia —dado un horario de apertura y unas citas ya reservadas, ¿qué huecos quedan libres?— con cero dependencias de producción. Esa restricción es deliberada: un proyecto sin dependencias arranca en milisegundos, se puede ejecutar en cualquier runner y deja el foco en el pipeline, que es lo que estamos aprendiendo.

Crea la estructura:

mkdir mini-reservalia && cd mini-reservalia
mkdir -p src test scripts .github/workflows

3.1 package.json

{
  "name": "mini-reservalia",
  "version": "0.1.0",
  "private": true,
  "description": "Version reducida de Reservalia para el modulo practico de CI/CD",
  "type": "module",
  "engines": {
    "node": ">=20.6.0"
  },
  "scripts": {
    "lint": "eslint .",
    "test": "node --test test/",
    "build": "node scripts/build.js",
    "start": "node src/servidor.js"
  },
  "devDependencies": {
    "@eslint/js": "^9.14.0",
    "eslint": "^9.14.0"
  }
}

Cuatro decisiones que importan:

  • "type": "module": usamos ESM (import/export), como el Reservalia real.
  • "engines": documenta la versión mínima. No la impone por sí solo, pero npm ci avisa y en la 07-02 la usaremos como referencia de la matriz.
  • Los cuatro scripts (lint, test, build, start) son el contrato entre el proyecto y el pipeline. Esto es lo que la 06-07 llamaba "lógica en scripts y YAML delgado": el workflow no sabrá cómo se hace el lint, solo que existe npm run lint. Si mañana cambias ESLint por otra cosa, el ci.yml no se toca.
  • Cero dependencias de producción: dependencies ni siquiera aparece.

3.2 src/disponibilidad.js

Este es el corazón del dominio. Léelo con calma, porque en la 07-02 escribirás sus casos límite.

// src/disponibilidad.js
// Calculo de huecos libres para Mini-Reservalia.
// Sin dependencias: solo aritmetica de minutos desde medianoche.

const PATRON_HORA = /^([01]\d|2[0-3]):([0-5]\d)$/;

/**
 * Convierte "HH:MM" en minutos desde medianoche.
 * @param {string} hhmm hora en formato 24 h
 * @returns {number} minutos (0..1439)
 */
export function aMinutos(hhmm) {
  if (typeof hhmm !== 'string' || !PATRON_HORA.test(hhmm)) {
    throw new TypeError(`Hora invalida: ${JSON.stringify(hhmm)}. Se esperaba "HH:MM" en 24 h.`);
  }
  const [horas, minutos] = hhmm.split(':').map(Number);
  return horas * 60 + minutos;
}

/**
 * Convierte minutos desde medianoche en "HH:MM".
 * @param {number} minutos
 * @returns {string}
 */
export function aHora(minutos) {
  const h = Math.floor(minutos / 60);
  const m = minutos % 60;
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}

/**
 * Normaliza la lista de citas: convierte a minutos, descarta las degeneradas,
 * ordena y FUSIONA los solapamientos. Sin esta fusion, dos citas que se pisan
 * generarian huecos fantasma.
 */
function normalizarOcupacion(citas) {
  return citas
    .map((cita) => ({ inicio: aMinutos(cita.inicio), fin: aMinutos(cita.fin) }))
    .filter((cita) => cita.fin > cita.inicio) // una cita de duracion 0 o negativa no ocupa
    .sort((a, b) => a.inicio - b.inicio)
    .reduce((acumulado, cita) => {
      const ultima = acumulado[acumulado.length - 1];
      if (ultima && cita.inicio <= ultima.fin) {
        ultima.fin = Math.max(ultima.fin, cita.fin); // se solapan o se tocan: fusionar
      } else {
        acumulado.push({ ...cita });
      }
      return acumulado;
    }, []);
}

/** Trocea el intervalo [desde, hasta) en slots de `duracion` minutos. */
function trocear(destino, desde, hasta, duracion) {
  for (let inicio = desde; inicio + duracion <= hasta; inicio += duracion) {
    destino.push({ inicio: aHora(inicio), fin: aHora(inicio + duracion) });
  }
}

/**
 * Calcula los huecos libres de un dia.
 *
 * @param {{inicio: string, fin: string}[]|{inicio: string, fin: string}} horario
 *        Uno o varios tramos de apertura. Varios tramos = horario partido.
 * @param {{inicio: string, fin: string}[]} citas Citas ya reservadas.
 * @param {number} duracionMin Duracion del servicio, en minutos.
 * @returns {{inicio: string, fin: string}[]} huecos libres, en orden cronologico.
 */
export function calcularHuecos(horario, citas = [], duracionMin = 30) {
  if (!Number.isInteger(duracionMin) || duracionMin <= 0) {
    throw new RangeError(`La duracion debe ser un entero positivo de minutos; recibido: ${duracionMin}`);
  }
  const tramos = Array.isArray(horario) ? horario : [horario];
  const ocupado = normalizarOcupacion(citas);
  const huecos = [];

  for (const tramo of tramos) {
    const apertura = aMinutos(tramo.inicio);
    const cierre = aMinutos(tramo.fin);
    if (cierre <= apertura) {
      throw new RangeError(`Tramo invalido: ${tramo.inicio}-${tramo.fin}. El cierre debe ser posterior a la apertura.`);
    }

    let cursor = apertura;
    for (const cita of ocupado) {
      if (cita.fin <= cursor || cita.inicio >= cierre) continue; // fuera de este tramo
      trocear(huecos, cursor, Math.min(cita.inicio, cierre), duracionMin);
      cursor = Math.max(cursor, cita.fin); // una cita que cruza el cierre recorta el tramo
      if (cursor >= cierre) break;
    }
    if (cursor < cierre) trocear(huecos, cursor, cierre, duracionMin);
  }

  return huecos;
}

Tres detalles que suelen ser fuente de bugs reales y que conviene que veas ahora, porque en la 07-02 los convertirás en pruebas:

Caso Comportamiento Por qué
Dos citas solapadas (10:00-11:00 y 10:30-11:30) Se fusionan en una sola ocupación 10:00-11:30 Si no, el algoritmo "vería" un hueco entre ellas
Cita que empieza antes del cierre y acaba después (13:45-14:30 con cierre a las 14:00) Recorta el tramo hasta el cierre El cursor avanza a 14:30, mayor que el cierre; se sale del bucle
Horario partido (09:00-14:00 y 16:00-20:00) Los huecos nunca cruzan la pausa Cada tramo se trocea de forma independiente

3.3 src/servidor.js

Un servidor HTTP sin dependencias, con dos rutas: /salud (que el pipeline usará como smoke test en la 07-03) y /api/huecos.

// src/servidor.js
// Servidor HTTP minimo de Mini-Reservalia. Sin dependencias externas.

import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { calcularHuecos } from './disponibilidad.js';

export const VERSION = process.env.APP_VERSION ?? 'dev';
export const PUERTO = Number(process.env.PORT ?? 3000);

/** Horario por defecto del negocio de demostracion: manana y tarde. */
export const HORARIO_POR_DEFECTO = [
  { inicio: '09:00', fin: '14:00' },
  { inicio: '16:00', fin: '20:00' },
];

/** Agenda en memoria. En la 07-02 se sustituye por una capa de persistencia. */
export const AGENDA_DEMO = new Map([
  ['2026-03-02', [{ inicio: '10:00', fin: '10:30' }, { inicio: '17:00', fin: '18:00' }]],
  ['2026-03-03', [{ inicio: '09:00', fin: '12:00' }]],
]);

const PATRON_FECHA = /^\d{4}-\d{2}-\d{2}$/;

function responderJson(res, codigo, cuerpo) {
  const texto = JSON.stringify(cuerpo);
  res.writeHead(codigo, {
    'content-type': 'application/json; charset=utf-8',
    'content-length': Buffer.byteLength(texto),
  });
  res.end(texto);
}

/**
 * Crea el servidor. Se exporta como funcion para que las pruebas puedan
 * levantarlo en un puerto efimero sin tocar variables de entorno.
 */
export function crearServidor({ agenda = AGENDA_DEMO, horario = HORARIO_POR_DEFECTO } = {}) {
  return http.createServer((req, res) => {
    const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);

    if (req.method === 'GET' && url.pathname === '/salud') {
      return responderJson(res, 200, {
        estado: 'ok',
        version: VERSION,
        activoSeg: Math.round(process.uptime()),
      });
    }

    if (req.method === 'GET' && url.pathname === '/api/huecos') {
      const fecha = url.searchParams.get('fecha');
      const duracion = Number(url.searchParams.get('duracion') ?? 30);

      if (!fecha || !PATRON_FECHA.test(fecha)) {
        return responderJson(res, 400, { error: 'Parametro "fecha" obligatorio con formato YYYY-MM-DD' });
      }
      if (!Number.isInteger(duracion) || duracion <= 0) {
        return responderJson(res, 400, { error: 'Parametro "duracion" debe ser un entero positivo de minutos' });
      }

      try {
        const citas = agenda.get(fecha) ?? [];
        const huecos = calcularHuecos(horario, citas, duracion);
        return responderJson(res, 200, { fecha, duracion, total: huecos.length, huecos });
      } catch (error) {
        return responderJson(res, 400, { error: error.message });
      }
    }

    return responderJson(res, 404, { error: 'Ruta no encontrada' });
  });
}

// Solo arranca si se ejecuta directamente (node src/servidor.js).
// Al importarlo desde una prueba, no se abre ningun puerto.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  crearServidor().listen(PUERTO, () => {
    console.log(`Mini-Reservalia ${VERSION} escuchando en http://localhost:${PUERTO}`);
  });
}

La guarda del final (process.argv[1] === fileURLToPath(import.meta.url)) es el equivalente ESM del clásico if __name__ == "__main__". Sin ella, cualquier prueba que importe el módulo abriría un puerto y el proceso de test no terminaría nunca: un cuelgue de CI clásico y desconcertante.

3.4 test/disponibilidad.test.js

// test/disponibilidad.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { calcularHuecos, aMinutos, aHora } from '../src/disponibilidad.js';

const MANANA = { inicio: '09:00', fin: '12:00' };

test('aMinutos convierte horas validas', () => {
  assert.equal(aMinutos('00:00'), 0);
  assert.equal(aMinutos('09:30'), 570);
  assert.equal(aMinutos('23:59'), 1439);
});

test('aMinutos rechaza formatos invalidos', () => {
  assert.throws(() => aMinutos('9:00'), TypeError);
  assert.throws(() => aMinutos('25:00'), TypeError);
  assert.throws(() => aMinutos(900), TypeError);
});

test('aHora es la inversa de aMinutos', () => {
  for (const hora of ['00:00', '07:05', '13:45', '23:59']) {
    assert.equal(aHora(aMinutos(hora)), hora);
  }
});

test('un dia sin citas se trocea entero', () => {
  const huecos = calcularHuecos(MANANA, [], 60);
  assert.deepEqual(huecos, [
    { inicio: '09:00', fin: '10:00' },
    { inicio: '10:00', fin: '11:00' },
    { inicio: '11:00', fin: '12:00' },
  ]);
});

test('una cita parte el dia en dos bloques', () => {
  const huecos = calcularHuecos(MANANA, [{ inicio: '10:00', fin: '11:00' }], 60);
  assert.deepEqual(huecos, [
    { inicio: '09:00', fin: '10:00' },
    { inicio: '11:00', fin: '12:00' },
  ]);
});

test('el resto sobrante no genera un hueco corto', () => {
  // De 09:00 a 12:00 con slots de 50 min caben 3 (hasta 11:30) y sobran 30 min.
  const huecos = calcularHuecos(MANANA, [], 50);
  assert.equal(huecos.length, 3);
  assert.equal(huecos.at(-1).fin, '11:30');
});

test('una duracion invalida es un error de programacion, no un resultado vacio', () => {
  assert.throws(() => calcularHuecos(MANANA, [], 0), RangeError);
  assert.throws(() => calcularHuecos(MANANA, [], 12.5), RangeError);
});

Seis pruebas es poco: en la 07-02 subiremos a las tres capas de la pirámide con casos límite. Para el primer pipeline basta con tener una señal real que pueda ponerse en rojo.

3.5 scripts/build.js

// scripts/build.js
// "Build" de Mini-Reservalia: copia src/ a dist/ y sella la version.
// Es trivial a proposito, pero cumple el papel del build real: produce un
// artefacto identificable y falla si algo no se puede resolver.

import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
import { execSync } from 'node:child_process';

const DESTINO = new URL('../dist/', import.meta.url);

function commitActual() {
  if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA;
  try {
    return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
  } catch {
    return 'desconocido';
  }
}

await rm(DESTINO, { recursive: true, force: true });
await mkdir(DESTINO, { recursive: true });
await cp(new URL('../src/', import.meta.url), new URL('src/', DESTINO), { recursive: true });

const sello = {
  nombre: 'mini-reservalia',
  version: process.env.npm_package_version ?? '0.0.0',
  commit: commitActual(),
  construidoEn: new Date().toISOString(),
};
await writeFile(new URL('version.json', DESTINO), `${JSON.stringify(sello, null, 2)}\n`);

// Comprobacion de humo del propio build: si el modulo no importa, fallamos aqui
// y no en produccion.
await import('../dist/src/servidor.js');

console.log(`Build OK -> dist/ (commit ${sello.commit.slice(0, 7)})`);

Fíjate en el await import(...) final: es una verificación del artefacto dentro del propio build. Si alguien deja un import roto, el build falla en 200 ms en vez de que lo descubra el smoke test tres etapas más tarde.

3.6 eslint.config.js

// eslint.config.js (flat config, ESLint 9)
import js from '@eslint/js';

export default [
  { ignores: ['dist/**', 'node_modules/**', 'coverage/**'] },
  js.configs.recommended,
  {
    languageOptions: {
      ecmaVersion: 2023,
      sourceType: 'module',
      globals: {
        process: 'readonly',
        console: 'readonly',
        Buffer: 'readonly',
        URL: 'readonly',
        fetch: 'readonly',
        setTimeout: 'readonly',
      },
    },
    rules: {
      'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      'no-console': 'off',
      eqeqeq: ['error', 'always'],
    },
  },
];

3.7 .gitignore

node_modules/
dist/
coverage/
*.log
.env
.DS_Store

node_modules/ y dist/ fuera del repositorio, por supuesto. Y .env desde el primer día: la 07-05 explicará con detalle lo que cuesta un secreto commiteado, pero la prevención empieza aquí.

3.8 Comprobar que todo funciona en local

npm install          # genera package-lock.json
npm run lint         # esperado: sin salida (todo limpio)
npm test             # esperado: 7 pruebas en verde
npm run build        # esperado: "Build OK -> dist/ (commit ...)"
npm start            # arranca en http://localhost:3000

Qué debes ver al ejecutar npm test:

▶ aMinutos convierte horas validas
✔ aMinutos convierte horas validas (0.9ms)
...
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0

Y con el servidor arrancado, en otra terminal:

curl -s http://localhost:3000/salud
# {"estado":"ok","version":"dev","activoSeg":12}

curl -s "http://localhost:3000/api/huecos?fecha=2026-03-02&duracion=60"
# {"fecha":"2026-03-02","duracion":60,"total":6,"huecos":[{"inicio":"09:00","fin":"10:00"}, ...]}

Si esto funciona en tu máquina, ya tienes la parte difícil: un proyecto que sabe verificarse a sí mismo con un solo comando. El pipeline solo va a ejecutar esos comandos en una máquina que no es la tuya.

  1. Inicializar el repositorio y subirlo a GitHub

git init -b main
git add .
git commit -m "feat: Mini-Reservalia con calculo de huecos y servidor HTTP"

Con la CLI de GitHub, en un comando:

gh repo create mini-reservalia --public --source=. --remote=origin --push

Qué debes ver: https://github.com/<tu-usuario>/mini-reservalia y, al abrirlo, los ficheros que acabas de crear.

Sin gh: crea el repositorio vacío desde la web (sin README, sin .gitignore, sin licencia, para evitar un historial divergente) y después:

git remote add origin https://github.com/<tu-usuario>/mini-reservalia.git
git push -u origin main

Comprueba que package-lock.json sí está en el repositorio. Es el requisito de la construcción reproducible de la 02-03: sin lockfile, npm ci no funciona y cada ejecución del pipeline podría instalar versiones distintas.

git ls-files | grep lock
# esperado: package-lock.json

  1. Incremento 1: el workflow mínimo que solo se ejecuta

Vamos a construir el ci.yml en cuatro pasos, viendo cada uno ejecutarse. La tentación es escribir el workflow completo de golpe; resístete. Cuando un workflow de 80 líneas falla en el primer intento, no sabes cuál de las 80 líneas es la culpable.

Fichero .github/workflows/ci.yml — versión 1:

# .github/workflows/ci.yml - INCREMENTO 1
# Objetivo: comprobar que el workflow se dispara y que el runner funciona.
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  hola:
    name: Comprobacion del runner
    runs-on: ubuntu-latest
    steps:
      - name: Descargar el codigo
        uses: actions/checkout@v4

      - name: Mostrar el entorno
        run: |
          echo "Runner: $RUNNER_OS"
          echo "Rama:   $GITHUB_REF_NAME"
          echo "Commit: $GITHUB_SHA"
          node --version
          npm --version
          ls -la
git add .github/workflows/ci.yml
git commit -m "ci: workflow minimo de comprobacion del runner"
git push

Qué debes ver. Entra en la pestaña Actions de tu repositorio. Debe aparecer una ejecución llamada "ci: workflow minimo de comprobacion del runner" con el workflow CI. Ábrela, entra en el job Comprobacion del runner y despliega el paso "Mostrar el entorno". Verás algo como:

Runner: Linux
Rama:   main
Commit: 8f3c1e2...
v20.18.0
10.8.2
total 48
drwxr-xr-x  5 runner docker 4096 ...
-rw-r--r--  1 runner docker  612 package.json

Tres cosas que acabas de aprender empíricamente y que ninguna lectura sustituye:

  1. El runner no tiene tu código por defecto. Si quitas el paso actions/checkout@v4, el ls -la sale casi vacío. Pruébalo si quieres.
  2. Node ya está instalado en el runner ubuntu-latest, pero con la versión que GitHub decida. Por eso el incremento 2 fija la versión explícitamente.
  3. El contexto viaja en variables de entorno: GITHUB_SHA, GITHUB_REF_NAME y decenas más, tal como vimos en la 06-06.

Duración esperada: entre 5 y 15 segundos. Anótala; la usaremos como referencia.

  1. Incremento 2: instalar, comprobar y probar

Ahora el pipeline hace trabajo de verdad.

# .github/workflows/ci.yml - INCREMENTO 2
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  verificar:
    name: Verificar
    runs-on: ubuntu-latest
    steps:
      - name: Descargar el codigo
        uses: actions/checkout@v4

      - name: Preparar Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Instalar dependencias
        run: npm ci

      - name: Analisis estatico
        run: npm run lint

      - name: Pruebas
        run: npm test

      - name: Construir
        run: npm run build
git add .github/workflows/ci.yml
git commit -m "ci: instalar, lint, test y build"
git push

Qué debes ver. La ejecución tarda ahora unos 35-60 segundos. Todos los pasos en verde. Fíjate especialmente en el log de "Instalar dependencias":

added 104 packages, and audited 105 packages in 6s

Y en el de "Pruebas":

ℹ tests 7
ℹ pass 7
ℹ fail 0

Por qué npm ci y no npm install. Ya lo vimos en la 02-03, pero ahora puedes verificarlo: npm ci borra node_modules, instala exactamente lo que dice package-lock.json y falla si el lockfile no concuerda con el package.json. npm install puede actualizar el lockfile silenciosamente, lo que significa que el pipeline probaría un árbol de dependencias distinto del que tú probaste. Compruébalo tú mismo:

# En local, edita package.json y sube la version de eslint a "^9.99.0" SIN tocar el lock
npm ci
# npm error `npm ci` can only install packages when your package.json and
# npm error package-lock.json are in sync.

Deshaz ese cambio antes de continuar.

  1. Incremento 3: dos jobs en paralelo con needs

Un solo job es una fila india: si el lint tarda 20 segundos, las pruebas esperan 20 segundos. Peor aún: si el lint falla, no ves el resultado de las pruebas, así que arreglas el lint, vuelves a esperar y descubres que además hay un test roto. Dos viajes donde debería haber uno.

La 04-01 llamaba a esto "feedback en una sola pasada". Vamos a separar.

# .github/workflows/ci.yml - INCREMENTO 3
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Si llegan dos pushes seguidos a la misma rama, cancela el anterior:
# nadie necesita el resultado de un commit que ya ha sido superado.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  calidad:
    name: Calidad
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - name: ESLint
        run: npm run lint

  test:
    name: Pruebas
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - name: Pruebas unitarias
        run: npm test

  build:
    name: Construir
    runs-on: ubuntu-latest
    # Solo se construye si calidad Y pruebas han pasado: no tiene sentido
    # gastar tiempo construyendo algo que ya sabemos que esta mal.
    needs: [calidad, test]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - name: Construir artefacto
        run: npm run build
      - name: Publicar dist/ como artefacto
        uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist/
          retention-days: 7
git add .github/workflows/ci.yml
git commit -m "ci: separar calidad y test en paralelo, build tras ambos"
git push

Qué debes ver. En la vista del run, GitHub dibuja el grafo: Calidad y Pruebas uno al lado del otro, y Construir a su derecha con las flechas de dependencia. Los dos primeros empiezan a la vez. Al final, en la parte inferior de la página del run, aparece la sección Artifacts con dist-<sha> descargable.

Lo que has ganado:

Un job (incremento 2) Tres jobs (incremento 3)
Feedback si falla el lint Solo el lint Lint y pruebas a la vez
Tiempo de pared Suma de todo Máximo de las ramas paralelas + build
Coste en minutos Menor (una máquina) Mayor (tres máquinas)
Artefacto Se queda en el runner Descargable durante 7 días

Es la contrapartida clásica: paralelizar reduce el tiempo de pared y aumenta el consumo de minutos. En repositorios públicos los minutos son gratis, así que la decisión es obvia; en uno privado con cientos de ejecuciones diarias hay que pensarla. Nota, además, que el npm ci se repite tres veces —una por job, porque cada job es una máquina limpia—. Eso lo ataca el incremento 4.

  1. Incremento 4: caché de dependencias y medición del efecto

actions/setup-node sabe cachear el directorio de npm. Un cambio de dos líneas:

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'                 # <-- anadido
          cache-dependency-path: package-lock.json

Aplícalo en los tres jobs. El fichero completo, la versión que cierra esta lección:

# .github/workflows/ci.yml - VERSION FINAL DE LA 07-01
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

# Minimo privilegio: este workflow solo necesita leer el codigo.
# La 07-05 profundiza en esto; se pone ya para no adquirir el mal habito.
permissions:
  contents: read

jobs:
  calidad:
    name: Calidad
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: package-lock.json
      - run: npm ci
      - name: ESLint
        run: npm run lint

  test:
    name: Pruebas
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: package-lock.json
      - run: npm ci
      - name: Pruebas unitarias
        run: npm test

  build:
    name: Construir
    runs-on: ubuntu-latest
    needs: [calidad, test]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: package-lock.json
      - run: npm ci
      - name: Construir artefacto
        run: npm run build
      - name: Publicar dist/ como artefacto
        uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist/
          retention-days: 7

Cómo medir el efecto correctamente. La primera ejecución con caché es más lenta, porque tiene que guardarla. La medición honesta compara la segunda ejecución con caché contra la línea base:

  1. Anota el tiempo del paso npm ci del incremento 3 (sin caché). Se lee en el propio log: Run npm ci ... 8s.
  2. Haz push del cambio. Primera ejecución: en el log de setup-node verás Cache not found for input keys: node-cache-Linux-npm-... y al final Cache saved with key: ....
  3. Haz un segundo push trivial (cambia el README). Ahora el log dirá Cache restored from key: node-cache-Linux-npm-<hash>.

Tabla típica en un proyecto de este tamaño:

Medición npm ci Total del run
Sin caché 7-9 s ~45 s
Con caché (primera vez) 7-9 s + guardado ~50 s
Con caché (a partir de la segunda) 2-3 s ~30 s

Cinco segundos por job no parecen mucho. Multiplícalo por tres jobs, por 40 ejecuciones diarias y por 250 días laborables: son unas 40 horas de máquina al año en un proyecto sin dependencias de producción. En Reservalia, con un node_modules de cientos de megas, la caché recorta minutos por ejecución, no segundos. La 04-04 lo cuantificaba; ahora lo has visto.

Detalle importante sobre la clave de caché. cache-dependency-path: package-lock.json hace que la clave incluya el hash del lockfile. Cuando cambies una dependencia, la clave cambia y se descarga todo de nuevo: es exactamente lo que quieres. Una caché cuya clave no depende del lockfile es una caché que sirve dependencias caducadas, y ese es el "verde falso" del que hablaba la 04-04.

  1. Provocar un fallo a propósito y leer el rojo

Un pipeline que nunca has visto fallar no es un pipeline: es decoración. Vamos a romperlo.

git checkout -b romper-a-proposito

Edita src/disponibilidad.js y cambia una sola línea dentro de trocear:

 function trocear(destino, desde, hasta, duracion) {
-  for (let inicio = desde; inicio + duracion <= hasta; inicio += duracion) {
+  for (let inicio = desde; inicio < hasta; inicio += duracion) {
     destino.push({ inicio: aHora(inicio), fin: aHora(inicio + duracion) });
   }
 }

Es un bug realista: ahora se genera un último hueco que sobresale del horario de cierre. Un negocio recibiría reservas a las 11:30 cuando cierra a las 12:00 y el servicio dura 50 minutos.

npm test   # comprobacion en local: debe fallar

Qué debes ver en local:

✖ el resto sobrante no genera un hueco corto (1.2ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  4 !== 3
ℹ tests 7
ℹ pass 6
ℹ fail 1

Súbelo igualmente, que es lo que queremos observar:

git commit -am "fix: troceo hasta el final del tramo"
git push -u origin romper-a-proposito

Ahora abre el pull request:

gh pr create --title "Troceo hasta el final del tramo" --body "Cambio en el bucle de troceo." --base main

Qué debes ver en el PR:

  1. Una caja de checks al final de la conversación con tres entradas: Calidad, Pruebas, Construir.
  2. Calidad en verde (ESLint no detecta bugs lógicos: solo mira la forma del código; esto es exactamente la advertencia de la 02-05).
  3. Pruebas en rojo, con el aspa.
  4. Construir en gris, marcado como skipped: nunca llegó a ejecutarse porque su needs no se cumplió. Has ahorrado un job.
  5. El botón de merge en gris con el mensaje "Some checks were not successful".

Pincha en Details del check rojo. GitHub te lleva al log del step fallido, ya desplegado y con la línea del error resaltada. Además, en la pestaña Files changed del PR aparece una anotación roja sobre la línea del fichero de test: el runner de Node emite el fallo en un formato que Actions reconoce, así que el error se ve sobre el código, no solo en el log.

Ahora arréglalo dentro del mismo PR:

git checkout src/disponibilidad.js   # revertir el cambio
npm test                             # verde en local
git commit -am "revert: restaurar el troceo correcto"
git push

Qué debes ver: el PR reejecuta los checks automáticamente (por el trigger pull_request, que se dispara también en synchronize), los tres pasan a verde y el botón de merge se habilita. Mergea:

gh pr merge --squash --delete-branch

Acabas de recorrer el ciclo completo: rama → PR → rojo → diagnóstico → arreglo → verde → merge. Es el ciclo que un equipo repite veinte veces al día.

  1. El flujo real: rama, pull request, checks, merge

Formalicemos lo que acabas de hacer, porque el orden importa y hay un paso que casi todo el mundo se salta.

flowchart LR
    A["git checkout -b rama"] --> B["Cambio pequeno<br/>+ prueba"]
    B --> C["npm test en LOCAL"]
    C -->|rojo| B
    C -->|verde| D["push + pull request"]
    D --> E["CI ejecuta<br/>calidad / test / build"]
    E -->|rojo| F["Leer log,<br/>reproducir en local"]
    F --> B
    E -->|verde| G["Revision humana"]
    G --> H["Merge a main"]
    H --> I["CI en main"]

El paso que se salta la gente es npm test en local antes del push. Usar el CI como intérprete de comandos —empujar para ver si pasa— convierte un ciclo de 3 segundos en uno de 3 minutos y llena el historial de commits de "arreglar CI", "arreglar CI de verdad", "ahora sí". Regla práctica: si el comando que va a ejecutar el pipeline no lo puedes ejecutar tú, el pipeline está mal diseñado. Por eso los cuatro scripts de package.json son el contrato.

  1. Proteger main y comprobar que bloquea

Hasta ahora los checks son informativos: nada te impide mergear en rojo, ni empujar directamente a main. Vamos a cerrar esa puerta, que es la "stop the line" de la 02-01 hecha configuración.

Desde la web: Settings → Rules → Rulesets → New ruleset → New branch ruleset.

  • Name: proteger-main
  • Enforcement status: Active
  • Target branches: Add target → Include default branch
  • Marca Require a pull request before merging (con Required approvals: 0 si trabajas solo; en un equipo, 1).
  • Marca Require status checks to pass, busca y añade Calidad, Pruebas y Construir. Marca también Require branches to be up to date before merging.
  • Marca Block force pushes.

Con gh, en un solo comando (crea el fichero y aplícalo):

cat > /tmp/ruleset.json <<'JSON'
{
  "name": "proteger-main",
  "target": "branch",
  "enforcement": "active",
  "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
  "rules": [
    { "type": "deletion" },
    { "type": "non_fast_forward" },
    { "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 0,
        "dismiss_stale_reviews_on_push": true,
        "require_code_owner_review": false,
        "require_last_push_approval": false,
        "required_review_thread_resolution": false
      } },
    { "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": true,
        "required_status_checks": [
          { "context": "Calidad" },
          { "context": "Pruebas" },
          { "context": "Construir" }
        ]
      } }
  ]
}
JSON

gh api --method POST "repos/{owner}/{repo}/rulesets" --input /tmp/ruleset.json

La comprobación de que falla cuando debe fallar. Una protección que no has intentado violar no sabes si existe. Dos pruebas:

Prueba A — push directo a main:

git checkout main
echo "# nota" >> README.md
git commit -am "test: intento de push directo"
git push

Qué debes ver:

remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: - Changes must be made through a pull request.
! [remote rejected] main -> main (push declined due to repository rule violations)

Deshaz el commit local: git reset --hard origin/main.

Prueba B — merge de un PR en rojo:

git checkout -b prueba-de-bloqueo

Rompe otra vez el test (cambia assert.equal(huecos.length, 3) por 4 en test/disponibilidad.test.js), commitea, empuja, abre el PR e intenta mergear:

gh pr merge --squash

Qué debes ver:

X Pull request #3 is not mergeable: the merge commit cannot be cleanly created.

o, desde la web, el botón de merge deshabilitado con "Required statuses must pass before merging". Cierra el PR y borra la rama:

gh pr close prueba-de-bloqueo --delete-branch

Esta comprobación negativa —que el sistema impide lo que debe impedir— es tan importante como la positiva, y es la que casi nadie hace. Un check obligatorio mal escrito (por ejemplo, con el nombre test en vez de Pruebas) queda eternamente "pendiente" y bloquea todo; o, peor, si lo configuras sobre un job que puede saltarse, deja pasar cualquier cosa.

Detalle que cuesta media hora a todo el mundo: el nombre del check obligatorio es el name: del job, no la clave del job en el YAML. Nuestro job test: se llama Pruebas porque tiene name: Pruebas. Si añades el check con el nombre equivocado, GitHub lo espera para siempre y el PR nunca es mergeable. Si te ocurre, la lista de nombres exactos está en la caja de checks de cualquier PR reciente.

  1. El badge de estado en el README

Crea README.md:

# Mini-Reservalia

[![CI](https://github.com/<tu-usuario>/mini-reservalia/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/<tu-usuario>/mini-reservalia/actions/workflows/ci.yml)

Version reducida de Reservalia para el modulo practico del curso de CI/CD.
Calcula los huecos libres de un dia a partir de un horario de apertura y las citas ya reservadas.

## Uso

npm ci npm test npm start # http://localhost:3000

## Endpoints

| Metodo | Ruta | Descripcion |
|---|---|---|
| GET | `/salud` | Estado del servicio y version desplegada |
| GET | `/api/huecos?fecha=YYYY-MM-DD&duracion=30` | Huecos libres del dia |

## Pipeline

| Etapa | Que hace | Rompe el build |
|---|---|---|
| Calidad | ESLint sobre todo el codigo | Si |
| Pruebas | `node --test` | Si |
| Construir | `dist/` sellado con el commit | Si |

Súbelo por PR (ya no puedes empujar a main, precisamente):

git checkout -b docs-readme
git add README.md
git commit -m "docs: README con badge de CI"
git push -u origin docs-readme
gh pr create --fill
# ...esperar a que los checks pasen...
gh pr merge --squash --delete-branch

Qué debes ver: en la portada del repositorio, un badge verde que dice CI: passing. El ?branch=main importa: sin él, el badge refleja la última ejecución de cualquier rama, así que un experimento roto en una rama personal pondría el badge en rojo y dejaría de significar nada.

  1. Verificación final

Recorre esta lista. Todo debe cumplirse antes de pasar a la 07-02.

# Comprobación Cómo se verifica Resultado esperado
1 El proyecto funciona en local npm ci && npm test && npm run build 7 pruebas en verde, dist/ creado
2 El servidor responde npm start y curl localhost:3000/salud {"estado":"ok",...}
3 El workflow se dispara en push Pestaña Actions tras un push a una rama Un run nuevo
4 El workflow se dispara en PR Abrir un PR Tres checks en la conversación
5 Los jobs corren en paralelo Grafo del run Calidad y Pruebas a la misma altura
6 La caché funciona Log de setup-node en el 2.º run Cache restored from key: ...
7 El pipeline se pone en rojo Romper un test y empujar Check Pruebas en rojo, Construir omitido
8 main rechaza el push directo git push desde main GH013: Repository rule violations
9 Un PR en rojo no se puede mergear Botón de merge Deshabilitado
10 El artefacto está disponible Sección Artifacts del run dist-<sha> descargable
11 El badge es verde Portada del repositorio CI: passing

Los tres en negrita son los que de verdad demuestran que el pipeline sirve para algo. Un pipeline que solo has visto en verde es una hipótesis sin comprobar.

Errores Comunes y Consejos

Síntoma: el workflow no aparece en la pestaña Actions después del push. Causa: la ruta del fichero es incorrecta. Debe ser exactamente .github/workflows/ci.yml, en la raíz del repositorio. github/workflows/, .github/workflow/ o .github/actions/ no valen. Arreglo: git ls-files .github debe mostrar .github/workflows/ci.yml. Si no, mueve el fichero y vuelve a empujar.

Síntoma: Error: Dependencies lock file is not found in /home/runner/work/.... Causa: has puesto cache: 'npm' en setup-node pero package-lock.json no está commiteado (probablemente por un .gitignore demasiado agresivo). Arreglo: comprueba que package-lock.json no está en .gitignore, ejecuta npm install para generarlo y commitéalo. El lockfile va siempre al repositorio.

Síntoma: npm error code EUSAGE — npm ci can only install packages when your package.json and package-lock.json are in sync. Causa: editaste package.json a mano sin regenerar el lockfile. Arreglo: en local, npm install (que sí actualiza el lock) y commitea los dos ficheros juntos. Un package.json y un lock que no concuerdan son la primera causa de "en mi máquina va".

Síntoma: el job de pruebas se queda colgado hasta el timeout de 6 horas. Causa clásica en Node: algún módulo importado por las pruebas abre un puerto o un temporizador y el proceso no termina. Por eso src/servidor.js tiene la guarda process.argv[1] === fileURLToPath(import.meta.url). Arreglo: añade timeout-minutes: 10 a todos los jobs —debería ser un reflejo— e investiga en local con node --test --test-reporter=spec test/. Si el proceso no termina, el culpable es un recurso abierto.

Síntoma: el check obligatorio queda en Expected — Waiting for status to be reported para siempre. Causa: el nombre del check configurado en el ruleset no coincide con el name: del job, o el job no se ejecuta en ese contexto (por ejemplo, tienes un filtro paths que lo salta). Arreglo: copia los nombres exactos de la caja de checks de un PR reciente. Y cuidado con los filtros paths: un job requerido que se salta por filtros bloquea el PR indefinidamente; la solución habitual es un job "verde permanente" que siempre se ejecuta y del que dependen los demás.

Síntoma: dos ejecuciones de la misma rama, la primera cancelada con "Canceling since a higher priority waiting request exists". Causa: no es un error. Es tu bloque concurrency con cancel-in-progress: true haciendo su trabajo. Consejo: no pongas esto en el workflow de despliegue con cancel-in-progress: true; cancelar un despliegue a mitad puede dejar el sistema en un estado inconsistente. En CI está bien; en CD, la 07-03 usará cancel-in-progress: false.

Síntoma: ESLint falla con Parsing error: 'import' and 'export' may appear only with 'sourceType: module'. Causa: falta "type": "module" en package.json o sourceType: 'module' en eslint.config.js. Arreglo: ambos deben estar. En este proyecto, los dos.

Consejo de higiene: commits pequeños y frecuentes. La 02-07 lo justificaba con la integración continua; aquí lo notarás de forma inmediata: cuando el pipeline se pone en rojo tras un commit de 400 líneas, el diagnóstico es arqueología; tras uno de 20, es evidente.

Ejercicios

Ejercicio 1: un job de comprobación del formato con --check

Añade al pipeline una comprobación de formato con Prettier que falle si el código no está formateado, y un script format que lo arregle. El job debe llamarse Formato y correr en paralelo con Calidad y Pruebas. Comprueba que falla desformateando un fichero a propósito.

Ejercicio 2: no ejecutar el pipeline cuando solo cambia documentación

Un cambio en README.md no necesita ejecutar tres jobs. Configura el workflow para que se salte los cambios que solo tocan Markdown, sin romper la protección de rama. Piénsalo bien: si los checks requeridos no se ejecutan, el PR queda bloqueado para siempre. Explica en un comentario del YAML por qué tu solución no cae en esa trampa.

Ejercicio 3: un resumen legible del run

Haz que el job Construir escriba en $GITHUB_STEP_SUMMARY una tabla con el commit, la versión, el tamaño de dist/ y el número de pruebas ejecutadas, de modo que se vea en la portada del run sin abrir ningún log.

Soluciones

Solución 1.

npm install --save-dev prettier

.prettierrc.json:

{
  "singleQuote": true,
  "printWidth": 110,
  "semi": true,
  "trailingComma": "all"
}

.prettierignore:

dist/
coverage/
package-lock.json

En package.json, dos scripts nuevos:

"scripts": {
  "format": "prettier --write .",
  "format:check": "prettier --check .",
  "lint": "eslint .",
  "test": "node --test test/",
  "build": "node scripts/build.js",
  "start": "node src/servidor.js"
}

Ejecuta npm run format una vez para normalizar todo el proyecto y commitea el resultado en su propio commit (un "commit de formato" aislado, para que no contamine los diffs futuros). Después, el job:

  formato:
    name: Formato
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: package-lock.json
      - run: npm ci
      - name: Comprobar formato
        run: npm run format:check

Y añade formato al needs del job build: needs: [calidad, formato, test].

Comprobación de que falla: mete espacios raros en src/servidor.js y empuja. Verás:

Checking formatting...
[warn] src/servidor.js
[warn] Code style issues found in the above file. Run Prettier with --write to fix.
Error: Process completed with exit code 1.

La diferencia entre --write y --check es exactamente la diferencia entre una herramienta de desarrollo y una puerta de calidad: el pipeline nunca modifica el código, solo constata. Un pipeline que autoformatea y commitea genera commits fantasma, dispara nuevas ejecuciones y puede entrar en bucle.

Solución 2.

La trampa: si añades paths-ignore: ['**.md'] al trigger, en un PR que solo toca Markdown los jobs no se ejecutan, los checks requeridos nunca reportan y el PR queda bloqueado en Expected para siempre.

Hay dos soluciones correctas. La sencilla y robusta:

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  cambios:
    name: Detectar cambios
    runs-on: ubuntu-latest
    outputs:
      codigo: ${{ steps.filtro.outputs.codigo }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - id: filtro
        # Comparamos con la base del PR (o con el commit anterior en push).
        run: |
          BASE="${{ github.event.pull_request.base.sha || github.event.before }}"
          if git diff --name-only "$BASE" HEAD | grep -qvE '\.(md|txt)$|^docs/'; then
            echo "codigo=true" >> "$GITHUB_OUTPUT"
          else
            echo "codigo=false" >> "$GITHUB_OUTPUT"
            echo "Solo documentacion: se omiten las verificaciones pesadas." >> "$GITHUB_STEP_SUMMARY"
          fi

  test:
    name: Pruebas          # <-- el check requerido SIEMPRE existe
    needs: cambios
    runs-on: ubuntu-latest
    steps:
      - name: Omitido por ser solo documentacion
        if: needs.cambios.outputs.codigo != 'true'
        run: echo "Sin cambios de codigo; nada que probar."
      - uses: actions/checkout@v4
        if: needs.cambios.outputs.codigo == 'true'
      - uses: actions/setup-node@v4
        if: needs.cambios.outputs.codigo == 'true'
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
        if: needs.cambios.outputs.codigo == 'true'
      - run: npm test
        if: needs.cambios.outputs.codigo == 'true'

La clave, y lo que hay que escribir en el comentario del YAML: el job requerido siempre se ejecuta y siempre reporta verde; lo que se salta son sus pasos caros. Un job que existe y no hace nada cuesta ~5 segundos y mantiene la protección de rama funcionando. Un job que no existe bloquea el PR para siempre.

La alternativa avanzada —y lo que hace Reservalia— es la 04-04: paths en el trigger más un job agregador ci-ok con if: always() que evalúa el resultado de los demás y es el único check requerido.

Solución 3.

Añade al job build, después de construir:

      - name: Resumen del run
        run: |
          TAM=$(du -sh dist | cut -f1)
          VER=$(node -p "require('./dist/version.json').version")
          COM=$(node -p "require('./dist/version.json').commit.slice(0,7)")
          N_TESTS=$(npm test 2>&1 | grep -oP '(?<=^# pass )\d+' || echo "?")
          {
            echo "## Artefacto construido"
            echo ""
            echo "| Campo | Valor |"
            echo "|---|---|"
            echo "| Commit | \`$COM\` |"
            echo "| Version | $VER |"
            echo "| Tamano de dist/ | $TAM |"
            echo "| Pruebas en verde | $N_TESTS |"
            echo "| Rama | \`${{ github.ref_name }}\` |"
            echo "| Autor | @${{ github.actor }} |"
          } >> "$GITHUB_STEP_SUMMARY"

Una versión mejor evita reejecutar las pruebas: haz que el job test escriba el número en un output y consúmelo aquí con needs.test.outputs.pruebas. Reejecutar la suite solo para contarla es el tipo de desperdicio que la 04-04 llamaba "trabajo duplicado por comodidad".

$GITHUB_STEP_SUMMARY es Markdown que se renderiza en la portada del run. Es la herramienta más infravalorada de GitHub Actions: convierte un pipeline en algo que un tech lead puede leer en diez segundos sin abrir un log. La usaremos mucho en la 07-02 (cobertura) y en la 07-04 (métricas DORA).

Reto opcional

Haz que el pipeline se ejecute también contra Node 22 sin duplicar el job, usando una matriz de dos entradas. Es un anticipo de la 07-02, así que si te sale, ya tienes medio ejercicio hecho. Pista: strategy.matrix.node: [20, 22] y node-version: ${{ matrix.node }}. Y una advertencia: al usar matriz, los nombres de los checks cambian a Pruebas (20) y Pruebas (22), así que tendrás que actualizar el ruleset de protección de rama o tu PR quedará bloqueado esperando un check llamado Pruebas que ya no existe. Ese descubrimiento vale más que el ejercicio.

Qué has construido

Un repositorio en GitHub con:

  • Mini-Reservalia: un proyecto Node.js con dominio real (cálculo de huecos), servidor HTTP con /salud y /api/huecos, pruebas, lint y build, todo sin dependencias de producción.
  • Un ci.yml de tres jobs con paralelización, concurrency, caché de dependencias y publicación de artefacto, construido en cuatro incrementos que has visto ejecutarse.
  • Una puerta real: main protegida, con tres checks obligatorios, comprobada por el lado positivo y por el negativo.
  • La experiencia de verlo en rojo, diagnosticarlo desde el log y la anotación, y arreglarlo dentro del mismo PR.

Conclusión

Lo que acabas de montar es, en miniatura, la etapa preparar → calidad/test → build del ci.yml de Reservalia que leíste en la 02-02. La diferencia es que ahora sabes por qué está cada línea, porque has visto qué pasa cuando falta: sin checkout el runner está vacío, sin setup-node la versión es la que toque, sin lockfile la instalación no es reproducible, sin needs se construye código que ya se sabe roto, sin caché se reinstala todo tres veces, y sin protección de rama nada de lo anterior obliga a nadie.

Ese pipeline, sin embargo, tiene una debilidad que no se ve en verde: su señal es débil. Siete pruebas unitarias sobre una función pura no dicen nada sobre si el endpoint /api/huecos responde bien, ni sobre si el servidor arranca, ni sobre qué porcentaje del código se está ejercitando realmente. Un pipeline verde con pruebas insuficientes da exactamente la misma sensación de seguridad que uno bueno, y esa es su peculiar peligrosidad —el "verde falso" de la 04-04—.

En la 07-02 atacamos justo eso: añadiremos a Mini-Reservalia una capa de persistencia, escribiremos las tres capas de la pirámide de pruebas sobre ella (unitarias con casos límite de verdad, integración contra la persistencia real, y un end-to-end contra el servidor arrancado), mediremos la cobertura y la publicaremos en el resumen del run, pondremos un umbral que rompa el build, ejecutaremos la suite en una matriz de versiones de Node y en dos shards paralelos, y —lo más instructivo— fabricaremos una prueba flaky a propósito para verla fallar de forma intermitente y aplicarle la política de cuarentena. No cierres el repositorio: seguimos justo aquí.

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