Reservalia ha resuelto dos de los cuatro frentes, y al hacerlo ha engordado el pipeline: el job seguridad de la lección anterior añadió dos minutos, las dependencias se auditan, las imágenes se escanean y se firman. El primer frente —el pipeline es lento y se está engordando— vuelve ahora con toda su fuerza, y con una frase de Diego que el equipo repite: "si el CI tarda más que ir a por un café, dejo de mirarlo". Esta lección va de recuperar ese tiempo sin perder verificación. Empezaremos por lo único que no es negociable: medir antes de tocar nada, porque se optimiza lo que se mide y no lo que se sospecha. Después recorreremos las cuatro palancas ordenadas por relación coste/beneficio —caché, paralelización, ejecución selectiva y elección del runner—, con sus reglas de invalidación y sus límites teóricos. Miraremos la factura, que crece más silenciosamente que el tiempo. Y veremos qué ocurre cuando el equipo triplica su tamaño. Todo ello vigilando el riesgo que acompaña a todas las optimizaciones: el verde falso, un pipeline rapidísimo que ya no comprueba lo que creemos. Lo que no haremos aquí es extraer plantillas ni reutilizar bloques: eso es la 04-05.

Contenido

  1. Medir antes de tocar: dónde se va el tiempo
  2. Palanca 1: caché de dependencias, de compilación y de capas
  3. Palanca 2: paralelización y el límite de Amdahl
  4. Palanca 3: ejecución selectiva en el monorepo
  5. Palanca 4: elección del runner
  6. Las cuatro palancas comparadas
  7. El coste: minutos, almacenamiento y cómo evitar que la factura crezca sola
  8. Escalar con el equipo: colas, merge queue y treinta personas
  9. El riesgo transversal: el verde falso
  10. Errores Comunes y Consejos
  11. Ejercicios
  12. Conclusión

  1. Medir antes de tocar: dónde se va el tiempo

La primera regla es incómoda porque contradice el instinto: casi nunca aciertas dónde está el tiempo. Todo el mundo tiene una hipótesis —"son las pruebas"— y en la mayoría de pipelines la respuesta real es la instalación de dependencias o la construcción de la imagen.

GitHub Actions da los datos sin instalar nada: en la interfaz, cada ejecución muestra la duración por job y, al abrir un job, por step. Para verlo de forma agregada, la API:

gh api "repos/reservalia/reservalia/actions/runs?branch=main&per_page=50" \
  --jq '.workflow_runs[] | select(.name=="CI") | [.run_started_at, .updated_at] | @tsv'   # 1

gh api "repos/reservalia/reservalia/actions/runs/$RUN_ID/jobs" \
  --jq '.jobs[] | {nombre: .name,
        segundos: ((.completed_at|fromdate) - (.started_at|fromdate))}'                   # 2
  1. Cincuenta ejecuciones, no una. Una sola medición mezcla el ruido de la infraestructura con la realidad; con cincuenta se ve la mediana y, sobre todo, la dispersión. Un job cuya duración oscila entre 40 s y 4 min tiene un problema distinto del que siempre tarda 3 min.
  2. Por job y por step. El agregado dice cuánto tarda el pipeline; el detalle dice qué hay que tocar.

Este es el desglose real del CI de Reservalia tras añadir el job seguridad, medido sobre las últimas cincuenta ejecuciones de main:

Job Step Mediana % del job Observación
calidad (0:50) npm ci (32 s) + prettier/lint/tsc (18 s) 50 s 64 % / 36 % La instalación domina
test (3:00) npm ci 32 s 18 %
unitarias 42 s 23 %
migraciones + integración 1:46 59 % El grueso real
build (4:05) npm ci (32 s) + npm run build (1:08) 1:40 41 %
docker build 2:25 59 % El camino crítico
seguridad (2:00) escaneos 2:00 100 % Ya corre en paralelo
publicar (2:00) build + push a ECR 2:00 100 % Reconstruye la imagen

Tres conclusiones que ninguna intuición habría dado. El camino crítico es build (4:05) + publicar (2:00) = 6 minutos, y dentro de él manda docker build, no las pruebas. npm ci se repite cuatro veces, 32 segundos cada una: no suma al reloj porque los jobs son paralelos, pero sí a los minutos facturables. Y publicar reconstruye la imagen que build ya había construido: dos minutos regalados. El objetivo que fija el equipo, coherente con la regla de los 10 minutos de la 04-01, es bajar de 6 minutos a 3. Y una advertencia metodológica: anota la línea base antes de tocar nada. Sin ella, dentro de dos meses nadie sabrá si la caché sirvió de algo o si el pipeline es más rápido porque alguien borró pruebas.

  1. Palanca 1: caché de dependencias, de compilación y de capas

Es la palanca con mejor relación coste/beneficio: se implementa en minutos y ahorra en cada ejecución de cada job. Caché de dependencias. Reservalia la tiene desde la 02-02 (cache: npm en setup-node) y la 04-02 explicó su clave: el hash del lockfile. De los 32 segundos de npm ci, unos 20 son descarga y 12 instalación; con caché caliente quedan unos 14 segundos.

Caché de compilación. TypeScript y Vite saben compilar de forma incremental si se les deja guardar su estado:

      - uses: actions/cache@v4
        with:
          path: |
            .tsbuildinfo
            apps/web/node_modules/.vite       # 1
          key: build-${{ runner.os }}-${{ hashFiles('**/*.ts','**/*.tsx','tsconfig*.json') }}
          restore-keys: build-${{ runner.os }}-   # 2
  1. Cada herramienta tiene su directorio de estado: .tsbuildinfo para tsc, .vite para Vite, .turbo o .nx con un orquestador de monorepo.
  2. restore-keys es aquí lo decisivo. Una coincidencia exacta de clave es rara —cualquier cambio en cualquier .ts la invalida—, pero recuperar la caché anterior permite recompilar solo lo que cambió. Sin restore-keys, la caché de compilación casi nunca acierta y no sirve de nada.

Caché de capas de Docker. Es la que más ahorra en Reservalia, porque docker build son 2:25 de los 4:05 de build.

      - uses: docker/setup-buildx-action@v3          # 1
      - uses: docker/build-push-action@v5
        with:
          context: .
          file: apps/api/Dockerfile
          push: true
          tags: ${{ env.ECR }}/reservalia/api:${{ github.sha }}
          cache-from: type=registry,ref=${{ env.ECR }}/reservalia/api:cache   # 2
          cache-to:   type=registry,ref=${{ env.ECR }}/reservalia/api:cache,mode=max
  1. buildx es el constructor extendido de Docker; sin él no hay exportación de caché.
  2. Caché en el registro (type=registry) en lugar de en GitHub (type=gha). La caché de GitHub tiene un límite de 10 GB por repositorio y expulsa lo menos usado, así que en un repositorio activo se pierde constantemente; una etiqueta :cache en el propio ECR no tiene ese problema, se comparte entre jobs y ramas, y vive donde ya se tiene acceso. mode=max guarda también las capas intermedias, que es lo que hace que la caché sirva de verdad.

El orden de las instrucciones del Dockerfile es la otra mitad del rendimiento, y es gratis. Docker invalida una capa y todas las siguientes cuando cambian sus entradas, así que lo que cambia poco va arriba:

FROM node:20.11.0-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./     # 1 · solo los manifiestos
RUN npm ci --omit=dev                       # 2 · capa cara, reutilizada casi siempre
COPY . .                                    # 3 · el código, al final
RUN npm run build
  1. Copiar solo los manifiestos antes de instalar es el truco central: mientras el lockfile no cambie, la capa del npm ci se reutiliza.
  2. Si el COPY . . estuviera antes del RUN npm ci, cualquier cambio en cualquier fichero invalidaría la instalación y se reinstalarían 418 paquetes en cada commit.
  3. Un .dockerignore que excluya node_modules, .git y dist reduce el contexto enviado al daemon, lo que a veces ahorra más que la propia caché.

Con la caché de capas caliente, docker build baja de 2:25 a unos 40 segundos y el job build completo a 2:10.

  1. Palanca 2: paralelización y el límite de Amdahl

Reservalia ya paraleliza los jobs independientes desde la 02-07. Lo siguiente es partir el job más lento por dentro, que en pruebas se llama sharding:

  test:
    strategy:
      fail-fast: false
      matrix:
        particion: [1, 2, 3]                                    # 1
    steps:
      - run: npm run test:unidad -- --shard=${{ matrix.particion }}/3
      - run: npm run test:integracion --workspace apps/api -- --shard=${{ matrix.particion }}/3

  test-completo:                                                 # 2
    needs: [test]
    if: always()
    runs-on: ubuntu-22.04
    steps:
      - run: '[ "${{ needs.test.result }}" = "success" ] || exit 1'
  1. Tres particiones, tres máquinas. Cada una ejecuta un tercio de los ficheros de prueba. El reparto lo hace el ejecutor (Vitest y Jest lo soportan) y conviene que sea por duración estimada y no por número de ficheros, o una partición acabará cargando con todas las pruebas de integración.
  2. El job agregador es imprescindible y se olvida siempre. Los checks obligatorios de la protección de rama se configuran por nombre de job, y con una matriz los nombres pasan a ser test (1), test (2), test (3). Si mañana pasas a cinco particiones, los checks test (4) y test (5) no existen en la configuración y nadie los exige: dos quintas partes de la suite dejan de bloquear el merge sin que nadie lo note. Un job agregador con nombre fijo, del que se exige el resultado, resuelve el problema para siempre.

Y ahora el límite, que evita gastar dinero sin ganar tiempo. La ley de Amdahl dice que la mejora total está acotada por la parte que no se puede paralelizar. En el job test hay 32 s de npm ci que ocurren en todas las particiones y no se dividen:

Particiones Parte serie Parte paralela (2:28) Total Ganancia
1 32 s 2:28 3:00
2 32 s 1:14 1:46 −41 %
3 32 s 49 s 1:21 −55 %
6 32 s 25 s 0:57 −68 %

De 1 a 3 particiones se gana un 55 %; de 3 a 6, solo un 13 % más a cambio de duplicar el gasto, y de 6 a 12 apenas un 8 % adicional. Reservalia se queda en 3. La conclusión general es que la paralelización tiene rendimientos rápidamente decrecientes: el suelo lo pone la parte serie —instalación, arranque del runner, descarga del código—, y por eso la caché del apartado anterior mejora también el techo de esta palanca.

  1. Palanca 3: ejecución selectiva en el monorepo

La palanca más rentable de un monorepo: no ejecutar lo que el cambio no puede haber roto. Requiere conocer el grafo de dependencias interno, que en Reservalia es este:

flowchart TD
    T["packages/tipos-compartidos"] --> A["apps/api"]
    T --> W["apps/web"]
    D[".github/ · infra/ · package-lock.json"] -.->|"afecta a todo"| A
    D -.-> W

La regla se lee hacia arriba: un cambio en tipos-compartidos obliga a verificar todo, porque los dos consumidores dependen de él; un cambio solo en apps/web no puede romper la API; y un cambio en docs/ no rompe nada.

  cambios:                                          # 1 · job barato que decide
    runs-on: ubuntu-22.04
    outputs:
      api: ${{ steps.filtro.outputs.api }}
      web: ${{ steps.filtro.outputs.web }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filtro
        with:
          filters: |
            api:
              - 'apps/api/**'
              - 'packages/tipos-compartidos/**'     # 2 · la dependencia, explícita
              - 'package-lock.json'
              - '.github/workflows/**'
            web:
              - 'apps/web/**'
              - 'packages/tipos-compartidos/**'
              - 'package-lock.json'

  build-api:
    needs: [cambios]
    if: needs.cambios.outputs.api == 'true'         # 3 · el job se salta entero
    runs-on: ubuntu-22.04
    steps: [ ... ]
  1. Un job previo de unos 10 segundos calcula qué ha cambiado y lo publica como outputs: el mecanismo de la 04-01 aplicado a una decisión.
  2. La dependencia interna se declara a mano en el filtro, y ese es el punto frágil: si alguien añade un paquete nuevo a packages/ y olvida incluirlo, los jobs se saltarán cambios que sí importaban. Herramientas como Turborepo o Nx derivan el grafo del propio código y evitan ese olvido, a cambio de una herramienta más que mantener. Con tres paquetes el filtro a mano es razonable; con treinta, no.
  3. if a nivel de job salta el job entero, no solo un step.

Y aquí está la propiedad que hay que entender bien: un job saltado por if reporta "success" —éxito neutro—, no un fallo. GitHub lo trata como cumplido a efectos de los checks obligatorios, de modo que un PR que solo toca apps/web puede fusionarse aunque build-api no se haya ejecutado. Es exactamente el comportamiento que se busca, y también el motivo por el que en la 02-07 se recomendó filtrar por job o por step y nunca filtrar el evento entero con on: paths: un evento filtrado no produce ningún resultado, y el check obligatorio se queda esperando para siempre. Un aviso de honestidad: la ejecución selectiva es la palanca con más riesgo de verde falso de las cuatro, porque su premisa es "esto no puede haberse roto" y esa premisa se puede equivocar. Volveremos sobre ello en el apartado 9.

  1. Palanca 4: elección del runner

Opción Ventaja Inconveniente Coste relativo
Estándar (2 vCPU, 7 GB) Barato y siempre disponible Lento en tareas con muchos núcleos ×1
Grande (8 o 16 vCPU) Compila y construye mucho más rápido Cuesta 4-8 veces más por minuto ×4 a ×8
Más jobs en paralelo Escala en anchura Multiplica los npm ci y los arranques ×N
Autoalojado Sin coste por minuto; disco y caché persistentes Hay que mantenerlo y asegurarlo Coste fijo

Tres criterios para decidir. Una máquina grande solo ayuda si la tarea usa varios núcleos: docker build y tsc sí; esperar a una base de datos, no. Pagar ×4 por un job que espera en la red es tirar el dinero, y por eso conviene mirar el uso de CPU antes de subir de tamaño. Más máquinas escalan mejor que máquinas más grandes cuando el trabajo es divisible, hasta el límite de Amdahl del apartado 3. Y el arranque en frío es un coste fijo de 10 a 30 segundos por job —aprovisionar la máquina y descargar el código—: con veinte jobs pequeños se pagan varios minutos solo en arrancar, así que partir en exceso deja de compensar. Sobre los runners autoalojados, la advertencia que enlaza con las dos lecciones anteriores: eliminan el coste por minuto y permiten una caché local persistente, pero reintroducen el problema de los pipelines de larga vida de la 04-01 —el estado se acumula entre ejecuciones— y un riesgo de seguridad serio: un runner autoalojado que ejecute workflows de pull requests de forks está ejecutando código de desconocidos dentro de tu red. Si se usan, deben ser efímeros (una máquina nueva por job, destruida al terminar) y nunca aceptar trabajos de forks. Reservalia no los necesita: con su volumen, los runners alojados salen más baratos que el tiempo de Nuria manteniéndolos.

  1. Las cuatro palancas comparadas

Aplicadas a Reservalia, en el orden en que conviene ejecutarlas, con su riesgo asociado:

Palanca Esfuerzo Ahorro estimado Riesgo Riesgo concreto
Caché de dependencias Muy bajo −18 s por job Bajo Clave mal construida: verde falso
Caché de capas Docker Bajo −1:45 en build Bajo Dockerfile mal ordenado: no acierta nunca
Reutilizar la imagen en publicar Bajo −1:50 Muy bajo Ninguno relevante
Sharding de pruebas ×3 Medio −1:39 en test Medio Sin job agregador: checks no exigidos
Ejecución selectiva Medio −2 a −4 min en PR parciales Alto Filtro incompleto: no se prueba lo que cambió
Runner grande para build Muy bajo −40 s Bajo Coste ×8 sin medir si compensa

Y el resultado sobre el camino crítico, midiendo con la misma vara:

Job Antes Después Qué lo consiguió
calidad 0:50 0:35 Caché de dependencias
test 3:00 1:21 Caché + sharding ×3
build 4:05 2:10 Caché de capas en el registro
seguridad 2:00 2:00 Sin cambios: ya era paralelo
publicar 2:00 0:50 Reutiliza el digest en vez de reconstruir
Camino crítico 6:05 3:00

Objetivo cumplido con margen y sin haber eliminado ni una sola verificación: eso es exactamente lo que distingue una optimización de un recorte.

  1. El coste: minutos, almacenamiento y cómo evitar que la factura crezca sola

El tiempo lo sufre el equipo; la factura la sufre alguien que no está en la conversación, y por eso crece sin que nadie se entere. Tres partidas:

Minutos de runner. Se facturan por job y por minuto empezado, así que paralelizar reduce el reloj pero aumenta el gasto: los 6 minutos de reloj de Reservalia eran unos 12 minutos-máquina sumando los cinco jobs. Con 12 despliegues y unos 40 pull requests por semana, la cuenta sale sola. Almacenamiento de artefactos y cachés. Es la partida que sorprende. Un artefacto web-dist de 40 MB por ejecución, con la retención por defecto de 90 días y 200 ejecuciones al mes, son unos 24 GB acumulados que nadie volverá a abrir.

      - uses: actions/upload-artifact@v4
        with: { name: web-dist, path: apps/web/dist, retention-days: 7 }   # 1
  1. Bajar retention-days es la medida de mayor rendimiento por carácter escrito. Siete días bastan para investigar un fallo; noventa solo sirven para pagar. La retención también puede fijarse a nivel de organización.

Las medidas que evitan que la factura crezca sola, por orden de eficacia:

  1. concurrency con cancel-in-progress en las ramas de PR: si Diego empuja tres veces en diez minutos, solo corre la última. Con la excepción de main, que nunca se cancela (02-07).
  2. No ejecutar en borradores: un if: github.event.pull_request.draft == false evita el pipeline completo mientras el PR está en construcción.
  3. timeout-minutes en todos los jobs, porque un job colgado consume seis horas facturables antes de rendirse por defecto; y retención corta de artefactos, con limpieza periódica de las cachés antiguas.

Un criterio para las conversaciones de presupuesto: compara el coste del pipeline con el coste del tiempo del equipo. Cinco personas esperando dos minutos de más en cada una de cuarenta ejecuciones semanales son varias horas de trabajo cada semana, que valen bastante más que la diferencia entre un runner estándar y uno grande. La conclusión no siempre es gastar más, pero la comparación honesta es esa y casi nunca se hace.

  1. Escalar con el equipo: colas, merge queue y treinta personas

Un pipeline de 3 minutos para tres personas puede ser una espera de 25 minutos para treinta, sin que cambie una sola línea de YAML. Lo que cambia es la contención.

Con 3 personas Con 30 personas
Los jobs arrancan al instante Los jobs esperan por el límite de concurrencia del plan
main se rompe una vez al mes main se rompe varias veces por semana por PR incompatibles entre sí
Una prueba inestable molesta Una prueba inestable al 3 % falla varias veces al día y bloquea a todos
La caché acierta casi siempre La caché se invalida a diario: el lockfile cambia mucho más

Tres respuestas concretas, y un efecto poco intuitivo al final. La cola de ejecución es el primer límite que se toca: los planes tienen un máximo de jobs concurrentes por organización, y al alcanzarlo los jobs esperan aunque cada uno sea rapidísimo. Se detecta midiendo el tiempo de espera además del de ejecución —si crece, el problema no es tu YAML— y se resuelve subiendo el límite o reduciendo el número de jobs por ejecución. La merge queue de la 02-07 pasa de lujo a necesidad: con treinta personas fusionando el mismo día, la situación de dos PR verdes por separado que rompen main al juntarse ocurre varias veces por semana; la cola verifica cada PR contra el resultado de los anteriores, y su coste —más ejecuciones— es justo lo que se ahorra en main roto. Y las pruebas inestables dejan de ser una molestia y se convierten en un impuesto: una que falla el 3 % de las veces, con 100 ejecuciones diarias, produce tres relanzamientos al día; la política de cuarentena de la 02-04 —sacarla del camino bloqueante, con ticket, dueño y plazo— es lo que impide que el equipo aprenda a relanzar sin mirar. Y el efecto poco intuitivo: la caché acierta menos cuanto mayor es el equipo, porque el lockfile cambia más a menudo. Es otro argumento a favor de unos restore-keys bien pensados, que permiten aprovechar una caché parcial en lugar de perderlo todo por un byte.

  1. El riesgo transversal: el verde falso

Todas las optimizaciones comparten el mismo modo de fallo: el pipeline sigue en verde pero ya no comprueba lo que creemos, y es un fallo silencioso porque nadie investiga por qué el CI ha pasado.

Optimización Cómo produce un verde falso Cómo detectarlo
Caché de dependencias Clave sin hash del lockfile: se prueba con dependencias antiguas Imprimir la versión instalada de un paquete clave
Caché de compilación Se reutiliza un artefacto compilado de otro commit Comparar la fecha de los ficheros generados
Sharding Una partición no ejecuta nada por un patrón mal escrito Contar las pruebas ejecutadas frente al total
Ejecución selectiva El filtro no cubre un cambio que sí afectaba Ejecución nocturna completa y sin filtros
continue-on-error Un fallo real queda en amarillo y no bloquea Auditar los continue-on-error del repositorio

Las dos comprobaciones que más rendimiento dan, por si solo puedes implementar dos:

# 1 · El número total de pruebas ejecutadas no debe bajar nunca
TOTAL=$(jq '[.testResults[].assertionResults[]] | length' informe-*.json | paste -sd+ | bc)
[ "$TOTAL" -ge "$MINIMO_ESPERADO" ] || { echo "::error::solo $TOTAL pruebas ejecutadas"; exit 1; }
  1. Contar las pruebas y fallar si bajan. Es la defensa más barata y más eficaz que existe contra el verde falso: cubre el sharding mal configurado, el filtro que se saltó un paquete y el fichero de pruebas que dejó de descubrirse por un cambio de nombre. Cinco líneas de Bash que valen por una política entera. La segunda es el pipeline nocturno de la 04-01 ejecutado sin ninguna optimización: sin caché, sin filtros de ruta y con la suite completa. Es la ejecución de referencia. Si el nocturno falla y el CI de los PR está verde, la diferencia entre ambos es el fallo de tu optimización, y ese diagnóstico vale más que cualquier auditoría manual. Y una regla de higiene que resume el apartado: cada vez que optimices, ejecuta una vez el pipeline sin la optimización y compara los informes; si el número de pruebas, de ficheros analizados o de hallazgos detectados no coincide, no has optimizado nada, has dejado de comprobar cosas.

Errores Comunes y Consejos

Error 1: optimizar sin medir. Se ataca lo que se sospecha —las pruebas— y el tiempo estaba en docker build o en cuatro npm ci. Media hora de medición ahorra días de trabajo mal dirigido. Error 2: no anotar la línea base, con lo que luego no se puede demostrar la mejora ni detectar la regresión.

Error 3: clave de caché fija. key: npm-cache nunca se invalida: acabas probando contra dependencias de hace meses; la clave siempre deriva del contenido. Error 4: cachear node_modules/ en lugar del directorio de descargas, con binarios compilados para otra máquina. Error 5: paralelizar sin job agregador: los checks obligatorios se configuran por nombre, y al pasar de 3 a 5 particiones dos de ellas dejan de exigirse sin que nadie lo note.

Error 6: sharding excesivo, que multiplica el coste por la parte serie y apenas mejora el reloj (Amdahl). Error 7: filtros de ruta incompletos que no incluyen los paquetes internos de los que depende cada aplicación: la forma más directa de un verde falso. Error 8: runners autoalojados no efímeros, que acumulan estado y, si aceptan PR de forks, ejecutan código de desconocidos dentro de tu red.

Consejo 1: mide el tiempo de espera además del de ejecución. Si crece, el problema es de capacidad y no de tu YAML. Consejo 2: pon un presupuesto de tiempo por pipeline y trátalo como tal: para añadir tres minutos, busca de dónde quitarlos. Consejo 3: revisa las duraciones una vez al mes, que es la métrica que se degrada más silenciosamente. Consejo 4: cuenta las pruebas ejecutadas en cada ejecución, la defensa más barata contra el verde falso.

Ejercicios

Ejercicio 1

El CI de un equipo tarda 22 minutos y todo el mundo asume que "son las pruebas E2E". Describe cómo obtendrías el desglose real, qué tres hipótesis alternativas comprobarías primero y cómo decidirías por dónde empezar con un criterio de coste/beneficio.

Ejercicio 2

Un equipo activa ejecución selectiva con paths-filter. Dos semanas después, un cambio en packages/tipos-compartidos llega a producción y rompe la API, con el CI en verde durante todo el proceso. Explica exactamente qué falló, por qué la puerta de calidad no lo detuvo y qué tres medidas lo evitan.

Ejercicio 3

Reservalia debe elegir entre dos inversiones: (a) un runner de 16 vCPU para build, que baja docker build de 2:25 a 45 s y cuesta ×8 por minuto; (b) implementar la caché de capas en el registro, que lo baja a 40 s con caché caliente y lo deja en 2:25 en frío. Argumenta cuál elegir, en qué orden y en qué circunstancias cambiaría la respuesta.

Soluciones

Solución 1. Cómo medir: extraer con la API la duración por job y por step de las últimas cincuenta ejecuciones de main, calcular la mediana y la dispersión de cada step y ordenar por tiempo absoluto. Importa la mediana y no la media —una ejecución anómala distorsiona— e importa la dispersión, porque un step muy variable señala espera de red o contención, no trabajo real. Con eso se identifica el camino crítico: los jobs paralelos no suman, así que optimizar un job que no está en el camino crítico no reduce ni un segundo el reloj. Las tres hipótesis alternativas, por probabilidad: (1) la instalación de dependencias, repetida en cada job y sin caché o con la caché fallando —comprobable mirando si el log dice cache hit o cache miss—; (2) la construcción de la imagen, sin caché de capas o con un Dockerfile que copia el código antes de instalar, de modo que nunca acierta; (3) el tiempo de espera en cola, que no es ejecución en absoluto y se ve comparando run_started_at con el inicio real del primer job.

Cómo decidir: ordenar las intervenciones por (ahorro estimado ÷ esfuerzo) y ejecutarlas de arriba abajo, siempre empezando por lo que está en el camino crítico. En la práctica, la caché de dependencias y el orden del Dockerfile son casi siempre lo primero —horas de trabajo, minutos de ahorro por ejecución y riesgo bajo—; sacar los E2E del pipeline de PR al nocturno suele ser lo segundo; y solo entonces tiene sentido plantearse runners más grandes.

Solución 2. Lo que falló: el filtro del job de la API declaraba apps/api/** pero no incluía packages/tipos-compartidos/**. Al cambiar solo el paquete compartido, paths-filter decidió que la API no estaba afectada, el job se saltó y —por la propiedad del apartado 4— reportó éxito neutro. El PR mostró todos los checks en verde.

La puerta de calidad no lo detuvo precisamente porque funcionó como está diseñada: los checks obligatorios comprueban que el job tenga resultado success, y un job saltado lo tiene. La puerta no distingue "verificado y correcto" de "no verificado". Ese es el precio explícito de la ejecución selectiva, y por eso es la palanca de mayor riesgo de las cuatro. Las tres medidas: (1) completar el filtro para que todo consumidor incluya las rutas de sus dependencias internas, y añadir CODEOWNERS sobre .github/workflows/ para que un cambio en los filtros lo revise alguien que entienda el grafo; (2) sustituir el filtro escrito a mano por una herramienta que derive el grafo del código (Turborepo, Nx o los propios workspaces de npm), de modo que añadir un paquete no exija acordarse de nada; y (3) ejecutar en el pipeline nocturno la suite completa sin filtros, que habría detectado el problema en menos de 24 horas aunque las dos primeras medidas fallaran. Complementariamente, la comprobación del número de pruebas ejecutadas del apartado 9 habría dado la señal en el propio PR: pasar de 480 pruebas a 120 es un dato visible.

Solución 3. La respuesta correcta es (b) primero, y (a) probablemente nunca, por cuatro razones.

Coste. La caché es una configuración: se implementa en media hora y no añade un céntimo al minuto. El runner grande multiplica por ocho el coste de un job que se ejecuta en cada pull request y en cada push a main: unas 240 ejecuciones al mes en Reservalia. Resultado. Son equivalentes en el caso bueno —45 s frente a 40 s— y la caché gana en el caso típico, porque la mayoría de commits no tocan el lockfile ni las primeras capas del Dockerfile, que es justo cuando la caché acierta.

Compatibilidad. No son excluyentes, pero se aplican en orden: con la caché caliente, el trabajo restante de docker build es tan pequeño que un runner grande apenas lo mejora, así que optimizar primero y ampliar la máquina después evita pagar ×8 por trabajo que se podía no hacer. Riesgo. El del runner grande es solo económico; el de la caché es un verde falso si la clave está mal construida, y se mitiga con lo del apartado 9 y con una propiedad de las capas de Docker: se identifican por el hash de su contenido, así que su invalidación es criptográfica y no depende de ninguna convención.

Cuándo cambiaría la respuesta: si el docker build en frío estuviera en el camino crítico de algo urgente —un rollback que exige reconstruir—; si la construcción usara compilación nativa multinúcleo (Rust, C++, imágenes multiplataforma), donde 16 vCPU dan una mejora que ninguna caché da; o si el equipo publicara decenas de imágenes distintas y la caché acertara pocas veces. En Reservalia, con un solo servicio y commits pequeños, nada de eso ocurre.

Conclusión

El primer frente está cerrado, y con números: el CI de Reservalia ha pasado de 6 minutos a 3, sin eliminar ni una verificación. Lo consiguieron, en este orden, la caché —de dependencias por hash del lockfile, de compilación con restore-keys, y de capas de Docker exportada al registro en lugar de al almacén de GitHub, apoyada en un Dockerfile que copia los manifiestos antes que el código—; el sharding de la suite en tres particiones, con un job agregador de nombre fijo para que los checks obligatorios sigan exigiéndose; la ejecución selectiva basada en el grafo del monorepo, con paths-filter a nivel de job y el éxito neutro como pieza clave; y una decisión consciente sobre el runner, que en este caso fue no gastar más. Pero la lección de fondo no es ninguna de las cuatro palancas: es el método. Primero medir —por job y por step, sobre cincuenta ejecuciones, anotando la línea base—, porque la intuición señalaba las pruebas y el tiempo estaba en docker build y en cuatro npm ci repetidos. Después ordenar por coste/beneficio y aplicar de arriba abajo, siempre sobre el camino crítico. Y comprobar después de cada paso que se sigue verificando lo mismo, porque todas las optimizaciones comparten el mismo modo de fallo: el verde falso. Contar las pruebas ejecutadas y mantener un nocturno sin optimizaciones como ejecución de referencia son las dos defensas que cuestan casi nada y detectan casi todo. A ello se suma la mirada al coste —minutos, retención de artefactos, concurrency, borradores— y la previsión de lo que cambia cuando el equipo triplica: colas de ejecución, merge queue y pruebas inestables que pasan de molestia a impuesto.

Queda, sin embargo, un problema que ninguna optimización arregla y que esta lección ha agravado. El ci.yml tiene ya cinco jobs y, entre él, cd.yml, infra.yml, rollback.yml y nocturno.yml, el mismo bloque de checkout, setup-node y npm ci con su caché aparece seis veces copiado: cada mejora que hemos introducido hubo que aplicarla seis veces, y la próxima persona que toque una de ellas dejará las otras cinco atrás. Encima, el módulo 5 va a añadir una aplicación móvil y unos microservicios que necesitan exactamente lo mismo. La siguiente lección, Pipeline as Code: Plantillas, Reutilización y Pruebas del Pipeline, trata el pipeline como lo que es —código de producción— y le aplica lo que se le aplica al código: extraer lo repetido en composite actions y workflows reutilizables, versionarlo, revisarlo y, sobre todo, probarlo, porque hasta hoy la única forma que ha tenido Reservalia de probar un cambio de pipeline ha sido fusionarlo y mirar.

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