Qué significan realmente CI y CD
La integración continua (Continuous integration) es la práctica de fusionar el trabajo de cada desarrollador en una rama compartida con frecuencia y verificarlo automáticamente. Cada fusión dispara un build, un paso de lint y la suite de pruebas en una máquina donde nadie desarrolla. El objetivo es encontrar problemas de integración en cuestión de minutos, mientras el cambio aún es pequeño, en lugar de enfrentarse a un merge doloroso al final de una rama larga.
La entrega continua (Continuous delivery) extiende esa idea: la rama principal está siempre en un estado apto para su lanzamiento, y el pipeline puede desplegarla a producción en cualquier momento. El artefacto se construye y se prueba con cada cambio; un humano decide cuándo realizar el lanzamiento. El despliegue continuo (Continuous deployment) elimina esa decisión humana, enviando cada cambio que supere los filtros directamente a producción.
La diferencia entre delivery y deployment es la puerta de aprobación, y es la única diferencia. Todo lo anterior —el build, las pruebas, el artefacto— es idéntico.
A menudo, los equipos afirman “hacer CI/CD” cuando no hacen ninguna de las dos cosas. La integración continua requiere que el build se ejecute en una máquina compartida y autoritativa, no solo en una laptop antes de un commit. La entrega continua requiere que el artefacto producido por el pipeline sea el que se envíe, no algo reconstruido a mano en el momento del lanzamiento. Si falta cualquiera de las dos, el ciclo queda abierto y los beneficios se pierden.
La anatomía de un pipeline
Todo pipeline, desde un archivo de GitHub Actions de diez líneas hasta una configuración empresarial de mil jobs, se construye a partir del mismo vocabulario.
- Trigger — el evento que inicia una ejecución: un push, un pull request, un tag, una programación o un dispatch manual.
- Workflow — el archivo que declara los triggers y los jobs. En GitHub Actions se encuentra en
.github/workflows/. - Job — una unidad de trabajo que se ejecuta en un runner. Los jobs se ejecutan en paralelo a menos que declares dependencias con
needs. - Step — un comando único o una acción reutilizable dentro de un job. Los steps se ejecutan en orden y el job se detiene ante el primer fallo.
- Runner — la máquina que ejecuta un job. Los hosted runners son desechables; los self-hosted runners son máquinas que tú gestionas.
- Artifact — un archivo producido por un job y almacenado para jobs posteriores o para humanos: un binario, un bundle, un reporte de tests.
- Cache — un directorio que se restaura entre ejecuciones para evitar repetir tareas costosas, como la instalación de dependencias.
- Environment — un objetivo nombrado, como
stagingoproduction, con sus propios secrets, reglas de protección y URL. - Secret — un valor cifrado que se inyecta en un job en tiempo de ejecución y se oculta en los logs.
- Status check — el resultado de aprobado o fallido reportado al commit, el cual puede ser requerido por la protección de ramas (branch protection).
Una vez que estos conceptos están claros, cualquier sistema de CI se vuelve legible. Jenkins, GitLab CI, CircleCI y GitHub Actions utilizan los mismos conceptos, aunque con nombres diferentes.
Un workflow de GitHub Actions, línea por línea
El workflow más sencillo y útil es aquel que descarga el código, instala las dependencias y ejecuta las pruebas.
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
on declara los triggers. Tanto un push a main como cualquier pull request inician una ejecución. jobs.test se ejecuta en un runner de ubuntu-latest limpio. uses ejecuta una acción reutilizable; run ejecuta un comando de shell. npm ci instala exactamente lo que indica el lockfile, lo que hace que la ejecución sea reproducible. cache: npm indica a la acción de configuración que cachee el directorio de npm, utilizando el lockfile como clave.
Añade concurrency para cancelar las ejecuciones superadas, lo que ahorra tiempo y dinero en las ramas activas:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Cuando un desarrollador hace push de tres commits seguidos, solo sobrevive la ejecución más reciente. Las anteriores se cancelan y el pull request muestra un único estado actual en lugar de una cola de estados obsoletos.
Construye una vez, despliega el mismo artefacto
El hábito más valioso en un pipeline es construir exactamente una vez. El pipeline compila, prueba y empaqueta la aplicación una sola vez, y cada entorno, desde staging hasta producción, ejecuta ese mismo resultado.
La alternativa —volver a construir para cada entorno— parece inofensiva, pero no lo es. Diferentes builds pueden resolver versiones de dependencias distintas, tomar diferentes digests de imágenes base o embeber un NODE_ENV diferente. Entonces, staging prueba un binario que producción nunca ejecutará, y que “pasó en staging” no demuestra nada.
Dos reglas hacen que el “build-once” sea práctico. Primero, etiqueta el artefacto por commit, nunca con un nombre mutable como latest:
docker build -t ghcr.io/me/app:$GIT_SHA .
docker push ghcr.io/me/app:$GIT_SHA
Segundo, inyecta la configuración en tiempo de ejecución. La imagen no debe saber si se está ejecutando en staging o en producción. Las URLs de la base de datos, los feature flags y las API keys llegan como variables de entorno cuando el contenedor inicia. El build es idéntico en todas partes; solo difiere el entorno.
Para promover, haz referencia al digest inmutable en lugar de a la etiqueta, para que incluso una imagen re-etiquetada no pueda variar:
kubectl set image deploy/app \
app=ghcr.io/me/app@sha256:...
Esta es la base que hace que los rollbacks sean triviales. El release anterior es simplemente el digest anterior, que sigue estando en el registry, listo para desplegarse en segundos.
Tests, lint y typecheck como puertas de enlace
Una puerta de enlace (gate) es una verificación que debe superarse antes de que el pipeline continúe. Las puertas esenciales son rápidas y económicas: linting, type checking y la suite de pruebas unitarias. Se ejecutan en cada pull request y evitan que el artefacto se construya en caso de fallo.
Ordénalas de la más rápida a la más lenta. El lint y el typecheck suelen terminar en segundos y detectan una gran cantidad de errores. Las pruebas unitarias van después. Las pruebas de integración y end-to-end, que son más lentas, pueden ejecutarse en paralelo o únicamente en la rama principal.
jobs:
lint:
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 run lint
- run: npm run typecheck
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
Dos jobs se ejecutan en paralelo, cada uno en su propio runner. El lint falla en veinte segundos sin tener que esperar a la suite de pruebas. La contrapartida es que ambos jobs instalan las dependencias; un cache compartido hace que este proceso sea económico.
Las puertas de enlace solo son útiles si son obligatorias. En la plataforma de forja, marca las verificaciones como requeridas en la protección de ramas (branch protection) para que un pull request no pueda fusionarse mientras alguna de ellas esté en rojo.
Matrices y paralelismo
Una matriz ejecuta una definición de trabajo a través de varias combinaciones de entradas. Es la forma de probar múltiples versiones de Node y sistemas operativos sin duplicar el YAML.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
Esto se expande a cuatro trabajos. fail-fast: false permite que los demás terminen incluso si uno falla, lo cual es útil cuando quieres saber si el fallo es específico de una versión.
El paralelismo no es gratuito. Cada trabajo aprovisiona un runner e instala las dependencias, por lo que una matriz amplia cuesta más tiempo y dinero que una estrecha. Prueba las versiones que realmente soportas, no todas las versiones que existen.
Para suites de pruebas grandes, fragmenta (shard) las pruebas mismas: pasa un índice de fragmento y el total al test runner para que cada uno de los cuatro trabajos ejecute una cuarta parte de las pruebas. La suite termina en aproximadamente una cuarta parte del tiempo.
Secretos y variables de entorno
Los secretos son valores cifrados que el pipeline inyecta en tiempo de ejecución. Deben residir en el almacén de secretos de la plataforma, nunca en el repositorio y nunca en la imagen.
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
La plataforma oculta los valores de los secretos conocidos en los logs, pero este proceso es “best-effort”. No puede detectar un secreto que haya sido transformado, codificado en base64 o impreso por una herramienta que reformatee el texto. La regla de oro es sencilla: nunca imprimas un secreto. No ejecutes env en un paso de depuración, no hagas echo de un token y no pases un secreto a un comando que registre sus argumentos en los logs.
Asigna los secretos al entorno que los necesite. Un despliegue de staging no tiene motivos para tener la contraseña de la base de datos de producción. En GitHub, los secretos de entorno solo están disponibles para los jobs que declaran dicho entorno y pueden requerir una aprobación previa.
Para los proveedores de nube, prefiere OIDC sobre las claves de larga duración. El pipeline intercambia un token de identidad de corta duración por credenciales temporales, por lo que no hay un secreto estático que pueda filtrarse o que requiera rotación.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy
aws-region: eu-west-1
El token es válido durante unos minutos y está limitado a un solo rol. Si se filtra, el radio de impacto se limita a una única ejecución.
Una advertencia merece especial énfasis: los pull requests de forks ejecutan código no confiable. Nunca expongas secretos a ese código. Usa pull_request en lugar de pull_request_target para cualquier proceso que descargue y compile código de colaboradores, y requiere aprobación antes de ejecutar workflows de colaboradores que contribuyen por primera vez.
Entornos, aprobaciones y ramas protegidas
Un environment es un objetivo de despliegue nombrado que posee sus propios secrets, reglas de protección y URL. Es el mecanismo que mantiene la producción separada de todo lo demás.
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- run: ./deploy.sh
Los environments pueden requerir una aprobación manual antes de que se ejecute un job, restringir qué ramas pueden desplegar en ellos y limitar cuánto tiempo espera un despliegue antes de expirar por timeout. Aquí es donde reside el “botón humano” de la entrega continua: el pipeline se ejecuta automáticamente hasta llegar a la puerta de enlace, y un revisor lo libera.
Las ramas protegidas complementan a los environments. En la rama main, se puede requerir un pull request, requerir que se superen los status checks, requerir una revisión y prohibir los force pushes. Juntos, esto garantiza que la única vía hacia producción sea a través del pipeline, que es precisamente el objetivo.
Estrategias de despliegue y rollbacks
La forma en que una versión nueva reemplaza a una antigua es una decisión de diseño con consecuencias reales para los usuarios.
El Rolling deployment reemplaza las instancias de pocas en pocas. La capacidad disminuye ligeramente durante el intercambio, pero no se requiere infraestructura adicional. Es la opción predeterminada en la mayoría de las plataformas y el punto de partida ideal.
El Blue-green deployment ejecuta dos entornos completos. El tráfico se dirige al entorno “blue” mientras que el “green” recibe la nueva versión; una vez que el entorno “green” está saludable, un único cambio de enrutamiento redirige todo el tráfico. El rollback consiste en volver al entorno anterior, lo cual es casi instantáneo. El coste es ejecutar dos entornos simultáneamente.
El Canary deployment envía una pequeña fracción del tráfico —un uno por ciento, luego cinco, luego cincuenta— a la nueva versión y monitoriza las tasas de error y la latencia antes de continuar. Esto permite detectar problemas que un health check no puede identificar, a cambio de un enrutamiento y una monitorización más complejos.
Independientemente de la estrategia, el despliegue debe ser reversible. Dado que el artefacto anterior es inmutable y aún reside en el registro, un rollback es un redespiegue del digest anterior, no una reconstrucción:
kubectl rollout undo deploy/app
Dos detalles hacen que los rollbacks sean seguros. Las migraciones de base de datos deben ser compatibles hacia atrás durante al menos una versión, para que el código antiguo pueda seguir funcionando con el nuevo esquema. Además, los feature flags permiten desactivar comportamientos nuevos sin necesidad de desplegar nada, lo cual es el rollback más rápido que existe.
Registros de artefactos e imágenes de contenedores
Un registro de artefactos almacena el resultado de la compilación: imágenes de contenedores, paquetes de npm, binarios, tarballs. Es el punto de entrega entre la fase de construcción y la de despliegue, y debe ser inmutable.
Las imágenes de contenedores son el artefacto más común porque incluyen su propio runtime. Un registro como GitHub Container Registry, Amazon ECR o Docker Hub almacena capas, y el pipeline realiza un push hacia él una vez por cada build. Posteriormente, los despliegues descargan el digest exacto.
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Las opciones de cache-from y cache-to mantienen la caché de capas de Docker entre ejecuciones, evitando que una capa de dependencias que no ha cambiado se reconstruya desde cero cada vez.
Mantén una política de retención. Los registros acumulan gigabytes rápidamente, y eliminar imágenes antiguas es fundamental para mantener el pipeline rápido. Conserva al menos las últimas versiones para que el rollback siga siendo posible.
Verificaciones de estado y protección de ramas
Una verificación de estado (status check) es el veredicto del pipeline sobre un commit. La plataforma lo adjunta al pull request, y la protección de ramas decide si es meramente informativo o obligatorio.
Configura la rama main para requerir:
- Un pull request antes de fusionar, con al menos una aprobación.
- Verificaciones de estado aprobadas, incluyendo lint, typecheck y tests.
- Que la rama esté actualizada con main antes de fusionar, para que las verificaciones se ejecuten sobre el código que realmente se implementará.
- Prohibir force pushes y eliminaciones.
El resultado es que la rama main siempre está en verde. Cada commit en ella ha pasado por los mismos filtros, y cada despliegue desde ella es un artefacto cuya calidad está garantizada.
Mantén la lista de requisitos corta. Exigir una suite de end-to-end lenta en cada pull request hace que los desarrolladores tengan que esperar; exigirlo en la cola de fusión (merge queue) o en la rama main ofrece la misma seguridad sin generar fricción.
Caching y builds incrementales
El caching es la diferencia entre un pipeline de dos minutos y uno de doce. El principio fundamental es no repetir nunca un trabajo cuyos inputs no hayan cambiado.
El cache más valioso es el de las dependencias. Utiliza el hash del lockfile como clave para que se invalide exactamente cuando las dependencias cambien:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-
restore-keys permite una coincidencia parcial: incluso cuando no hay una coincidencia exacta de la clave, se restaura el cache más reciente y se actualiza incrementalmente, lo cual es mucho más rápido que una instalación desde cero.
Las herramientas de build añaden sus propios caches. El build incremental de TypeScript, el pre-bundling de dependencias de Vite y el layer cache de Docker se benefician de ser persistidos entre ejecuciones. Para Docker, utiliza el registry o el backend de cache del proveedor de CI en lugar del daemon local, el cual se descarta junto con el runner.
Un cache es una optimización, nunca una fuente de verdad. Si un cache está corrupto o desactualizado, la ejecución debe seguir siendo correcta. Las herramientas de build que confían ciegamente en un cache pueden producir resultados erróneos, por lo que es recomendable definir las claves de los caches de forma conservadora y tratar un cache miss como algo normal.
Pipelines para monorepos
Un monorepo contiene muchos paquetes en un solo repositorio. Un pipeline ingenuo reconstruye y vuelve a probar todos ellos en cada cambio, lo que se vuelve más lento a medida que el repositorio crece.
La solución es la detección de cambios. Los filtros de rutas y los grafos de dependencias indican al pipeline qué paquetes se ven afectados por un diff, y solo esos se compilan.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run test --filter='...[origin/main]'
El filtro selecciona los paquetes modificados desde main más todo aquello que dependa de ellos. Un cambio en una utilidad compartida activa sus dependientes; un cambio en un paquete hoja solo se activa a sí mismo.
Añade un remote cache para que el resultado de la compilación y los resultados de las pruebas se compartan entre máquinas y ejecuciones. Si las entradas de un paquete no han cambiado, su build se restaura en lugar de volver a computarse. En un monorepo, esto suele representar una ganancia mayor que el almacenamiento en caché de dependencias.
Mantén el pipeline honesto: un cambio en el lockfile de la raíz o en la configuración compartida aún debería ejecutar la suite completa. Las pruebas selectivas son una optimización que nunca debe permitir que un paquete roto pase desapercibido.
Seguridad del pipeline
El pipeline contiene credenciales de producción, lo que lo convierte en un objetivo de alto valor. Trata los archivos de workflow como código sensible a la seguridad y revisa cuidadosamente cualquier cambio en ellos.
Fija las acciones a una versión. Una etiqueta como @v4 es un puntero móvil; un commit SHA completo es inmutable. Fijar a un SHA es la opción más estricta y evita que una etiqueta comprometida ejecute código malicioso en tu pipeline.
Otorga el privilegio mínimo. Configura permissions explícitamente en cada workflow y job, estableciendo el acceso de solo lectura por defecto, y añade permisos de escritura solo donde sea necesario.
permissions:
contents: read
El GITHUB_TOKEN predeterminado suele ser más amplio de lo que cualquier job necesita. Restringirlo significa que un paso comprometido no podrá hacer push de commits ni publicar paquetes.
Prefiere OIDC sobre claves estáticas, como se describió anteriormente. Las credenciales de corta duración limitadas a un solo rol eliminan el secreto persistente que los atacantes más desean.
No ejecutes código no confiable con secretos. Los pull requests desde forks son el vector clásico. Usa pull_request para la compilación y las pruebas, requiere aprobación para los colaboradores primerizos y nunca descargues ni ejecutes código de un fork en un workflow que tenga acceso a secretos.
Revisa las acciones de terceros. Una acción es una dependencia que se ejecuta con tus credenciales. Prefiere acciones del propio forge o de editores conocidos, y audita el resto.
Mejores prácticas
- Construye el artefacto una sola vez y promociónalo a través de cada entorno.
- Etiqueta los artefactos por commit SHA o digest, nunca solo por
latest. - Inyecta la configuración en tiempo de ejecución; mantén la construcción idéntica en todas partes.
- Ejecuta el lint y el typecheck antes de la suite de pruebas más lenta para que los fallos se detecten rápido.
- Haz que las comprobaciones importantes sean obligatorias en la protección de ramas.
- Almacena en caché las dependencias usando como clave el hash del lockfile, con restore keys.
- Limita los secretos a entornos específicos y prefiere credenciales OIDC de corta duración.
- Nunca imprimas secretos y nunca los expongas a pull requests no confiables.
- Fija las versiones de las acciones de terceros y establece permisos de token de mínimo privilegio.
- Usa
concurrencypara cancelar ejecuciones superadas. - Mantén el pipeline rápido; si es lento, se terminará ignorando.
- Haz que los rollbacks sean un redespiegue del digest anterior.
Errores comunes
- Reconstruir la imagen de forma distinta para cada entorno y llamar a eso “promoción”.
- Incrustar la configuración del entorno en la imagen durante el tiempo de compilación.
- Usar
latestcomo único tag y perder la trazabilidad. - Imprimir secretos en un paso de depuración asumiendo que el enmascaramiento lo detecta todo.
- Exponer secretos a workflows activados por pull requests de forks.
- Referenciar actions de terceros mediante una rama inestable.
- Dejar los permisos del token predeterminado totalmente abiertos.
- Marcar cada check opcional como obligatorio, haciendo que los merges sean lentísimos.
- Permitir que los tests flaky permanezcan en rojo hasta que nadie lea los resultados.
- Implementar un caching agresivo sin una clave que se invalide ante cambios reales en el input.
- Reconstruir todo en un monorepo ante cualquier cambio.
- No tener una ruta de rollback más allá de revertir un commit y esperar.
Próximos pasos
Un pipeline finaliza desplegando un artefacto, por lo que el siguiente paso natural son las Cloud Platforms, donde ocurren realmente el release, los health checks y los rollbacks. El artefacto suele ser un contenedor, y la guía de Docker cubre las imágenes y los multi-stage builds que produce el pipeline. La mayoría de los runners son máquinas Linux, por lo que la guía de Linux explica la shell y las herramientas de las que dependen tus pasos, mientras que Node.js sienta las bases del runtime y la toolchain que se está construyendo.