CI/CD moderno con GitHub Actions: patrones de producción
Un workflow de GitHub Actions que funciona en la demo y uno que aguanta un equipo grande en producción no se parecen tanto como crees. La diferencia está en la estructura, los permisos y lo que decides cachear.
Cualquiera puede escribir un .github/workflows/ci.yml que compile, pase los tests y despliegue. El problema aparece seis meses después, cuando ese pipeline tarda doce minutos en decirte que un test falló, cuando un pull request de un colaborador externo consigue ejecutar código con permisos de escritura sobre el repositorio, o cuando alguien encuentra una credencial de producción en un secreto que llevaba dos años sin rotarse. GitHub Actions no te protege de nada de eso por defecto: te da las piezas, y la calidad del pipeline depende de cómo las ensambles.
Este artículo cubre la estructura de un pipeline que aguanta producción de verdad: separación de fases, despliegue seguro por entornos, cómo cachear sin acabar depurando una caché corrupta, cómo paralelizar con matrices sin dispararte al pie, y los dos o tres errores de permisos que son, con diferencia, la causa más común de incidentes de seguridad en CI/CD.
La forma de un pipeline sólido
Un pipeline de producción separa con claridad tres fases que tienen dueños y objetivos distintos, y evita que una fase bloquee a las demás sin motivo:
graph LR PR["Pull Request"] --> Build["Build"] Build --> Test["Test"] Test --> Staging["Deploy: staging"] Staging --> E2E["Tests E2E en staging"] E2E -->|"aprobación manual"| Prod["Deploy: producción"]
- Build: compila, instala dependencias, construye la imagen o el artefacto. Debe ser idéntico sea cual sea el destino final — el mismo artefacto que se prueba es el que se despliega, nunca se reconstruye entre staging y producción.
- Test: unitarios, integración, y análisis estático (lint, tipos, seguridad de dependencias). Corre en paralelo siempre que las suites sean independientes entre sí.
- Deploy: promociona el artefacto ya construido y probado a un entorno, empezando siempre por el de menor riesgo.
name: ci-cd
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
build:
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 build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
test:
needs: build
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 test -- --coverage
- run: npm run lint
deploy-staging:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- run: ./scripts/deploy.sh staging
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- run: ./scripts/deploy.sh production
El detalle que marca la diferencia entre esto y un pipeline de aficionado es environment: production. Los entornos de despliegue de GitHub Actions permiten exigir aprobación manual de una persona concreta antes de que ese job se ejecute, restringir qué ramas pueden desplegar a ese entorno, y asignar secretos que solo existen dentro de ese entorno — un secreto de production nunca es visible ni accesible desde un job que corre contra staging.
Si el despliegue incluye cambios de esquema en la base de datos, ese paso necesita su propia disciplina: una migración destructiva ejecutada a mitad de un rolling deploy puede tumbar la versión anterior de la app que todavía está sirviendo tráfico. En Migraciones de bases de datos sin miedo se explica el patrón expand-contract para que ese paso del pipeline no sea el que más miedo da.
Caché: la diferencia entre 12 minutos y 2
La instalación de dependencias suele ser el tramo más lento y más repetitivo de cualquier pipeline. actions/setup-node, actions/setup-python y equivalentes incluyen un parámetro cache que gestiona automáticamente el cacheo del directorio de dependencias entre ejecuciones, usando como clave el hash del archivo de bloqueo (package-lock.json, poetry.lock).
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm" # cachea automáticamente ~/.npm según package-lock.json
Para casos que no cubren esos actions oficiales —capas de build de Docker, compiladores con caché propia, artefactos intermedios— actions/cache da control manual:
- uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
nextjs-${{ runner.os }}-
Para builds de imágenes Docker, el backend de caché type=gha de BuildKit reutiliza capas entre ejecuciones del propio pipeline en vez de reconstruir la imagen entera cada vez:
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/miorg/miapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Matrices: paralelizar sin duplicar YAML
Cuando necesitas probar el mismo código contra varias versiones de runtime, varios sistemas operativos, o varios paquetes de un monorepo, una matriz genera un job independiente por cada combinación sin que tengas que copiar y pegar el bloque de pasos.
test:
strategy:
fail-fast: false
matrix:
node-version: ["20", "22", "24"]
os: [ubuntu-latest, windows-latest]
exclude:
- node-version: "20"
os: windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm test
fail-fast: false es la opción que más gente olvida y más disgustos evita: por defecto, si una combinación de la matriz falla, GitHub Actions cancela el resto inmediatamente. Eso está bien cuando quieres feedback rápido y barato, pero es un problema cuando necesitas ver todas las combinaciones que fallan de una vez — por ejemplo, si sospechas que el fallo es específico de una versión de Node y no de todas.
Para monorepos, una matriz dinámica generada en un job previo (calculando qué paquetes cambiaron realmente en el pull request y pasándolos como JSON a matrix.include) evita ejecutar la suite completa de cada paquete en cada push, cuando solo uno de ellos cambió.
Permisos y secretos: donde se rompen las cosas de verdad
GitHub reportó un aumento notable de credenciales filtradas a través de configuraciones incorrectas de Actions en los últimos años, y la causa casi siempre es la misma combinación de descuidos, no un ataque sofisticado.
El primer descuido es no restringir GITHUB_TOKEN. Ese token se genera automáticamente para cada ejecución y, si no lo limitas explícitamente, puede tener permisos de escritura sobre el repositorio. Declara siempre permissions a nivel de workflow con el mínimo necesario, y amplíalo solo en el job concreto que lo necesite:
permissions:
contents: read
jobs:
release:
permissions:
contents: write # este job en concreto sí necesita crear un release
runs-on: ubuntu-latest
steps:
- run: gh release create ...
El segundo es guardar credenciales de nube como secretos de larga duración cuando no hace falta. OpenID Connect (OIDC) permite que un workflow obtenga un token de corta duración directamente del proveedor de identidad de GitHub, que AWS, Azure o GCP validan sin que ningún secreto de acceso permanente tenga que vivir nunca en el repositorio:
permissions:
id-token: write # necesario para solicitar el token OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-west-1
- run: aws s3 sync dist/ s3://mi-bucket-produccion/
Con esto, ni siquiera existe un AWS_SECRET_ACCESS_KEY que rotar, revocar o que se pueda filtrar en un log: el rol IAM confía en la identidad del workflow (repositorio, rama, entorno concretos) en vez de en un secreto compartido.
El tercero, específico de repositorios públicos u open source, es pull_request_target. A diferencia de pull_request, este evento ejecuta el workflow con los permisos y secretos del repositorio base, no de la rama del fork — lo que significa que si además haces checkout del código del fork y lo ejecutas, le estás dando a cualquiera que abra un pull request acceso efectivo a tus secretos. Evítalo salvo que sepas exactamente por qué lo necesitas, y si lo usas, nunca combines pull_request_target con un checkout del head del fork seguido de ejecución de ese código.
Errores comunes que ralentizan pipelines sin que nadie note por qué
- No paralelizar lo que es independiente. Ejecutar lint, tests unitarios y análisis de tipos en un único job secuencial cuando no comparten estado es tiempo tirado; sepáralos en jobs distintos con
needssolo donde exista una dependencia real. - Reconstruir en cada fase en vez de promocionar un artefacto. Si
deploy-stagingydeploy-productionreconstruyen la imagen en vez de reutilizar la que ya pasó los tests, no solo pierdes tiempo: rompes la garantía de que lo que se prueba es exactamente lo que se despliega. - Workflows reutilizables mal aprovechados. Cuando varios repositorios (o varios pipelines del mismo repo) repiten la misma secuencia de build y test, un reusable workflow (
workflow_call) centraliza esa lógica en un solo sitio, de forma similar a como una función evita duplicar código — el mantenimiento deja de ser “copiar el cambio en quince repos”. - Runners sobredimensionados por defecto. Usar un runner más grande de lo que el job necesita porque “por si acaso” es un coste recurrente silencioso; mide antes de sobreaprovisionar minutos de CI, la misma lógica que aplica a evitar sobreaprovisionar infraestructura en producción — algo que desarrollamos en FinOps para developers.
- Notificaciones o pasos de limpieza que bloquean el pipeline principal. Un paso de “avisar a Slack” o “actualizar un dashboard interno” que falla no debería tumbar el despliegue; márcalo con
continue-on-error: truesi su fallo no es crítico para el resultado del pipeline.
Cuándo GitOps sustituye al último paso
Todo lo anterior cubre un pipeline “push”: el propio job de deploy se conecta al entorno de destino y aplica el cambio, con las credenciales que eso implica. Si tu destino es un clúster de Kubernetes y te preocupa reducir esa superficie de credenciales al mínimo, la alternativa es que el pipeline de CI termine en un commit sobre un repositorio de configuración, y un controlador dentro del propio clúster se encargue de aplicar el cambio — el modelo que cubrimos en detalle en GitOps con Git como fuente de verdad. Son dos formas legítimas de resolver el último tramo del pipeline; cuál usar depende de cuántos entornos gestionas y de cuánto pesa la auditoría de cambios en tu contexto, no de que una sea objetivamente “más moderna” que la otra.
Un pipeline de producción no se distingue por tener más pasos ni más integraciones. Se distingue por partir del artefacto correcto en cada fase, por no darle a ningún job más permiso del que necesita, y por hacer que el feedback llegue rápido cuando algo falla — que, al final, es la razón por la que existe CI/CD.
Artículos relacionados
Monorepos full-stack en 2026: Turborepo, Nx y cuándo merece la pena
Un monorepo no es una arquitectura ni una moda: es una forma de organizar código que solo compensa a partir de ciertas señales. Comparamos Turborepo y Nx sin dogmatismo.
GitOps con Git como fuente de verdad: guía práctica
GitOps no es 'usar Git para desplegar'. Es un modelo concreto donde un controlador dentro del clúster tira de los cambios en vez de que un pipeline externo los empuje. La diferencia importa para la seguridad y la fiabilidad.
Platform Engineering en 2026: qué es y por qué está reemplazando al DevOps tradicional
DevOps prometía que cada equipo fuera dueño de su infraestructura. En la práctica, eso se ha traducido en desarrolladores agotados de configurar YAML. Platform Engineering es la respuesta organizativa a ese problema.