Cómo redujimos el tiempo total de nuestra CI de 30 minutos a 5 minutos
La CI lenta es una de esas cosas que pone de los nervios a los equipos de ingeniería. Los ingenieros esperan más para recibir feedback, aumenta el cambio de contexto y crece la tentación de agrupar cambios, lo que solo empeora las cosas. Pasamos unas semanas optimizando nuestros workflows de GitHub Actions y redujimos las comprobaciones de PR de 30 minutos a 5 minutos. Es una reducción del 83% en el tiempo de espera, conseguida sin sacrificar cobertura de pruebas ni calidad. No hubo una única solución mágica. Miramos dónde se iba realmente el tiempo y aplicamos tres principios: dependencias de trabajos inteligentes, caché agresiva y particionado de pruebas. Esto es lo que aprendimos.

La base: migrar a Blacksmith
De hecho, ya habíamos intentado migrar a Blacksmith antes, pero encontramos problemas. Con una rama main tan activa como la nuestra, no queríamos lidiar con inestabilidad en la CI. Esta vez la migración fue sencilla (#26247): sustituir los runners buildjet-*vcpu-ubuntu-2204 por blacksmith-*vcpu-ubuntu-2404 en 28 archivos de workflow, cambiar las acciones cache y setup-node, y ajustar las asignaciones de vCPU. El único problema real que encontramos fue el de la caché corrupta con archivos .next (más sobre eso en la sección de caché).
Una vez actualizamos, notamos de inmediato una mejora de velocidad de 2x al descargar la caché. Como yarn install pesa 1,2 GB, eso ahorra unos 22 segundos en cada trabajo que necesita ejecutar yarn install, que son la mayoría. También estamos aprovechando el cacheado de contenedores de Blacksmith para servicios que se usan en muchos trabajos (Postgres, Redis, Mailhog). El trabajo Initialize containers ahora se ha reducido en más de un 50%, de 20 segundos a 9 segundos.
jobs:
build:
name: Build Web App
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-build
Lo que tuvimos que arreglar
Los runners de Blacksmith exponen el número de CPU de la máquina anfitriona en lugar de las vCPU asignadas (normalmente 4). Playwright, muy servicial, levantaba workers para ajustarse a ese número, lo que provocaba contención de recursos y fallos intermitentes en las pruebas. Solo tuvimos que limitar los workers explícitamente con --workers=4 para que coincidieran con nuestra asignación real.
También descubrimos que una mayor paralelización sacaba a la luz condiciones de carrera en nuestra lógica de limpieza de pruebas. Las pruebas de integración que iban bien de forma secuencial chocaban al ejecutarse en paralelo y producían errores de idempotencyKey. La solución requirió replantear cómo limpian las pruebas después de ejecutarse: consultar las reservas por eventTypeId en lugar de seguir identificadores individuales de reserva, que se perdían reservas creadas indirectamente por el código bajo prueba (#26269, #26278, #26283).
Incluso nuestras pruebas de capturas de pantalla necesitaron ajustes (#26247). Distintos runners tienen pequeñas diferencias en el renderizado de fuentes, así que añadimos deviceScaleFactor: 1 para conseguir un renderizado consistente y aumentamos ligeramente el umbral de diferencias de 0.05 a 0.07 para tener en cuenta variaciones de antialiasing sin ocultar regresiones reales.
Ir más rápido a menudo saca a la luz acoplamientos ocultos en tu sistema. El acoplamiento siempre estaba ahí; solo que a menor velocidad no lo veías. Arreglar estos problemas es lo que permite mejoras de velocidad sostenibles.
Principio 1: acorta la ruta crítica con un buen diseño de dependencias
Para nosotros, un problema grande era que lint estaba bloqueando cosas que no debería bloquear. Cuando tus pruebas E2E tardan 7-10 minutos, bloquearlas por 2-3 minutos de lint y comprobaciones de tipos solo hace que todo el workflow sea más largo. El lint terminará mucho antes que las E2E, así que ¿por qué no dejarlas correr en paralelo?
Desacopla suites de pruebas no relacionadas
Nuestras pruebas de integración y la suite principal de E2E no necesitan la compilación de API v2, así que quitamos esa dependencia (#26170). A la inversa, las pruebas E2E de API v2 no necesitan la compilación principal de la web. Ahora cada suite de pruebas depende solo de lo que realmente necesita, lo que permite la máxima paralelización.
Consolida los trabajos de preflight
Teníamos trabajos separados para detectar cambios en archivos (changes) y comprobar la etiqueta de E2E (check-label). Cada trabajo tiene un coste de arranque: levantar un runner, hacer checkout del código, restaurar el contexto. Al fusionarlos en un único trabajo prepare, eliminamos costes redundantes (#26101). Fuimos más allá y movimos también los pasos de instalación de dependencias al trabajo prepare, ahorrando otros 20 segundos por workflow al eliminar otro límite entre trabajos (#26320).
Haz que los informes no bloqueen
Los informes de pruebas E2E son valiosos, pero no deberían estar en la ruta crítica. Movimos los trabajos merge-reports, publish-report y cleanup-report a un workflow separado activado por workflow_run (#26157). Ahora los ingenieros pueden volver a ejecutar los trabajos fallidos de inmediato sin esperar a que termine la generación del informe.
name: E2E Report
on:
workflow_run:
workflows: ["PR Update"]
types:
- completed
La regla es simple: sé flexible para que los trabajos E2E empiecen antes en lugar de después. No bloquees trabajos largos con prerequisitos cortos salvo que exista una dependencia real. El tiempo de pared de tu CI lo determina la ruta crítica, y cada dependencia innecesaria alarga esa ruta.
Principio 2: la caché es clave (pero solo si acierta)
Una caché eficaz requiere claves que invaliden cuando deben, se mantengan estables cuando deben y un almacén de caché que siga sano con el tiempo.
Aprovecha lookup-only
Nuestra mejora más espectacular en caché vino de una observación sencilla: estábamos descargando aproximadamente 1,2 GB de datos cacheados incluso cuando no necesitábamos instalar nada. La caché existía, el acierto era correcto, pero seguíamos pagando el coste de descarga antes de descubrir que podíamos saltarnos el paso de instalación.
La solución fue usar comprobaciones de caché lookup-only (#26314). Tenemos pasos dedicados que se ejecutan antes de todos los trabajos para asegurarnos de que las dependencias están instaladas, así que cuando los trabajos individuales se ejecutan ya sabemos que la caché existe. Antes de intentar restaurar la caché completa, primero comprobamos si existe sin descargarla. Si todas las cachés aciertan, nos saltamos todo el flujo de restauración e instalación. Sin descargas, sin instalaciones: pasamos directamente al trabajo real. Solo esto ya ahorró bastante tiempo en cada escenario de acierto de caché, que, con un buen diseño de claves de caché, deberían ser la mayoría de ejecuciones.
- name: Check yarn cache (lookup-only)
if: ${{ inputs.skip-install-if-cache-hit == 'true' }}
uses: actions/cache/restore@v4
id: yarn-download-cache-check
with:
path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
lookup-only: true
Aplicamos la misma lógica a la instalación del navegador de Playwright (#26060). Como tenemos un paso dedicado que garantiza que los navegadores de Playwright están cacheados antes de que se ejecuten los trabajos de prueba, podemos usar el mismo enfoque lookup-only. Si la caché de Playwright acierta, nos saltamos la instalación por completo. Ahora comprobamos primero y luego decidimos si descargar o no.
Llevamos este patrón también a nuestra caché de base de datos (#26344). Con comprobaciones lookup-only, podemos saltarnos la reconstrucción de la caché de la BD cuando ya existe. También simplificamos la clave de caché eliminando el número de la PR y el SHA, haciéndola mucho más reutilizable entre ejecuciones.
Corrección de la caché
La corrección de la caché importa tanto como su velocidad. Descubrimos que nuestro patrón glob **/node_modules/ estaba capturando apps/web/.next/node_modules/, que es output de compilación de Next.js con enlaces simbólicos y archivos generados que cambian durante las compilaciones. Eso provocaba archivos de caché corruptos con errores al extraer tar (#26268). La solución fue un patrón de exclusión sencillo, pero encontrarlo requirió entender exactamente qué se estaba cacheando y por qué.
También implementamos limpieza automática de la caché (#26312). Cuando una PR se fusiona o se cierra, ahora borramos sus cachés de compilación asociadas. Así mantenemos el almacén de caché ligero y evitamos la acumulación de entradas obsoletas que ya no volverán a usarse. En el proceso simplificamos el formato de nuestras claves de caché, eliminando segmentos innecesarios como runner.os y node_version, que añadían complejidad sin mejorar la tasa de aciertos.
delete-cache-build-on-pr-close:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: blacksmith-2vcpu-ubuntu-2404
env:
CACHE_NAME: prod-build
steps:
- name: Delete cache-build cache
uses: useblacksmith/cache-delete@v1
with:
key: ${{ env.CACHE_NAME }}-${{ github.event.pull_request.head.ref }}
prefix: "true"
Turbo Remote Caching
Actualizamos a Next.js 16 (#26093), lo que redujo nuestras compilaciones de más de 6 minutos a ~1 minuto (mención especial al equipo de Next.js por las enormes mejoras). Con Turbo Remote Caching activado, las compilaciones posteriores tardan solo 7 segundos en cargar desde la caché, frente al ~1 minuto de una compilación completa.
También activamos Turbo Remote Caching para nuestras pruebas E2E de API v2 (#26331). Antes, cada shard de E2E reconstruía los paquetes de la plataforma desde cero. Ahora cada shard se beneficia de la caché remota de Turbo, y optimizamos la compilación de TypeScript de Jest activando isolatedModules y deshabilitando los diagnósticos en CI, lo que acelera bastante el tiempo de arranque de las pruebas.
Principio 3: escala las pruebas con sharding
Cuando tienes más de 1.100 casos de prueba repartidos en 82 archivos de prueba E2E, ejecutarlos secuencialmente es dejar rendimiento sobre la mesa. El particionado de pruebas, que divide tu suite de pruebas entre varios runners en paralelo, es la forma más directa de reducir el tiempo total en suites grandes. Ya teníamos sharding en nuestra suite principal de E2E, pero lo aumentamos de 4 a 8 shards (#26342), lo que redujo otro minuto el tiempo total de la suite E2E. Nuestras pruebas E2E de API v2 seguían ejecutándose como un único trabajo.
Particionamos nuestras pruebas E2E de API v2 en 4 trabajos paralelos usando la opción integrada --shard de Jest (#26183). Cada shard se ejecuta de forma independiente con sus propios servicios de Postgres y Redis, y los artefactos se nombran de forma única para cada shard. Lo que antes tardaba más de 10 minutos como un solo trabajo ahora termina mucho más rápido al distribuir el trabajo entre cuatro runners.
- name: Run Tests
working-directory: apps/api/v2
run: |
yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }}
EXIT_CODE=$?
echo "yarn test:e2e:ci --shard=${{ matrix.shard }}/${{ strategy.job-total }} command exit code: $EXIT_CODE"
exit $EXIT_CODE
El sharding a gran escala también saca a la luz problemas de infraestructura que quizá no notas con una ejecución secuencial. Cuando varios trabajos intentan poblar la misma caché de base de datos al mismo tiempo, aparecen condiciones de carrera. Lo solucionamos creando un trabajo dedicado setup-db que se ejecuta antes de todos los trabajos de prueba E2E e integración (#26171). Este único trabajo rellena la caché de la base de datos una sola vez y todos los trabajos posteriores la restauran desde ahí. Sin carreras, sin trabajo duplicado.
También creamos un workflow dedicado para las pruebas unitarias de API v2, separándolas de la suite principal de pruebas (#26189). Así pueden ejecutarse en paralelo con otras comprobaciones en lugar de competir por recursos dentro de un único trabajo monolítico de pruebas.
El efecto acumulado
Ninguno de estos cambios por separado habría reducido el tiempo de nuestra CI en un 83%. La mejora viene de la combinación.
Las dependencias inteligentes permiten que los trabajos empiecen antes. Una caché eficaz significa que esos trabajos dedican menos tiempo a la preparación. El sharding hace que la ejecución real de las pruebas ocurra en paralelo. Juntos, comprimen la ruta crítica desde todos los ángulos.
La inversión se acumula con el tiempo. Ahora cada PR recibe feedback más rápido. Los ingenieros permanecen más tiempo en flujo. La tentación de agrupar cambios disminuye, lo que significa diffs más pequeños y más fáciles de revisar. La calidad del código mejora porque el ciclo de feedback es más ajustado.
Seguiremos invirtiendo en el rendimiento de la CI porque los resultados son reales y medibles. El objetivo no es la velocidad por sí misma; es permitir que los ingenieros entreguen software de calidad más rápido. Cuando la CI es rápida, avanzar deprisa y mantener estándares altos trabajan juntos en lugar de ir en contra uno del otro.
Pull requests citadas
Base: migración de Buildjet a Blacksmith
- #26247 - Migrar los workflows de GitHub de Buildjet a Blacksmith
Estabilidad de las pruebas
-
#26269 - Arreglar pruebas E2E inestables con sesiones de usuario aisladas
-
#26278 - Estabilizar pruebas E2E con localizadores acotados y horarios deterministas
-
#26283 - Mejorar el aislamiento de las pruebas E2E de managed scheduling link
Turbo Remote Caching
-
#26093 - Actualizar a Next 16 (compilaciones reducidas de más de 6 minutos a ~1 minuto)
-
#26331 - Activar Turbo Remote Caching (las compilaciones posteriores tardan 7 s)
Dependencias de trabajos y optimización de workflow
-
#26101 - Consolidar los trabajos changes y check-label en prepare
-
#26170 - Desacoplar las pruebas que no son de API v2 de la compilación de API v2
-
#26157 - Hacer que los trabajos de informes E2E no bloqueen al moverlos a un workflow separado
-
#26320 - Mover el trabajo deps al paso del trabajo prepare para ahorrar ~20 s por workflow
Aprovecha lookup-only
-
#26314 - Usar la comprobación de caché
lookup-onlypara saltarse las descargas del trabajo deps -
#26060 - Saltarse la instalación de Playwright cuando hay acierto de caché
-
#26344 - Usar
lookup-onlypara la caché de la BD y simplificar la clave de caché
Corrección de la caché
-
#26268 - Excluir .next/node_modules de la caché de yarn para evitar corrupción
-
#26312 - Borrar entradas de caché-build al cerrar PR
Particionado de pruebas e infraestructura
-
#26342 - Aumentar los shards de E2E principales de 4 a 8 (ahorra 1 minuto)
-
#26183 - Dividir las pruebas E2E de API v2 en 4 trabajos paralelos
-
#26171 - Añadir un trabajo dedicado setup-db para eliminar la condición de carrera de la caché
-
#26189 - Crear un nuevo workflow para las pruebas unitarias de API v2
