CI para proyectos Node.js con GitHub Actions
1. Descripción general
Cada vez que alguien hace push de código o abre un pull request, necesitas saber inmediatamente si el código se compila y si las pruebas siguen pasando. Esto debe ocurrir automáticamente, sin depender de la máquina de cada persona. Este artículo te guía para construir un pipeline CI para un proyecto Node.js usando GitHub Actions. Al terminar, tendrás un workflow que instale dependencias con caché, ejecute lint, ejecute pruebas en múltiples versiones de Node, exporte reportes de coverage y cancele automáticamente las ejecuciones obsoletas.
2. Preparar el proyecto de ejemplo
Usaremos un módulo pequeño que calcula el total de un pedido. Primero está package.json:
{
"name": "order-service",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"lint": "eslint src test",
"test": "node --test",
"test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info"
}
}
El proyecto usa el test runner integrado de Node (node:test), así que no necesitas instalar Jest o Mocha. "type": "module" permite usar la sintaxis import/export. El script test:coverage usa dos reporters simultáneamente. Un reporter imprime los resultados en la consola. El otro reporter escribe el archivo lcov.info para guardarlo después.
La lógica de negocio está en src/order.js:
export function calculateTotal(items, discountPercent = 0) {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError('discountPercent phải nằm trong khoảng 0–100');
}
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return Math.round(subtotal * (100 - discountPercent)) / 100;
}
Esta función suma price * quantity de cada artículo, luego resta el descuento. Si el porcentaje de descuento está fuera del rango 0–100, la función lanza RangeError.
Las pruebas correspondientes están en test/order.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateTotal } from '../src/order.js';
test('tính tổng đơn hàng có giảm giá', () => {
const items = [
{ price: 100000, quantity: 2 },
{ price: 50000, quantity: 1 },
];
assert.equal(calculateTotal(items, 10), 225000);
});
test('ném lỗi khi giảm giá không hợp lệ', () => {
assert.throws(() => calculateTotal([], 120), RangeError);
});
El comando node --test busca automáticamente archivos con sufijo .test.js. Prueba en tu máquina local:
npm install --save-dev eslint @eslint/js
npm test
✔ tính tổng đơn hàng có giảm giá (0.9ms)
✔ ném lỗi khi giảm giá không hợp lệ (0.3ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
ESLint 9 usa flat config. Creas el archivo eslint.config.js en el directorio raíz:
import js from '@eslint/js';
export default [js.configs.recommended];
Finalmente, asegúrate de hacer commit del archivo package-lock.json. Como verás después, CI necesita este archivo.
3. Tu primer workflow de CI
GitHub Actions lee archivos YAML en el directorio .github/workflows/. Creas el archivo .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
Cada parte del archivo funciona así:
on: el workflow se ejecuta cuando hay un push amainy cuando hay un pull request hacia cualquier rama.runs-on: ubuntu-latest: el job se ejecuta en una máquina virtual Linux nueva proporcionada por GitHub.actions/checkout@v4: clona el código fuente en la máquina virtual.actions/setup-node@v4: instala Node 22. La opcióncache: npmguarda el directorio de caché de npm, usando el hash depackage-lock.jsoncomo clave. En futuras ejecuciones, los paquetes se recuperarán del caché en lugar de descargarlos nuevamente del registro.npm ci: instala las dependencias exactamente como en el lockfile.npm test: ejecuta las pruebas. Si el comando retorna un código de salida diferente de 0, el job fallará y el pull request será marcado en rojo.
Después de hacer push de este archivo, ve a la pestaña Actions del repositorio para ver los logs. En la segunda ejecución, el paso setup-node imprimirá una línea similar:
Cache restored successfully
Cache restored from key: node-cache-Linux-x64-npm-3f9a1c...
Nota: No uses
npm installen CI.npm installpuede actualizar el lockfile e instalar versiones diferentes a las de tu máquina.npm cieliminanode_modules, instala exactamente según el lockfile, e informa de un error de inmediato sipackage.jsonypackage-lock.jsonno coinciden. Es gracias a este comportamiento que detectas temprano si alguien olvida hacer commit del lockfile.
4. Ejecutar pruebas en múltiples versiones de Node
Tu biblioteca o servicio puede tener que ejecutarse en múltiples versiones de Node. La estrategia matrix te ayuda a duplicar el job para cada versión:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
GitHub creará dos jobs en paralelo, llamados test (22) y test (24). La expresión ${{ matrix.node-version }} se reemplaza con el valor correspondiente en cada job.
Por defecto fail-fast es true: si falla un job, los demás se cancelan inmediatamente. Establecer fail-fast: false te muestra el panorama completo. Por ejemplo, sabes que el error solo ocurre en Node 24, mientras que Node 22 sigue pasando.
Puedes expandir la matriz a múltiples sistemas operativos:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [22, 24]
runs-on: ${{ matrix.os }}
Esta configuración crea 2 × 2 = 4 jobs.
Nota: Cada dimensión que añades a la matriz multiplica el número de jobs. Para un servicio que solo se implementa en Linux y Node 24, una matriz grande solo desperdicia minutos de CI sin beneficio. Usa matrix cuando realmente soportas múltiples entornos, como cuando publicas un paquete npm.
Si tu proyecto usa una sola versión de Node, deja que CI y tu máquina dev lean de la misma fuente. Creas un archivo .nvmrc que contiene 24, luego declaras:
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
5. Separar el job de lint y establecer dependencias entre jobs
Lint es mucho más rápido que las pruebas y no necesita ejecutarse en cada versión de Node. Por lo tanto, debes separar lint en un job independiente y hacer que el job de pruebas espere a que lint pase:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
test:
needs: lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
needs: lint hace que el job test solo comience cuando lint tenga éxito. Supongamos que alguien deja una variable sin usar:
const unusedTax = 0.1;
Entonces el job lint fallará con el mensaje:
/home/runner/work/order-service/src/order.js
1:7 error 'unusedTax' is assigned a value but never used no-unused-vars
✖ 1 problem (1 error, 0 warnings)
El job test no se ejecutará, así que ahorras tiempo de toda la matriz.
Necesitas considerar el trade-off aquí. Usar needs hace que el tiempo total sea más largo cuando todo pasa, porque los dos jobs se ejecutan secuencialmente. Si priorizas la velocidad de respuesta, omite needs para que lint y test se ejecuten en paralelo.
6. Reportes de coverage y guardando artifacts
Todo archivo generado en la máquina virtual se pierde cuando termina el job. Para mantener los reportes de coverage, usas actions/upload-artifact:
coverage:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: lcov.info
retention-days: 7
El script test:coverage imprime la tabla de coverage directamente en el log del job:
ℹ start of coverage report
ℹ file | line % | branch % | funcs %
ℹ src/order.js | 100.00 | 100.00 | 100.00
ℹ all files | 100.00 | 100.00 | 100.00
ℹ end of coverage report
La condición if: always() asegura que el artifact se cargue incluso si las pruebas fallan. Este es precisamente el momento en que más necesitas el reporte. El archivo lcov.info aparece al final de la página de resumen de la ejecución del workflow y se eliminará después de 7 días.
Si quieres que CI falle cuando el coverage baja, desde Node 22.8 puedes agregar una bandera de umbral al script:
node --test --experimental-test-coverage --test-coverage-lines=80
Este comando retorna código de salida 1 si el porcentaje de líneas cubiertas está por debajo del 80%.
7. Optimizar el workflow
Estas tres configuraciones deberían estar en la mayoría de los workflows de CI. Las colocas en el nivel superior del archivo:
name: CI
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
paths-ignore:
- '**.md'
- 'docs/**'
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
El significado de cada configuración:
paths-ignore: un commit que solo modifique README o documentación no activará CI.permissions: contents: read: restringe los permisos deGITHUB_TOKENa solo lectura. Si una dependencia maliciosa ejecuta scripts en CI, tampoco puede hacer push de código ni modificar releases.concurrency: cuando haces push de tres commits seguidos hacia el mismo pull request, solo se conserva la ejecución más reciente. Las ejecuciones anteriores se cancelan porque sus resultados ya no son válidos.
Además, debes agregar timeout-minutes a cada job:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
Por defecto, un job puede ejecutarse hasta 6 horas. Incluso una sola prueba atascada, por ejemplo por olvidar cerrar una conexión de base de datos, puede agotar tu cuota de minutos.
Nota:
paths-ignoretiene una trampa cuando se combina con protección de rama. Supongamos que requieres que la verificacióntestpase antes de poder hacer merge. Un pull request que solo modifique archivos.mdno ejecutará el workflow, por lo que esa verificación permanecerá en estado “Expected — Waiting”. En este punto tienes dos opciones: eliminarpaths-ignoreo eliminar el requisito obligatorio para esa verificación.
8. Errores comunes
Falta lockfile. Si package-lock.json no se le hace commit o está listado en .gitignore, el paso setup-node reportará un error:
Error: Dependencies lock file is not found in /home/runner/work/order-service/order-service.
Supported file patterns: package-lock.json,npm-shrinkwrap.json,yarn.lock
La solución es hacer commit del lockfile. Si el proyecto usa pnpm o yarn, cambia el valor cache al gestor de paquetes correcto.
Las pruebas se ejecutan en modo watch. Algunas herramientas, como Vitest o Jest cuando se configuran en modo watch, esperarán cambios de archivo indefinidamente. GitHub Actions establece automáticamente la variable de entorno CI=true, y la mayoría de las herramientas se basan en esto para desactivar el modo watch. De todas formas, debes invocar explícitamente el comando para ejecutar una sola vez, por ejemplo vitest run.
Los secrets no están disponibles en pull requests desde forks. Para eventos pull_request desde un repositorio fork, los secrets no se pasarán al workflow. Este es un mecanismo de seguridad intencional. Las pruebas que requieren una clave API real fallarán en este caso. Mock los servicios externos en las pruebas unitarias. No cambies a pull_request_target para “apagar fuegos”, porque este evento se ejecuta con permisos completos en código sin revisar.
Diferencias entre sistemas operativos. Las pruebas que pasan en macOS pero fallan en Ubuntu generalmente se deben a la sensibilidad de mayúsculas y minúsculas en las rutas. Por ejemplo, import './Order.js' mientras que el archivo real se llama order.js. El sistema de archivos de Linux distingue mayúsculas y minúsculas, así que CI detecta este error antes de que llegue a producción.
Cuándo no necesitas todo este pipeline. Para un script interno de solo unas pocas docenas de líneas, un solo job npm ci && npm test es suficiente. Solo debes agregar matriz, coverage y jobs separados cuando el proyecto crece o cuando hay múltiples colaboradores.
9. Conclusión
En este artículo, has construido un pipeline CI completo para un proyecto Node.js con GitHub Actions. El pipeline usa npm ci junto con el caché de setup-node para instalar dependencias de forma rápida y estable. La estrategia matrix ayuda a probar en múltiples versiones de Node. El job de lint se separa y se vincula al job de pruebas mediante needs. Los reportes de coverage se guardan usando artifacts. Finalmente, el workflow se optimiza con concurrency, permissions y timeout-minutes.
El principio central es mantener CI rápido y confiable. Un pipeline que se ejecuta quince minutos o que falla ocasionalmente de forma aleatoria hará que todo el equipo eventualmente ignore sus resultados. Comienza con el workflow minimalista en la sección 3 y agrega cada parte cuando el proyecto realmente la necesita.