☁️ Cloud, DevOps & Infraestructura ✅ Testing & Calidad

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.

📅 16 de junio de 2026 ⏱️ 9 min de lectura ✍️ Equipo ProgramacionWebs

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 needs solo donde exista una dependencia real.
  • Reconstruir en cada fase en vez de promocionar un artefacto. Si deploy-staging y deploy-production reconstruyen 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: true si 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.

Compartir