StackPractices
intermediate Por Mathias Paulenko

GitHub Actions: Ejemplos de Workflows CI/CD y Matriz

Cómo construir y desplegar con GitHub Actions usando workflows, matrices, caché, secrets, environments y workflows reutilizables.

Temas: devops

Visión general

Los equipos que todavía verifican el código a mano — ejecutando tests en local, construyendo artefactos en la máquina de un desarrollador y desplegando por SSH — entregan despacio y rompen cosas. GitHub Actions convierte cada evento de Git en un workflow automatizado y reproducible que corre en entornos aislados, de modo que el pipeline vive junto al código que prueba.

Al estar el pipeline definido en YAML dentro del repositorio, obtiene de regalo control de versiones, revisión por pares y auditabilidad. Un check verde en un pull request significa que el código compila, los tests pasan y las reglas de estilo se cumplen antes de que nadie haga merge.

Esta receta reúne los patrones de workflow que realmente copias en .github/workflows/: un pipeline CI de Node.js, matrices de build, jobs programados, secrets y environments, caché de dependencias y workflows reutilizables. Para el recorrido conceptual completo, consulta la guía de CI/CD con GitHub Actions.

Cuándo usarlo

Usa esta receta cuando:

  • Detectas regresiones en cada pull request, antes de que lleguen a producción — consulta Unit Testing para automatización de tests.
  • Publicas imágenes Docker desde tags de release para que los despliegues sean inmutables — consulta Docker Basics para la construcción de imágenes.
  • Despliegas a staging o producción con aprobaciones controladas. Consulta Environment Variables para gestionar la configuración de despliegue.
  • Haces chequeos de linting y formateo antes del merge. Consulta Pre-Commit Hooks para calidad de código en local.
  • Arrancas trabajo recurrente — auditorías de dependencias, copias de seguridad nocturnas — con una planificación cron en UTC.
  • Analizas vulnerabilidades en las dependencias en cada commit.

Descarta GitHub Actions cuando necesites runners con GPU o agentes de build poco comunes, cuando tu código viva fuera de GitHub (usa GitLab CI o Jenkins en su lugar), o cuando el coste en minutos hosted por build supere lo que costaría un runner self-hosted a tu volumen de commits.

Solución

Workflow básico (Node.js CI)

Este workflow se ejecuta en cada push y pull request dirigido a main. Baja el código, prepara Node.js, instala las dependencias y recorre todo el pipeline de validación.

# .github/workflows/ci.yml
name: CI

# Trigger: ejecutar en push y PR a la rama main
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # Descarga el código del repositorio
      - uses: actions/checkout@v4

      # Configura Node.js con caché de npm integrado
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      # Instala dependencias usando el lock file para reproducibilidad
      - run: npm ci

      # Ejecuta linting para detectar problemas de estilo temprano
      - run: npm run lint

      # Ejecuta la suite de tests
      - run: npm run test

      # Construye el artefacto de producción
      - run: npm run build

Matriz de build

Una estrategia de matriz ejecuta el mismo job en combinaciones de SO y versiones del runtime. Detecta bugs específicos de plataforma y confirma la compatibilidad sin duplicar código del workflow. Consulta Matriz de estrategia en GitHub Actions para combinaciones con include/exclude y matrices dinámicas.

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test

Workflows programados (Cron)

El trigger schedule ejecuta workflows con una planificación cron en UTC — útil para auditorías de dependencias, builds nocturnos o tareas de limpieza. Añade workflow_dispatch junto a él para poder lanzar el job manualmente.

# .github/workflows/nightly-audit.yml
name: Nightly Audit

on:
  schedule:
    - cron: '0 3 * * *'   # Todos los días a las 03:00 UTC
  workflow_dispatch:       # Permite ejecuciones manuales desde la pestaña Actions

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm audit --audit-level=high

Deploy con Secrets

Este job solo se ejecuta después de que el job test tenga éxito y solo en la rama main. Los secrets se inyectan en runtime desde la configuración del repositorio y quedan enmascarados en los logs.

jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: test              # espera a que el job test pase
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: |
          echo "Deploying with token"
          curl -X POST https://api.example.com/release \
            -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}"

secrets y vars se comportan de forma distinta — elige el adecuado para cada valor:

ContextoCifrado en reposoEnmascarado en logsÚsalo para
secrets.*SíSíTokens de API, credenciales de deploy, claves de firma
vars.*NoNoFeature flags, URLs de entorno, configuración no sensible

Caché de dependencias

La caché almacena dependencias descargadas entre ejecuciones del workflow. La clave incluye el SO y un hash del lock file, así que se invalida automáticamente cuando cambian las dependencias.

steps:
  - uses: actions/checkout@v4
  - uses: actions/cache@v4
    with:
      path: |
        ~/.npm
        node_modules
      key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
  - run: npm ci

Workflow reutilizable

Cuando varios repositorios necesitan la misma lógica de pipeline, extráela a un workflow reutilizable. Quienes lo invocan pasan inputs y reciben outputs, y la lógica compartida vive en un solo lugar. Consulta Workflows reutilizables en GitHub Actions para secrets: inherit, paso de outputs y estrategias de versionado.

# .github/workflows/reusable-lint.yml
name: Reusable Lint

on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
      - run: npm run lint

Llámalo desde otro repositorio:

jobs:
  call-lint:
    uses: org/shared-workflows/.github/workflows/reusable-lint.yml@main
    with:
      node-version: '20'

Explicación

Cómo funciona el modelo de ejecución

Un workflow es un archivo YAML que se dispara con un evento (push, pull_request, schedule, workflow_dispatch o una llamada externa a repository_dispatch). GitHub encola cada job del workflow y lo asigna a un runner — una VM hosted (ubuntu-latest, windows-latest, macos-latest) o una máquina que operas tú (self-hosted). Dentro de un job, los steps corren secuencialmente en la misma VM, compartiendo filesystem y entorno.

Los jobs corren en paralelo por defecto. needs convierte el grafo en una secuencia: un job espera a que sus dependencias terminen con éxito antes de arrancar. Las condiciones if expresan ramas encima — por ejemplo, desplegar solo cuando la ref es main:

Flujo de ejecución del workflow: un push o pull request a main dispara los jobs lint y test en paralelo; el job deploy espera a ambos vía needs y solo corre en main, controlado por una aprobación del entorno de producción antes del release.

Contextos y expresiones

Todo lo que va dentro de ${{ }} es una expresión evaluada contra un contexto. Los que más usarás: github (payload del evento, ref, SHA), secrets (valores cifrados), vars (configuración en claro), matrix (la combinación actual de la matriz), inputs (valores pasados por quien invoca en workflows reutilizables y dispatch) y env / runner para datos del entorno y la máquina. Las expresiones funcionan en comandos run, condiciones if e inputs with.

Permisos y GITHUB_TOKEN

Cada workflow corre con un GITHUB_TOKEN cuyos permisos por defecto vienen de la configuración del repositorio. Deja el workflow en permissions: read-all y otorga contents: write o packages: write solo a los jobs que lo necesiten — una action de terceros comprometida solo puede hacer lo que el token permite. Para despliegues en la nube, prefiere federación OIDC sobre secrets de larga duración: el runner intercambia un JWT efímero por un rol en la nube, así que no hay ningún AWS_ACCESS_KEY_ID guardado en Secrets.

Concurrencia y facturación

Un grupo de concurrency cancela la ejecución anterior cuando llega un push nuevo a la misma ref, lo que evita que despliegues obsoletos compitan con los nuevos:

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

Los minutos de runners hosted se facturan por SO: los de Linux cuentan 1x, los de Windows 2x y los de macOS 10x. Una matriz de 3 SOs × 3 runtimes produce 9 jobs por commit, así que el tamaño de la matriz tiene un precio directo en repositorios privados — consulta la facturación de GitHub Actions.

Variantes

Workflow reutilizable con estrategia de matriz

# .github/workflows/test-matrix.yml
name: Test Matrix
on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: "20"

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false  # No cancelar otros jobs en fallo
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
        exclude:
          - os: macos-latest
            node: 18  # Omitir Node viejo en macOS
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage
      - uses: codecov/codecov-action@v4
        if: matrix.os == 'ubuntu-latest' && matrix.node == 20

Reglas de protección de environments

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    tags: ['v*']

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.example.com
    # Los revisores requeridos deben aprobar antes del deploy
    # Se configura en repo Settings > Environments
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: |
          echo "Deploying ${{ github.ref_name }} to production"
          ./deploy.sh --env prod --version ${{ github.ref_name }}
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Action compuesta

# .github/actions/setup-project/action.yml
name: Setup Project
description: Install dependencies and cache them
inputs:
  node-version:
    required: false
    default: '20'

runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'
    - name: Install dependencies
      shell: bash
      run: npm ci
    - name: Build
      shell: bash
      run: npm run build
# Uso en un workflow
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-project
        with:
          node-version: '22'
      - run: npm test

Jobs y steps condicionales

jobs:
  lint:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

  deploy:
    needs: [lint, test]
    if: github.ref == 'refs/heads/main' && success()
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying only on main after lint+test pass"

Resumen de job para comentarios de PR

$GITHUB_STEP_SUMMARY renderiza Markdown en la vista de la ejecución de Actions — una forma rápida de mostrar resultados sin un comentario de bot. Las duraciones aquí son ilustrativas; en una ejecución real escribirías los valores medidos.

- name: Generate summary
  if: always()
  run: |
    echo "## Test Results" >> $GITHUB_STEP_SUMMARY
    echo "| Suite | Status |" >> $GITHUB_STEP_SUMMARY
    echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
    echo "| Unit  | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY
    echo "| E2E   | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY

Lo que funciona

  • Fija las versiones de las actions con el SHA completo del commit para seguridad de la supply chain. Los tags como v4 son cómodos, pero un maintainer comprometido podría reapuntar el tag a código malicioso; un SHA ejecuta exactamente el mismo código siempre.
  • Ejecuta npm ci, no npm install — ci instala exactamente lo que fija package-lock.json, así que el build no puede derivar entre la máquina de un desarrollador y el runner.
  • Separa los jobs: test, build y deploy como jobs distintos con dependencias explícitas (needs). Los fallos quedan aislados, los jobs corren en paralelo y los logs se leen mejor.
  • Usa grupos de concurrencia para cancelar ejecuciones obsoletas cuando se suben commits nuevos. Ahorra minutos de CI y evita que despliegues antiguos pisen a los más recientes.
  • Guarda los secrets en GitHub Secrets, nunca escribas tokens en los archivos de workflow. Los secrets están cifrados en reposo y enmascarados en los logs. Incluso los repositorios privados pueden filtrarse a través de PRs de forks.
  • Extrae la lógica compartida del pipeline a workflows reutilizables — quienes los invocan desde otros repos pasan inputs, y una corrección en el repositorio central llega a todos los consumidores de golpe.
  • Falla rápido con permisos estrictos: define permissions: read-all a nivel de workflow y concede write solo donde haga falta — una action comprometida no puede hacer más de lo que el token permite.
  • Vigila la factura de Actions — configura un límite de gasto y revisa el uso cada mes. Una matriz de 3 SOs y 3 versiones de Node produce 9 jobs por commit, y los minutos de macOS cuentan 10x.
  • Añade un fallback con restore-keys a actions/cache para que una caché vieja siga acelerando npm ci cuando cambie el lock file:
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-
  • Usa runners self-hosted para builds pesados cuando la cola o la CPU hosted sean el cuello de botella:
runs-on: self-hosted  # Tu propio hardware
  • Divide la suite de tests entre runners con la matriz para paralelizar suites largas:
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npm test -- --shard=${{ matrix.shard }}/4
  • Activa la caché de capas de Docker para acelerar los builds de imágenes:
- uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

Errores comunes

  • Usar actions/checkout sin especificar un ref, causando problemas de detached HEAD o descargando el commit equivocado cuando se dispara por un tag. Usa ref: ${{ github.ref }} explícitamente cuando haga falta.
  • Desplegar en cada push en lugar de filtrar por rama o tag — ramas de funcionalidad sin terminar acaban en producción y tu proceso de release queda anulado por completo.
  • No cachear las dependencias, generando builds que se descargan internet en cada ejecución. Una caché bien ajustada reduce el tiempo de instalación de minutos a segundos.
  • Acudir a ubuntu-latest cuando necesitas un SO concreto para compilación nativa. La etiqueta latest migra con el tiempo — fija ubuntu-22.04 y el build se mantiene reproducible.
  • Steps de notificación que omiten if: failure() — las alertas de Slack o email solo saltan cuando el pipeline está verde, que es justo cuando no las necesitas.
  • Valores de entorno escritos en el propio workflow en lugar de variables de repositorio — cada cambio de URL o versión se convierte en una edición de YAML y un commit.
  • permissions: write-all y otras concesiones amplias — una action de terceros comprometida puede reescribir tu código, crear releases o tocar secrets. El menor privilegio aquí no es opcional.

Lectura adicional

Preguntas frecuentes

¿Cómo ejecuto un workflow manualmente?

Añade workflow_dispatch: al bloque on:. Aparece entonces en la pestaña Actions como una ejecución manual, con inputs opcionales que el workflow puede leer.

¿Puedo reutilizar workflows entre repositorios?

Sí. Crea un workflow con on: workflow_call en un repositorio central y referéncialo desde otros con la forma uses: org/shared-workflows/.github/workflows/reusable.yml@main. La lógica compartida se mantiene actualizada en un solo lugar.

¿Cómo depuro un workflow que falla?

Usa la action tmate para abrir una sesión SSH interactiva dentro del runner, o añade set -x en los steps de shell para una salida detallada. También puedes habilitar el debug de steps poniendo el secret ACTIONS_STEP_DEBUG a true.

¿Cómo evito que los workflows se ejecuten desde forks?

Usa if: github.event.pull_request.head.repo.full_name == github.repository para omitir workflows disparados por forks externos. Para workflows que tocan secrets o producción, ve más lejos: desactiva los triggers de PR de forks en la configuración del repositorio y exige aprobación manual.

¿Cuál es la diferencia entre workflow_dispatch y repository_dispatch?

workflow_dispatch es el trigger manual — la pestaña Actions o la API. repository_dispatch solo existe para quien llama desde fuera: un servicio de terceros golpea el endpoint de la API cuando necesita un build que ningún evento de Git puede arrancar.

¿Cómo comparto secrets entre entornos?

Usa secrets a nivel de repositorio para valores no sensibles y secrets a nivel de environment para credenciales de producción. Haz referencia a ellos con ${{ secrets.SECRET_NAME }}:

steps:
  - run: deploy.sh
    env:
      API_KEY: ${{ secrets.PROD_API_KEY }}  # Secret de environment
      LOG_LEVEL: ${{ vars.LOG_LEVEL }}       # Variable de repositorio
¿Cómo ejecuto jobs en secuencia o en paralelo?

Por defecto, los jobs corren en paralelo. Usa needs para crear dependencias:

jobs:
  build:    # Corre primero
    runs-on: ubuntu-latest
  test:     # Corre después de build
    needs: build
    runs-on: ubuntu-latest
  deploy:   # Corre después de test
    needs: test
    runs-on: ubuntu-latest
¿Cómo paso artefactos entre jobs?

Usa actions/upload-artifact y actions/download-artifact:

build:
  runs-on: ubuntu-latest
  steps:
    - run: npm run build
    - uses: actions/upload-artifact@v4
      with:
        name: dist
        path: dist/

deploy:
  needs: build
  runs-on: ubuntu-latest
  steps:
    - uses: actions/download-artifact@v4
      with:
        name: dist
        path: dist/
    - run: ./deploy.sh dist/