Llevas cinco módulos usando GitHub Actions: has escrito ci.yml, cd.yml, rollback.yml e infra.yml, has extraído la composite action preparar-node y el workflow reutilizable reusable-build-publicar.yml, has federado credenciales con OIDC y has puesto concurrency y matrices con sharding. Lo que no has hecho es mirar la herramienta como herramienta: qué ocurre exactamente entre que alguien empuja un commit y arranca un runner, qué contextos existen y cuáles están disponibles en qué momento, qué disparadores hay más allá de push y pull_request, cómo se escribe una action propia cuando las existentes no llegan, cuáles son los límites reales del servicio y qué hacer cuando los tocas. Esta lección cierra esos huecos. No re-explica qué es una caché ni qué es un artefacto inmutable —eso está en la 04-02 y la 02-06—: explica cómo los materializa GitHub Actions, dónde se rompen las intuiciones, y qué contrapartidas tiene la herramienta que el curso ha usado por defecto. Porque el módulo prometió que no habría fanatismo, y esto también aplica a la herramienta de casa.

Contenido

  1. El ciclo completo de un evento y dónde vive el estado
  2. Contextos y expresiones a fondo
  3. Todos los disparadores que faltan
  4. pull_request frente a pull_request_target
  5. GITHUB_TOKEN, permissions y OIDC
  6. Los tres tipos de action, con una JavaScript action completa
  7. Matrices avanzadas y matrices dinámicas
  8. Control de flujo: outputs, continue-on-error, timeout-minutes, concurrency
  9. Caché y artefactos: claves, límites y desalojo
  10. Runners autoalojados y autoescalado con ARC
  11. Resúmenes, anotaciones y comandos de workflow
  12. Entornos, reglas y despliegues
  13. Límites reales del servicio y estrategias
  14. Depurar y probar workflows
  15. Errores Comunes y Consejos
  16. Ejercicios
  17. Conclusión

  1. El ciclo completo de un evento y dónde vive el estado

flowchart TD
    E["Evento en GitHub<br/>push, PR, schedule, API"] --> F{"Hay workflow<br/>con ese on:?"}
    F -->|no| X["Nada"]
    F -->|si| C["Se toma el YAML de una ref concreta"]
    C --> W["Workflow run<br/>se congela la definicion"]
    W --> J["Jobs: se evaluan needs e if"]
    J --> Q["Cola: se busca runner<br/>por runs-on"]
    Q --> R["Runner: maquina limpia<br/>clona nada por defecto"]
    R --> S["Steps en secuencia<br/>mismo sistema de ficheros"]
    S --> O["Outputs, artefactos, caches, logs"]
    O --> N["Checks en el commit / PR"]

Cinco detalles que explican comportamientos que a mucha gente le parecen mágicos o rotos:

De qué rama se lee el workflow. Para push y pull_request, del commit que dispara. Pero para schedule, workflow_dispatch y workflow_run se lee de la rama por defecto, siempre. Por eso un schedule nuevo no se ejecuta hasta que el cambio llega a main, y por eso un workflow_dispatch que arreglas en una rama sigue comportándose mal al lanzarlo.

La definición se congela al arrancar. Si empujas un cambio al workflow mientras hay una ejecución en curso, esa ejecución sigue con la versión antigua. Sirve para razonar sobre ejecuciones en vuelo.

Cada job es una máquina nueva. Nada persiste entre jobs salvo lo que pases explícitamente: outputs, artefactos o caché. Lo que sí persiste entre steps del mismo job es el sistema de ficheros y el proceso de shell no (cada run es un shell nuevo: un export en un step no llega al siguiente; para eso está $GITHUB_ENV).

El runner llega vacío. No hay checkout automático —a diferencia de Travis o GitLab—; por eso actions/checkout es siempre el primer paso, y por eso olvidarlo produce el error "no such file or directory" más repetido de la herramienta.

El resultado se publica como checks sobre el commit, y son esos checks los que la protección de rama de la 02-07 exige. Un job que no se ejecuta por un if falso queda como skipped, y skipped cuenta como aprobado para la protección de rama: esa asimetría es la causa del "verde falso" más sutil de Actions, y volveremos a ella.

GitHub Actions Equivalente en el curso
Workflow Fichero de pipeline
Job Job (una máquina)
Step Step
Action Paso empaquetado y reutilizable
Runner Agente
Artifact Artefacto
Environment Entorno con reglas y aprobaciones

  1. Contextos y expresiones a fondo

Los contextos son objetos disponibles dentro de ${{ }}. Saber cuáles hay y cuándo están disponibles evita la mitad de los errores.

Contexto Contiene Disponible en
github Evento, sha, ref, actor, repository, event completo Todas partes
env Variables definidas con env: Casi todo (no en env: del mismo nivel)
vars Variables de configuración (no secretas) del repo/org/entorno Todas partes
secrets Secretos Job y step; no en if de nivel workflow
job Estado del job actual, servicios y sus puertos Steps
jobs Resultados de jobs (solo en workflows reutilizables, para outputs) outputs del reusable
steps Outputs y outcome/conclusion de steps con id Steps posteriores
runner os, arch, temp, tool_cache, debug Steps
needs Outputs y result de los jobs de los que dependes Job dependiente
matrix Valores de la combinación actual Job con matriz
inputs Entradas de workflow_dispatch, workflow_call o de una action Según el caso
strategy job-index, job-total, fail-fast Job con matriz
env:
  ENTORNO: staging

jobs:
  ejemplo:
    runs-on: ubuntu-22.04
    steps:
      - id: version
        run: echo "valor=1.4.2" >> "$GITHUB_OUTPUT"      # 1

      - name: Usar el output
        run: echo "Versión ${{ steps.version.outputs.valor }}"

      - name: Variable para steps siguientes
        run: |
          echo "SHA_CORTO=${GITHUB_SHA::7}" >> "$GITHUB_ENV"   # 2
          echo "$PWD/bin" >> "$GITHUB_PATH"                     # añade al PATH

      - name: Condición con funciones
        if: >-
          github.event_name == 'push' &&
          startsWith(github.ref, 'refs/tags/v') &&
          !contains(github.event.head_commit.message, '[skip ci]')
        run: ./scripts/publicar.sh
  1. $GITHUB_OUTPUT es el mecanismo actual (el antiguo ::set-output está retirado por motivos de seguridad). Para valores multilínea hay que usar un delimitador:
    {
      echo "notas<<EOF"
      cat CHANGELOG.md
      echo "EOF"
    } >> "$GITHUB_OUTPUT"
    
  2. $GITHUB_ENV define variables para los steps siguientes del mismo job; un export normal no sale del step porque cada run es un shell distinto.

Funciones disponibles, con sus usos típicos:

Función Qué hace Uso típico
contains(a, b) Subcadena o elemento de lista contains(github.event.pull_request.labels.*.name, 'urgente')
startsWith / endsWith Prefijo / sufijo startsWith(github.ref, 'refs/tags/')
format('{0}-{1}', a, b) Interpolación Construir nombres
join(lista, ', ') Unir Mensajes
toJSON(x) Serializar Depurar contextos: run: echo '${{ toJSON(github) }}'
fromJSON(s) Deserializar Matrices dinámicas y convertir cadenas a números o booleanos
hashFiles('**/package-lock.json') Hash de ficheros Claves de caché
success(), failure(), cancelled(), always() Estado Condicionales de step y job

Y los detalles que muerden:

Todos los valores de matrix y de inputs de workflow_dispatch llegan como cadena. if: inputs.forzar == true es falso siempre si forzar viene de un workflow_dispatch; hay que comparar con 'true' o usar fromJSON(inputs.forzar).

if a nivel de job no lleva ${{ }} (aunque funciona con ellas); dentro de una expresión, sí.

if: always() frente a if: ${{ !cancelled() }}: always() ejecuta el step incluso si el workflow fue cancelado, lo que puede dejar recursos a medias o retrasar la cancelación. Para "ejecutar aunque falle, pero no si cancelan" —el caso habitual de publicar informes de test— lo correcto es if: ${{ !cancelled() }}.

Los secretos no están disponibles en if de nivel de workflow, y tampoco puedes usarlos para decidir si un workflow se ejecuta. El patrón para "solo si hay credenciales" es un job previo que los comprueba y expone un output booleano.

  1. Todos los disparadores que faltan

Más allá de push y pull_request:

on:
  # Ejecución manual con entradas TIPADAS
  workflow_dispatch:
    inputs:
      entorno:
        description: Entorno de destino
        type: choice
        options: [staging, produccion]
        default: staging
      digest:
        description: Digest de la imagen a desplegar
        type: string
        required: true
      omitir-smoke:
        description: Omitir smoke tests (solo emergencias)
        type: boolean
        default: false

  # Disparo desde una API externa
  repository_dispatch:
    types: [desplegar-solicitado, contrato-actualizado]

  # Programado (UTC, siempre desde la rama por defecto)
  schedule:
    - cron: '17 3 * * *'          # 03:17 UTC, no en punto: ver más abajo

  # Reacción a comentarios: el "/desplegar" en un PR
  issue_comment:
    types: [created]

  # Publicación de una release
  release:
    types: [published]

  # Encadenado tras otro workflow
  workflow_run:
    workflows: ["CI"]
    types: [completed]
    branches: [main]

  # Merge queue
  merge_group:
    types: [checks_requested]
Disparador Cuándo usarlo Trampa
workflow_dispatch Despliegues manuales, operaciones, rollback Se lee de la rama por defecto; los inputs son cadenas
repository_dispatch Integración con sistemas externos Requiere token con permiso de escritura sobre contents
schedule Nocturnos, limpieza, escaneos No dispara puntual: la cola en punto está saturada; usa minutos raros. Y se desactiva tras ~60 días de inactividad del repositorio
issue_comment Comandos tipo /desplegar Se dispara en issues y en PR; hay que filtrar github.event.issue.pull_request. Y el comentario es texto de terceros: nunca lo interpoles en run:
release Publicar paquetes, notas published frente a created no es lo mismo
workflow_run CD tras CI (el cd.yml de Reservalia) El YAML se lee de la rama por defecto; completed incluye fallos: hay que comprobar conclusion == 'success'
merge_group Merge queue (02-07) Si el check obligatorio no se ejecuta en merge_group, la cola se bloquea

Dos patrones concretos que aparecen mucho:

# Comando /desplegar en un pull request
on: { issue_comment: { types: [created] } }
jobs:
  desplegar:
    if: >-
      github.event.issue.pull_request &&
      startsWith(github.event.comment.body, '/desplegar') &&
      contains(fromJSON('["OWNER","MEMBER"]'), github.event.comment.author_association)
    runs-on: ubuntu-22.04
    steps:
      - run: ./scripts/desplegar.sh preview     # NUNCA interpolar el cuerpo del comentario

La comprobación de author_association es imprescindible: sin ella, cualquier persona de internet puede disparar un despliegue escribiendo un comentario. Y el cuerpo del comentario no se interpola nunca dentro de run:, porque es una inyección de script directa (04-03): si necesitas leerlo, pásalo por env: y trátalo como dato.

# CD encadenado tras CI, comprobando el resultado
on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]
    branches: [main]
jobs:
  desplegar:
    if: github.event.workflow_run.conclusion == 'success'   # sin esto, despliegas builds rotos
    runs-on: ubuntu-22.04

  1. pull_request frente a pull_request_target

La diferencia más importante de seguridad de toda la herramienta. La 04-03 la enunció; aquí el mecanismo completo.

pull_request pull_request_target
Código que se ejecuta El de la rama del PR (posiblemente de un fork) El de la rama base
Contexto de github.ref La rama base con el merge La rama base
Acceso a secrets No, si el PR viene de un fork Sí, siempre
Permisos de GITHUB_TOKEN Solo lectura desde forks Escritura
Riesgo Bajo Alto si haces checkout del código del PR
# PELIGROSO: la combinación que filtra secretos
on: pull_request_target
jobs:
  build:
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # ← código del fork
      - run: npm ci                                        # ← ejecuta scripts del atacante
      #     …con los secretos del repositorio disponibles

Un npm ci ejecuta los scripts postinstall del package.json del PR. Un atacante abre un PR con un postinstall que envía process.env a su servidor, y se lleva todos los secretos. No es teórico: es la forma más común de comprometer repositorios públicos.

Las reglas, sin matices:

  1. Usa pull_request por defecto. Cubre el 95 % de los casos.
  2. Usa pull_request_target solo cuando necesites secretos o escritura desde forks —etiquetar, comentar, dar formato— y sin hacer checkout del código del PR.
  3. Si necesitas ambas cosas (construir código de un fork y comentar el resultado), pártelo: un workflow pull_request construye sin secretos y sube un artefacto; un segundo workflow con workflow_run lo descarga y comenta. El segundo tiene secretos pero nunca ejecuta código del fork.
  4. GITHUB_TOKEN con permissions mínimos siempre, y sobre todo aquí.

Aplicado a un repositorio privado como el de Reservalia, el riesgo es menor —solo el equipo abre PR— pero no nulo: una cuenta comprometida basta. Y si algún repositorio se hace público algún día, los workflows viajan con él.

  1. GITHUB_TOKEN, permissions y OIDC

Cada job recibe un GITHUB_TOKEN efímero que muere con él. Su alcance depende de la configuración de la organización —el valor por defecto puede ser permisivo en repositorios antiguos— y de lo que declares:

permissions:                # a nivel de workflow: se aplica a todos los jobs
  contents: read            # lo mínimo para checkout

jobs:
  etiquetar:
    permissions:            # a nivel de job: SUSTITUYE al del workflow, no se suma
      contents: read
      pull-requests: write  # solo este job puede comentar
    runs-on: ubuntu-22.04

  desplegar:
    permissions:
      contents: read
      id-token: write       # emitir el token OIDC
    environment: produccion
    runs-on: ubuntu-22.04
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/reservalia-despliegue-prod
          aws-region: eu-west-1
          # sin claves de larga vida: el token OIDC se cambia por credenciales temporales

Puntos que importan:

  • Declarar permissions en el workflow lo pone todo a lo declarado, no solo lo listado: si pones contents: read, el resto queda en none. Es lo que quieres.
  • Los permisos de job sustituyen, no acumulan. Un job con permissions: { pull-requests: write } pierde contents: read y el checkout falla.
  • GITHUB_TOKEN no dispara otros workflows. Un push hecho con él no lanza el on: push, por diseño, para evitar bucles infinitos. Si necesitas encadenar, usa una GitHub App o un PAT —con la precaución de que entonces sí puedes crear un bucle—.
  • OIDC (id-token: write) es lo que la 03-02 estableció: el runner pide un token firmado con reclamaciones sobre repositorio, rama, entorno y flujo, y AWS lo cambia por credenciales temporales. La condición de confianza en IAM debe atar sub con precisión:
    "StringLike": { "token.actions.githubusercontent.com:sub": "repo:reservalia/reservalia:environment:produccion" }
    
    Escribir repo:reservalia/* o dejar el sub con comodines amplios anula la mayor parte de la protección: cualquier rama de cualquier repositorio de la organización podría asumir el rol. Es un error de configuración frecuente y grave.

  1. Los tres tipos de action, con una JavaScript action completa

Tipo Cómo se ejecuta Velocidad Cuándo
Composite Steps YAML en el runner del job Muy rápida Secuencias de comandos; lo primero que hay que probar (04-05)
JavaScript Node.js en el runner, con @actions/* Rápida Lógica real, uso de la API, outputs calculados
Docker Contenedor construido o descargado Lenta: construye o descarga la imagen Herramientas que no son JS y dependencias del sistema. Solo Linux

La composite ya la escribiste en la 04-05. Aquí, una JavaScript action completa: comprueba el presupuesto de tamaño del bundle y publica el resultado, un caso real de Reservalia (05-01).

# .github/actions/presupuesto-bundle/action.yml
name: 'Presupuesto de bundle'
description: 'Compara el tamaño del bundle con un presupuesto y falla si lo supera'
author: 'Reservalia'

inputs:
  ruta:
    description: 'Directorio del build'
    required: true
    default: 'apps/web/dist'
  presupuesto-kb:
    description: 'Presupuesto en KB para el JS inicial'
    required: true
  fallar:
    description: 'Fallar el job si se supera'
    required: false
    default: 'true'

outputs:
  tamano-kb:
    description: 'Tamaño medido en KB'
  supera:
    description: 'true si supera el presupuesto'

runs:
  using: 'node20'
  main: 'dist/index.js'          # empaquetado con @vercel/ncc, con node_modules incluidos
// .github/actions/presupuesto-bundle/src/index.js
const core = require('@actions/core');
const fs   = require('node:fs');
const path = require('node:path');

function tamanoJsInicial(dir) {
  // Suma el tamaño de los .js de primer nivel (los chunks diferidos no cuentan)
  return fs.readdirSync(dir)
    .filter((f) => f.endsWith('.js'))
    .reduce((total, f) => total + fs.statSync(path.join(dir, f)).size, 0);
}

async function run() {
  try {
    const ruta        = core.getInput('ruta', { required: true });
    const presupuesto = Number(core.getInput('presupuesto-kb', { required: true }));
    const fallar      = core.getBooleanInput('fallar');          // 1 · parseo correcto de booleanos

    if (!fs.existsSync(ruta)) {
      core.setFailed(`El directorio ${ruta} no existe. ¿Se ejecutó el build?`);
      return;
    }

    const kb = Math.round(tamanoJsInicial(path.join(ruta, 'assets')) / 1024);
    const supera = kb > presupuesto;

    core.setOutput('tamano-kb', String(kb));                      // 2
    core.setOutput('supera', String(supera));

    // 3 · Resumen visible en la pestaña del job
    await core.summary
      .addHeading('Presupuesto de bundle')
      .addTable([
        [{ data: 'Métrica', header: true }, { data: 'Valor', header: true }],
        ['Tamaño del JS inicial', `${kb} KB`],
        ['Presupuesto', `${presupuesto} KB`],
        ['Margen', `${presupuesto - kb} KB`],
      ])
      .write();

    if (supera) {
      // 4 · Anotación: aparece señalada en la interfaz
      const mensaje = `El bundle pesa ${kb} KB y el presupuesto es ${presupuesto} KB`;
      if (fallar) core.setFailed(mensaje);
      else core.warning(mensaje);
    } else {
      core.info(`Bundle dentro de presupuesto: ${kb}/${presupuesto} KB`);
    }
  } catch (error) {
    core.setFailed(`Error inesperado: ${error.message}`);          // 5
  }
}

run();
  1. getBooleanInput parsea 'true'/'false' correctamente. getInput('fallar') === true sería siempre falso: todos los inputs llegan como cadena, que es la misma trampa del apartado 2.
  2. core.setOutput publica outputs consumibles con steps.<id>.outputs.<nombre>.
  3. core.summary escribe en $GITHUB_STEP_SUMMARY: markdown que aparece en la página del job (apartado 11).
  4. core.setFailed marca el paso como fallido con mensaje; core.warning y core.notice crean anotaciones sin fallar. Con file, startLine y endLine la anotación se ancla a una línea concreta del código, que es lo que hace útiles a los linters en la vista de diff.
  5. Captura global: sin ella, una excepción produce un fallo con traza cruda y difícil de interpretar.

Una función más que hay que conocer: core.setSecret(valor) registra un valor para que se enmascare en los logs a partir de ese momento. Es imprescindible cuando tu action calcula o recibe un secreto que no venía de secrets —un token obtenido de una API, por ejemplo—. Y con la misma advertencia de la 06-01 y la 06-04: el enmascarado cubre la coincidencia literal, no las transformaciones.

# Uso
- uses: ./.github/actions/presupuesto-bundle
  id: presupuesto
  with:
    ruta: apps/web/dist
    presupuesto-kb: '180'
    fallar: ${{ github.ref == 'refs/heads/main' }}
- run: echo "Bundle: ${{ steps.presupuesto.outputs.tamano-kb }} KB"

Detalle operativo que sorprende: una JavaScript action necesita sus dependencias comprometidas en el repositorio, porque el runner no ejecuta npm install. Se empaqueta con @vercel/ncc (ncc build src/index.js -o dist) y se comprueba en CI que dist/ está sincronizado con src/, o publicarás una action que no contiene tus cambios.

Cómo elegir: composite si son comandos; JavaScript si hay lógica, API o outputs calculados; Docker solo si necesitas dependencias del sistema difíciles —asumiendo que solo funciona en runners Linux y que arranca despacio—.

  1. Matrices avanzadas y matrices dinámicas

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false          # 1
      max-parallel: 4           # 2
      matrix:
        os: [ubuntu-22.04, macos-14]
        node: [20, 22]
        include:                # 3
          - os: ubuntu-22.04
            node: 22
            cobertura: true     # añade una propiedad a esa combinación
          - os: windows-2022    # añade una combinación entera
            node: 22
        exclude:                # 4
          - os: macos-14
            node: 20
    steps:
      - run: npm test
      - if: matrix.cobertura
        run: npm run cobertura
  1. fail-fast: true (por defecto) cancela toda la matriz al primer fallo. Ahorra minutos y oculta información: si querías saber si falla en Node 20 y en Node 22, ponlo en false. Regla práctica: false en matrices de compatibilidad, true en shards de la misma suite.
  2. max-parallel limita la concurrencia: útil cuando la matriz golpea un recurso compartido (una base de datos de pruebas, un límite de API).
  3. include tiene dos comportamientos y ahí está la confusión: si sus claves coinciden con una combinación existente, añade propiedades a esa combinación; si no coincide, crea una combinación nueva.
  4. exclude se aplica después de expandir todo, incluidos los include.

Matriz dinámica, que es el patrón que resuelve el monorepo de la 04-04:

jobs:
  detectar:
    runs-on: ubuntu-22.04
    outputs:
      paquetes: ${{ steps.calcular.outputs.paquetes }}
      hay-cambios: ${{ steps.calcular.outputs.hay-cambios }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }          # necesario para comparar con la base
      - id: calcular
        run: |
          # Emite un array JSON con los workspaces afectados
          PAQUETES=$(node scripts/afectados.js --base "${{ github.event.pull_request.base.sha }}")
          echo "paquetes=${PAQUETES}" >> "$GITHUB_OUTPUT"
          [ "$PAQUETES" = "[]" ] && echo "hay-cambios=false" >> "$GITHUB_OUTPUT" \
                                 || echo "hay-cambios=true"  >> "$GITHUB_OUTPUT"

  test:
    needs: [detectar]
    if: needs.detectar.outputs.hay-cambios == 'true'      # 1
    runs-on: ubuntu-22.04
    strategy:
      matrix:
        paquete: ${{ fromJSON(needs.detectar.outputs.paquetes) }}   # 2
    steps:
      - uses: ./.github/actions/preparar-node
      - run: npm test --workspace ${{ matrix.paquete }}

  puerta:                                                  # 3
    needs: [detectar, test]
    if: always()
    runs-on: ubuntu-22.04
    steps:
      - name: Comprobar resultado
        run: |
          if [ "${{ needs.test.result }}" = "failure" ] || [ "${{ needs.test.result }}" = "cancelled" ]; then
            echo "Los tests fallaron"; exit 1
          fi
          echo "OK (tests: ${{ needs.test.result }})"
  1. Una matriz vacía hace fallar el job, no lo omite. De ahí el output hay-cambios y el if.
  2. fromJSON convierte la cadena en un array real para la matriz.
  3. El job puerta es la pieza clave y la que casi nadie pone. Como los jobs de matriz tienen nombres dinámicos, no se pueden exigir por nombre en la protección de rama; y como un job skipped cuenta como aprobado, sin esta puerta un PR donde los tests se omiten por un error de detección pasaría la protección con todo en verde. Es el "verde falso" de la 04-04 en su forma más traicionera: no hay nada rojo que mirar. puerta sí tiene nombre fijo, se ejecuta siempre (if: always()) y comprueba needs.test.result explícitamente.

  1. Control de flujo: outputs, continue-on-error, timeout-minutes, concurrency

jobs:
  build:
    runs-on: ubuntu-22.04
    timeout-minutes: 20                        # 1
    outputs:
      digest: ${{ steps.publicar.outputs.digest }}
    steps:
      - id: publicar
        run: echo "digest=sha256:aaa..." >> "$GITHUB_OUTPUT"

      - name: Análisis opcional
        continue-on-error: true                # 2
        id: analisis
        run: ./scripts/analisis-experimental.sh

      - name: Avisar si el análisis falló
        if: steps.analisis.outcome == 'failure' # 3
        run: echo "::warning::El análisis experimental falló"

  desplegar:
    needs: [build]
    runs-on: ubuntu-22.04
    concurrency:                               # 4
      group: despliegue-produccion
      cancel-in-progress: false
    steps:
      - run: ./scripts/desplegar.sh "${{ needs.build.outputs.digest }}"
  1. timeout-minutes en cada job, sin excepción. El valor por defecto son 6 horas: un job colgado consume minutos facturables durante todo ese tiempo y bloquea un hueco de concurrencia. Es una línea que ahorra dinero real.
  2. continue-on-error: true deja que el step falle sin romper el job. También existe a nivel de job.
  3. outcome frente a conclusion: outcome es el resultado antes de aplicar continue-on-error; conclusion es el resultado final. Con continue-on-error, conclusion es success aunque outcome sea failure. Para detectar que algo falló pero no bloqueó, se mira outcome.
  4. Dos usos distintos de concurrency, y conviene no mezclarlos. Para CI: group: ci-${{ github.ref }} con cancel-in-progress: true, para cancelar ejecuciones obsoletas y ahorrar (04-04). Para despliegues: cancel-in-progress: false y un grupo global, para serializar y evitar dos despliegues simultáneos al mismo entorno. Cancelar un despliegue a medias es mucho peor que esperar.

Un detalle que confunde: con concurrency, solo se conserva la ejecución más reciente en espera; las intermedias se cancelan. En un despliegue serializado, si se acumulan tres commits, se despliega el primero y el último, y el del medio nunca. Suele ser lo deseable, pero hay que saberlo.

  1. Caché y artefactos: claves, límites y desalojo

- uses: actions/cache@v4
  id: cache
  with:
    path: |
      ~/.npm
      apps/web/node_modules/.vite
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}     # 1
    restore-keys: |
      npm-${{ runner.os }}-                                                # 2

- if: steps.cache.outputs.cache-hit != 'true'                              # 3
  run: echo "Caché fría"
  1. La clave debe contener todo lo que invalida: sistema operativo, arquitectura si varía, y el hash de los lockfiles. hashFiles acepta patrones múltiples.
  2. restore-keys son prefijos de reserva: sin ellos, cualquier cambio del lockfile obliga a descargar todo.
  3. cache-hit es 'true' solo con coincidencia exacta; una restauración parcial por restore-keys deja cache-hit en false aunque haya restaurado ficheros. Condicionar npm ci a cache-hit es un error clásico.

Reglas de comportamiento de la caché que no son obvias y explican muchas rarezas:

  • El ámbito de la caché es la rama. Una rama puede leer sus propias cachés y las de su rama base; no las de ramas hermanas. Por eso la primera ejecución de un PR suele ser más lenta y por eso conviene que main pueble la caché.
  • Las cachés son inmutables: escrita una clave, no se sobrescribe. Para invalidar, cambia la clave (un prefijo versionado ayuda).
  • Hay un límite de tamaño total por repositorio y se aplica desalojo por menos usada recientemente; además, las cachés no usadas durante un tiempo se eliminan. Consecuencia práctica: cachear varios gigabytes por rama hace que se expulsen unas a otras y la tasa de acierto se desploma. Menos y mejor elegido rinde más.
  • La caché no es un canal seguro entre ramas. Un PR puede escribir en una caché que después restaura otra ejecución. No caches binarios construidos que luego se ejecuten con privilegios (04-03).

Artefactos:

- uses: actions/upload-artifact@v4
  if: ${{ !cancelled() }}
  with:
    name: informes-${{ matrix.shard }}        # 1 · nombres únicos por shard
    path: |
      informes/
      cobertura/
    retention-days: 7                          # 2
    compression-level: 9

- uses: actions/download-artifact@v4
  with:
    pattern: informes-*                        # 3
    merge-multiple: true
    path: informes/
  1. En v4 los nombres de artefacto deben ser únicos dentro de la ejecución: subir el mismo nombre desde varios jobs de matriz falla. Es un cambio respecto a v3 que rompe muchos workflows heredados.
  2. retention-days ahorra almacenamiento facturable; la política de la 02-06 en una línea.
  3. pattern con merge-multiple recompone los informes de todos los shards en un job de agregación.

Y una diferencia con la caché que conviene tener presente: los artefactos se suben y descargan por red desde el servicio, no desde el runner, así que artefactos grandes cuestan tiempo en ambos extremos. Pasar node_modules entre jobs como artefacto suele ser más lento que reinstalar desde caché.

  1. Runners autoalojados y autoescalado con ARC

Alojados por GitHub Autoalojados
Mantenimiento Ninguno Tuyo: sistema, herramientas, seguridad
Coste Por minuto (gratis en repos públicos) Tu infraestructura + operación
Aislamiento Máquina limpia y efímera por job Depende de cómo lo montes
Acceso a red privada No : el motivo principal
Hardware especial Limitado a lo ofrecido Cualquiera: GPU, ARM, mucha memoria
Rendimiento Modesto en los tamaños básicos El que pagues
jobs:
  test:
    runs-on: [self-hosted, linux, x64, reservalia-grande]    # todas las etiquetas deben coincidir

Los runners se organizan en grupos (por organización) para restringir qué repositorios pueden usarlos —imprescindible si un runner tiene acceso a una red sensible—.

El riesgo grave, y hay que decirlo claro: nunca uses runners autoalojados en repositorios públicos. Cualquiera puede abrir un PR cuyo workflow ejecuta código arbitrario en tu máquina, dentro de tu red. Y si el runner es persistente, ese código puede dejar cosas para el siguiente job —incluido el de otro equipo—. GitHub lo advierte explícitamente y la advertencia se ignora con demasiada frecuencia.

ARC (Actions Runner Controller) resuelve el problema del runner persistente ejecutando un Pod efímero por job en Kubernetes, que es exactamente el modelo de la 06-05:

# values.yaml de un conjunto de runners escalables
githubConfigUrl: https://github.com/reservalia
githubConfigSecret: arc-github-app          # GitHub App, mejor que un PAT
minRunners: 1                               # 1 · uno caliente para el primer job
maxRunners: 30
containerMode:
  type: kubernetes                          # 2 · sin DinD privilegiado
template:
  spec:
    containers:
      - name: runner
        image: ghcr.io/actions/actions-runner:latest
        resources:
          requests: { cpu: "1", memory: "2Gi" }
          limits:   { cpu: "2", memory: "4Gi" }
  1. minRunners: 1 mantiene un runner caliente: sin él, el primer job de la mañana paga el arranque completo del Pod. Es el compromiso entre coste y tiempo de cola que la 04-04 pedía medir por separado.
  2. containerMode: kubernetes evita Docker-in-Docker privilegiado y usa Pods para los contenedores de servicio, con las implicaciones de seguridad de la 06-05.

Contrapartidas honestas de ARC: es un componente más que operar y actualizar; el arranque del Pod añade tiempo de cola; y la caché no persiste entre Pods, así que dependes de caché remota. A cambio: entorno limpio por job, autoescalado, y coste proporcional al uso en tu propia infraestructura.

  1. Resúmenes, anotaciones y comandos de workflow

Un pipeline cuyo resultado hay que buscar en 4.000 líneas de log es un pipeline que nadie mira (03-06). Tres mecanismos lo arreglan:

# Resumen del job: markdown que se ve en la pestaña, sin abrir logs
{
  echo "## Resultado de las pruebas"
  echo ""
  echo "| Suite | Tests | Fallos | Tiempo |"
  echo "|---|---|---|---|"
  echo "| API | 412 | 0 | 2m 14s |"
  echo "| Web | 188 | 1 | 1m 02s |"
  echo ""
  echo "<details><summary>Tests lentos</summary>"
  echo ""
  echo '```'
  cat informes/lentos.txt
  echo '```'
  echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"

# Anotaciones: aparecen destacadas y, con file/line, ancladas al diff
echo "::error file=apps/api/src/reservas.ts,line=42::Falta validación de la fecha"
echo "::warning::Cobertura por debajo del objetivo: 78 %"
echo "::notice::Imagen publicada como sha256:aaa..."

# Agrupar salida larga en secciones plegables
echo "::group::Salida de npm ci"
npm ci
echo "::endgroup::"

# Enmascarar un valor calculado en tiempo de ejecución
echo "::add-mask::$TOKEN_DERIVADO"

El resumen de job es la mejor inversión de legibilidad que se puede hacer en un pipeline con poco esfuerzo: un enlace a la previsualización, el digest publicado, el resultado del presupuesto de bundle y los tests fallidos en una tabla ahorran a Nuria abrir logs cada vez. Y ::add-mask:: es el equivalente de core.setSecret para scripts en shell: si tu script deriva un token, enmascáralo antes de que pueda aparecer en cualquier salida.

  1. Entornos, reglas y despliegues

jobs:
  desplegar-produccion:
    environment:
      name: produccion
      url: https://app.reservalia.example      # 1
    permissions: { contents: read, id-token: write }
    runs-on: ubuntu-22.04
    steps:
      - run: ./scripts/desplegar.sh produccion "${{ needs.build.outputs.digest }}"
  1. La URL aparece en la interfaz de despliegues y en el PR.

Lo que aporta un Environment, que es más de lo que parece:

  • Revisores obligatorios: el job espera aprobación humana. Es la puerta de la 03-01, y el job en espera no consume runner —diferencia notable frente al input de Jenkins (06-01)—.
  • Temporizador de espera: minutos obligatorios antes de desplegar, para dar margen a cancelar.
  • Ramas y tags permitidos: solo main puede desplegar a produccion, aunque alguien modifique el workflow en otra rama.
  • Secretos y variables por entorno: secrets.DATABASE_URL vale una cosa en staging y otra en producción, sin condicionales en el YAML.
  • Historial de despliegues por entorno con su commit.
  • Y la pieza que más importa combinada con OIDC: la reclamación sub del token incluye el entorno, así que el rol de IAM de producción solo puede asumirse desde un job con environment: produccion, que a su vez solo puede ejecutarse desde main y tras aprobación. Tres controles encadenados en lugar de uno.

  1. Límites reales del servicio y estrategias

Sin fanatismo, también con la herramienta de casa. Los valores concretos cambian con el plan y con el tiempo —consúltalos antes de diseñar—, pero los tipos de límite y sus estrategias son estables:

Límite Efecto cuando lo tocas Estrategia
Concurrencia de jobs por plan Jobs en cola: se percibe como "el CI va lento" aunque la ejecución sea rápida Medir cola y ejecución por separado (04-04); concurrency para cancelar obsoletos; runners propios para desbordar
Minutos incluidos (repos privados) Facturación adicional paths y ejecución selectiva; timeout-minutes; matrices más pequeñas
Tamaño total de caché por repositorio Desalojo por menos usada: la tasa de acierto se desploma Cachear menos y mejor; no cachear node_modules
Retención de artefactos y logs Almacenamiento facturable retention-days bajo; artefactos pequeños
API rate limit del GITHUB_TOKEN Fallos intermitentes en workflows que llaman mucho a la API Reducir llamadas, paginar bien, GitHub App con límites propios
schedule no dispara puntual Un cron a las 0 * * * * puede retrasarse Minutos raros; no asumir puntualidad; para precisión, disparador externo con repository_dispatch
schedule se desactiva tras ~60 días sin actividad en el repositorio El nocturno deja de ejecutarse en silencio Alerta de "hace X días que no se ejecuta"
Duración máxima de job y de workflow Cancelación Partir en jobs; timeout-minutes explícito
Anidamiento de workflows reutilizables Error al validar Aplanar la jerarquía
Matriz: número máximo de jobs Error Matrices dinámicas acotadas

Dos límites conceptuales, más importantes que los numéricos:

  • El plano de control es de GitHub. Si el servicio tiene una incidencia, no despliegas —ni con runners propios—. Conviene tener un camino de emergencia documentado: un script que despliegue desde una máquina con credenciales temporales, ensayado. Es el mismo razonamiento de la 03-05 aplicado a la herramienta.
  • La dependencia del ecosistema de acciones de terceros es una superficie de suministro. Es su mayor fortaleza y un riesgo real: fijar por SHA (04-03), revisar lo que se añade, y preferir comandos de CLI cuando la action solo envuelve uno.

  1. Depurar y probar workflows

# 1 · Logs de depuración: secretos ACTIONS_STEP_DEBUG y ACTIONS_RUNNER_DEBUG a "true"
- run: echo "runner.debug = ${{ runner.debug }}"     # '1' si está activo

# 2 · Volcar un contexto entero para entender qué llega
- run: echo '${{ toJSON(github.event) }}'

# 3 · Sesión interactiva en el runner (solo repos privados y con criterio)
- uses: mxschmitt/action-tmate@v3
  if: ${{ failure() && github.event_name == 'workflow_dispatch' }}
  timeout-minutes: 15
# 4 · Ejecutar workflows en local con act (aproximación, no equivalencia)
act pull_request -j calidad --container-architecture linux/amd64
act -j test --secret-file .secrets.local

Notas de uso real: ACTIONS_STEP_DEBUG muestra qué inputs recibe cada action y qué expresiones se evalúan a qué, y resuelve la mayoría de los "¿por qué este if no se cumple?". toJSON(github.event) es la forma más rápida de descubrir el nombre exacto de un campo del payload. tmate abre una sesión interactiva en un runner con los secretos del job: úsalo solo en repositorios privados, con timeout-minutes y preferiblemente en un job sin credenciales de producción; es el mismo razonamiento del SSH into build de la 06-03.

Y act: útil para iterar sobre la lógica de un job, pero no reproduce el entorno. No implementa igual las cachés, los servicios, los permisos del token, OIDC ni los entornos, y sus imágenes no son las de GitHub. Sirve para "¿mi script funciona?", no para "¿mi workflow es correcto?".

Para lo segundo, la estrategia de la 04-05 sigue siendo la buena: actionlint en el propio CI para detectar errores de sintaxis y expresiones antes de fusionar, una rama de pruebas donde ejercitar el workflow completo, y cambios progresivos con el pipeline nuevo en paralelo al viejo.

# Validar los workflows como parte del CI
- uses: actions/checkout@v4
- run: |
    bash <(curl -s https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
    ./actionlint -color

Errores Comunes y Consejos

Olvidar actions/checkout. El runner llega vacío. Es el primer error de todo el mundo.

Esperar que export viaje entre steps. Cada run es un shell nuevo: usa $GITHUB_ENV.

Comparar inputs booleanos con true. Llegan como cadena. == 'true' o fromJSON(...).

permissions a nivel de job creyendo que suma. Sustituye: si pones pull-requests: write sin contents: read, el checkout falla.

pull_request_target con checkout del código del PR. Es la forma más directa de filtrar todos tus secretos.

sub de OIDC con comodines amplios. repo:org/* permite que cualquier repositorio de la organización asuma tu rol de producción. Ata repositorio, rama o entorno.

Sin timeout-minutes. El valor por defecto son 6 horas de minutos facturables por un job colgado.

fail-fast: true en matrices de compatibilidad. Cancela y oculta la información que buscabas.

Matriz dinámica sin job de puerta. Los jobs skipped cuentan como aprobados en la protección de rama: verde falso sin nada rojo que mirar.

Condicionar la instalación a cache-hit. Una restauración parcial deja cache-hit en false; y aunque acertara, npm ci sigue siendo necesario.

Cachear node_modules. Binarios atados a versión y arquitectura, y ocupa el presupuesto de caché del repositorio expulsando cachés útiles.

Runners autoalojados en repositorios públicos. Ejecución de código arbitrario dentro de tu red.

Acciones de terceros sin fijar por SHA. Un tag es mutable: quien controla el repositorio de la action controla tu pipeline (04-03).

Nombres de artefacto repetidos en matriz con v4. Falla la subida; usa sufijo con el índice.

if: always() para publicar informes. Se ejecuta también al cancelar. Usa if: ${{ !cancelled() }}.

Ejercicios

Ejercicio 1. Escribe una JavaScript action verificar-migraciones que, dado un directorio de migraciones, compruebe tres cosas: que no hay dos ficheros con el mismo número de versión, que ninguna migración nueva del PR contiene DROP COLUMN o DROP TABLE (la regla de expand and contract de la 04-06), y que cada migración tiene su fichero de reversión. Debe exponer outputs, escribir un resumen de job, generar anotaciones ancladas al fichero y poder configurarse como bloqueante o informativa.

Ejercicio 2. Un repositorio público de Reservalia tiene este workflow. Encuentra todos los problemas de seguridad, explica el impacto de cada uno y reescríbelo:

on: pull_request_target
jobs:
  comentar-tamano:
    runs-on: [self-hosted, linux]
    steps:
      - uses: actions/checkout@v3
        with: { ref: ${{ github.event.pull_request.head.sha }} }
      - run: npm install && npm run build
      - run: |
          TAMANO=$(du -sh dist | cut -f1)
          curl -X POST -H "Authorization: token ${{ secrets.PAT_ADMIN }}" \
            -d "{\"body\":\"Bundle: $TAMANO — ${{ github.event.pull_request.title }}\"}" \
            "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.number }}/comments"

Ejercicio 3. El ci.yml de Reservalia tarda 11 minutos y el equipo se queja de que "GitHub Actions va lento". Los datos: el tiempo medio en cola es de 4 minutos entre las 10:00 y las 12:00; los seis jobs restauran caché pero la tasa de acierto es del 40 %; la matriz de tests tiene fail-fast: true y a menudo se cancela por un flaky; hay tres jobs sin timeout-minutes y uno se queda colgado un par de veces por semana; y la caché total del repositorio está al límite con 9 GB, la mayoría en node_modules de ramas viejas. Diagnostica cada punto y da un plan ordenado.

Soluciones

Solución 1.

# .github/actions/verificar-migraciones/action.yml
name: 'Verificar migraciones'
description: 'Comprueba numeración única, ausencia de operaciones destructivas y reversión'
inputs:
  directorio: { description: 'Directorio de migraciones', required: true, default: 'apps/api/migraciones' }
  base:       { description: 'SHA base para detectar migraciones nuevas', required: true }
  fallar:     { description: 'Fallar el job si hay problemas', required: false, default: 'true' }
outputs:
  problemas:  { description: 'Número de problemas encontrados' }
  destructivas: { description: 'true si hay operaciones destructivas' }
runs:
  using: 'node20'
  main: 'dist/index.js'
const core = require('@actions/core');
const exec = require('@actions/exec');
const fs   = require('node:fs');
const path = require('node:path');

const DESTRUCTIVAS = /\b(DROP\s+(COLUMN|TABLE)|TRUNCATE|ALTER\s+COLUMN\s+\w+\s+TYPE)\b/i;

async function ficherosNuevos(base, dir) {
  let salida = '';
  await exec.exec('git', ['diff', '--name-only', '--diff-filter=A', `${base}...HEAD`, '--', dir],
    { listeners: { stdout: (d) => (salida += d.toString()) } });
  return salida.split('\n').filter(Boolean);
}

async function run() {
  try {
    const dir    = core.getInput('directorio', { required: true });
    const base   = core.getInput('base', { required: true });
    const fallar = core.getBooleanInput('fallar');
    const problemas = [];

    // 1 · Numeración duplicada (sobre TODAS las migraciones, no solo las nuevas)
    const porNumero = new Map();
    for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.sql') && !f.endsWith('.down.sql'))) {
      const num = f.split('_')[0];
      if (porNumero.has(num)) {
        problemas.push({ fichero: path.join(dir, f), linea: 1,
          mensaje: `Número de versión duplicado ${num} (colisiona con ${porNumero.get(num)})` });
      } else {
        porNumero.set(num, f);
      }
    }

    // 2 y 3 · Solo sobre las migraciones NUEVAS del PR
    let hayDestructivas = false;
    for (const ruta of await ficherosNuevos(base, dir)) {
      if (ruta.endsWith('.down.sql')) continue;

      const lineas = fs.readFileSync(ruta, 'utf8').split('\n');
      lineas.forEach((linea, i) => {
        if (DESTRUCTIVAS.test(linea)) {
          hayDestructivas = true;
          problemas.push({ fichero: ruta, linea: i + 1,
            mensaje: `Operación destructiva. Usa expand and contract: despliega el código que ya no usa la columna, y elimínala en una migración posterior` });
        }
      });

      const reversion = ruta.replace(/\.sql$/, '.down.sql');
      if (!fs.existsSync(reversion)) {
        problemas.push({ fichero: ruta, linea: 1, mensaje: `Falta el fichero de reversión ${path.basename(reversion)}` });
      }
    }

    for (const p of problemas) {
      core.error(p.mensaje, { file: p.fichero, startLine: p.linea, title: 'Migraciones' });
    }

    core.setOutput('problemas', String(problemas.length));
    core.setOutput('destructivas', String(hayDestructivas));

    const resumen = core.summary.addHeading('Verificación de migraciones');
    if (problemas.length === 0) {
      resumen.addRaw('Sin problemas. Numeración única, sin operaciones destructivas y con reversión.');
    } else {
      resumen.addTable([
        [{ data: 'Fichero', header: true }, { data: 'Línea', header: true }, { data: 'Problema', header: true }],
        ...problemas.map((p) => [p.fichero, String(p.linea), p.mensaje]),
      ]);
    }
    await resumen.write();

    if (problemas.length > 0) {
      const msg = `${problemas.length} problema(s) en las migraciones`;
      fallar ? core.setFailed(msg) : core.warning(msg);
    }
  } catch (e) {
    core.setFailed(`Error inesperado: ${e.message}`);
  }
}
run();

Decisiones de diseño que conviene justificar. La numeración duplicada se comprueba sobre todo el directorio, porque la colisión puede venir de dos PR abiertos en paralelo que individualmente son correctos —es un problema de integración, no de fichero—. Las operaciones destructivas y la reversión se comprueban solo sobre las migraciones nuevas, porque las históricas ya se aplicaron y marcarlas sería ruido que haría ignorar la herramienta. El input fallar permite el despliegue progresivo de la 04-05: primero informativa durante dos semanas para medir cuánto ruido genera, luego bloqueante. Y las anotaciones ancladas a fichero y línea aparecen en la vista de diff, donde el revisor las ve sin abrir logs. El uso requiere fetch-depth: 0 y pasar base: ${{ github.event.pull_request.base.sha }}.

Solución 2. Seis problemas, tres de ellos graves:

# Problema Impacto
1 pull_request_target + checkout del código del PR Cualquiera abre un PR y ejecuta código con todos los secretos del repositorio
2 npm install sobre código del PR Ejecuta scripts postinstall del atacante. Consumación inmediata del punto 1
3 Runner autoalojado en un repositorio público Ejecución arbitraria dentro de tu red, y contaminación de jobs posteriores si es persistente
4 secrets.PAT_ADMIN Un PAT de administrador donde bastaría GITHUB_TOKEN con pull-requests: write. Máximo privilegio
5 Interpolar pull_request.title dentro de un curl Inyección de comandos: un título con comillas y $( ) ejecuta lo que quiera
6 actions/checkout@v3 sin fijar por SHA, sin permissions, sin timeout-minutes Dependencia mutable, permisos amplios, coste sin límite

Reescritura en dos workflows, que es el patrón correcto:

# .github/workflows/pr-build.yml — SIN secretos, ejecuta código no confiable
name: PR · build
on: pull_request
permissions: { contents: read }
jobs:
  build:
    runs-on: ubuntu-22.04                 # runner alojado, efímero
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - uses: actions/setup-node@v4
        with: { node-version-file: .nvmrc, cache: npm }
      - run: npm ci
      - run: npm run build
      - name: Guardar datos para el comentario
        run: |
          mkdir -p resultado
          du -sk dist | cut -f1 > resultado/tamano-kb.txt
          echo "${{ github.event.number }}" > resultado/pr.txt
      - uses: actions/upload-artifact@v4
        with: { name: resultado, path: resultado/, retention-days: 1 }
# .github/workflows/pr-comentar.yml — CON permisos, NO ejecuta código del PR
name: PR · comentar
on:
  workflow_run:
    workflows: ["PR · build"]
    types: [completed]
permissions: { contents: read, pull-requests: write }
jobs:
  comentar:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-22.04
    timeout-minutes: 5
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: resultado
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - id: datos
        run: |
          echo "kb=$(cat tamano-kb.txt)" >> "$GITHUB_OUTPUT"
          echo "pr=$(cat pr.txt)"        >> "$GITHUB_OUTPUT"
      - uses: actions/github-script@v7
        env:
          KB: ${{ steps.datos.outputs.kb }}     # por env, nunca interpolado en el cuerpo
          PR: ${{ steps.datos.outputs.pr }}
        with:
          script: |
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: Number(process.env.PR),
              body: `Tamaño del bundle: ${process.env.KB} KB`
            });

La idea central: separar el privilegio de la ejecución de código no confiable. El primer workflow ejecuta código del PR sin ningún secreto y con permisos de solo lectura; el segundo tiene permiso de escritura pero solo procesa datos ya generados y nunca ejecuta código del fork. Nótese además que el título del PR desaparece del comentario: no aportaba nada y era el vector de inyección. Y que los datos se pasan por env, no interpolados en el cuerpo del script, que es la regla general para cualquier dato de origen externo.

Solución 3. El diagnóstico separa dos cosas que el equipo está mezclando: 11 minutos de ejecución y 4 de cola son problemas distintos con causas distintas, y llamarlo todo "GitHub Actions va lento" impide arreglar ninguno.

# Síntoma Causa Acción Efecto
1 3 jobs sin timeout-minutes, colgados 2 veces/semana Valor por defecto de 6 h timeout-minutes en todos los jobs Deja de consumir concurrencia y minutos; causa parcial de la cola
2 Caché al límite (9 GB) con acierto del 40 % node_modules cacheado por rama; desalojo por menos usada Cachear ~/.npm con clave por lockfile; borrar cachés viejas; no cachear node_modules Acierto al 85-90 %; ~1,5 min por job
3 4 min de cola en horas punta Límite de concurrencia del plan, agravado por 1 concurrency con cancel-in-progress en CI; paths para no lanzar todo siempre; evaluar runners propios si persiste Cola a ~1 min
4 Matriz cancelada por un flaky fail-fast: true en shards + flaky sin cuarentena fail-fast: false y, sobre todo, cuarentena del flaky (02-04) Menos reejecuciones completas
5 11 min de ejecución A determinar tras lo anterior Medir por job con los tiempos de la interfaz antes de tocar nada más

Orden y razones. Primero el punto 1, porque cuesta cinco minutos de trabajo y es causa parcial del punto 3: jobs colgados ocupando huecos de concurrencia son parte de la cola. Luego el 2, que es el ahorro de tiempo más grande por esfuerzo y además libera presupuesto de caché. Después el 4, con el matiz importante: fail-fast: false reduce el síntoma pero el problema real es el flaky, y dejarlo sin cuarentena mientras se esconde el síntoma es exactamente lo que la 02-04 advertía. Y solo entonces el 3 y el 5, cuando se pueda medir cuánto queda de cada cosa.

Lo que hay que llevarse: de los cinco puntos, tres no son de la herramienta —un flaky sin cuarentena, jobs sin timeout, caché mal elegida— y sí de cómo se está usando. La conclusión que hay que llevar al equipo es que antes de cambiar de herramienta o de pagar más concurrencia, hay que separar cola de ejecución y arreglar lo propio, que es literalmente la primera regla de la 04-04. Si tras esto la cola sigue siendo el cuello de botella, entonces sí hay una conversación legítima sobre plan o runners propios, y esa conversación se tiene con datos.

Conclusión

GitHub Actions ha sido la herramienta del curso desde el módulo 2, y esta lección la ha mirado por fin como objeto de estudio. Lo que cierra los huecos que quedaban: el ciclo de un evento y de qué rama se lee el workflow —que explica por qué schedule y workflow_run se comportan como se comportan—; los contextos, con la trampa de que todos los inputs son cadenas; los disparadores que faltaban y la regla de oro de pull_request frente a pull_request_target; los permisos que sustituyen en vez de sumar y el sub de OIDC que hay que atar con precisión; los tres tipos de action y cuándo bajar a JavaScript; las matrices dinámicas con su job de puerta obligatorio; el comportamiento real de la caché con su ámbito por rama y su desalojo; ARC para runners efímeros; y los límites del servicio con sus estrategias.

Las contrapartidas, dichas sin adornos, porque el módulo prometió no hacer marketing con ninguna herramienta: el plano de control no es tuyo y una incidencia del servicio te deja sin desplegar; el ecosistema de acciones de terceros es su mayor fortaleza y una superficie de suministro real; la caché tiene un presupuesto por repositorio que castiga el exceso; schedule no es puntual y se apaga solo; hay asimetrías peligrosas como que un job skipped cuente como aprobado; y pull_request_target es un pie de guerra permanente en repositorios públicos. Ninguna de esas cosas la hace mala elección —para un equipo cuyo código ya está en GitHub sigue siendo la opción por defecto más razonable—, pero conocerlas es la diferencia entre usarla y sufrirla.

Con esto quedan las seis herramientas recorridas y el mismo pipeline de Reservalia traducido seis veces. La lección que cierra el módulo, Comparativa y Criterios para Elegir Herramienta, pone todo en la misma mesa: la tabla de equivalencias de vocabulario, la comparación por dimensiones —modelo de ejecución, alojamiento, reutilización, identidad, coste—, los criterios de decisión ordenados por su peso real (con un factor que decide el 80 % de los casos), un árbol de decisión, un ejercicio de coste total de propiedad, el coste de cambiar de herramienta y cómo reducirlo, y una guía de migración por fases. Y después, el módulo 7: construir tu propio pipeline de extremo a extremo.

Curso de CI/CD: Integración y Despliegue Continuo

Módulo 1: Introducción a CI/CD

Módulo 2: Integración Continua (CI)

Módulo 3: Despliegue Continuo (CD)

Módulo 4: Prácticas Avanzadas de CI/CD

Módulo 5: Implementación de CI/CD en Proyectos Reales

Módulo 6: Herramientas y Tecnologías

Módulo 7: Ejercicios Prácticos

Módulo 8: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados