Un CI lento es una de esas cosas que desgastan a los equipos de ingeniería. Los ingenieros esperan más tiempo por comentarios, el cambio de contexto aumenta y la tentación de agrupar cambios crece, lo que solo empeora las cosas. Pasamos unas semanas optimizando nuestros flujos de trabajo de GitHub Actions y reducimos nuestras verificaciones de PR de 30 minutos a 5 minutos. Eso es una reducción del 83% en el tiempo de espera, logrado sin sacrificar ninguna cobertura de prueba ni puertas de calidad.
No hubo una solución mágica. Observamos a dónde iba realmente el tiempo y aplicamos tres principios: dependencias de trabajos inteligentes, almacenamiento en caché agresivo y fragmentación de pruebas. Esto es lo que aprendimos.
La Fundación: Migrando 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 la inestabilidad de CI. Esta vez, la migración fue sencilla (#26247): reemplazar los runners buildjet-*vcpu-ubuntu-2204 por blacksmith-*vcpu-ubuntu-2404 en 28 archivos de flujo de trabajo, cambiar las acciones cache y setup-node, y ajustar las asignaciones de vCPU. El único problema real que encontramos fue el problema de caché corrupto con los archivos .next (más sobre eso en la sección de caché).
Una vez que actualizamos, notamos una mejora inmediata de 2x en la velocidad para recuperar caché. Con yarn install siendo 1.2GB, eso ahorra alrededor de 22 segundos en cada tarea que necesita yarn install, que es la mayoría de ellas. También estamos aprovechando el caché de contenedores de Blacksmith para los servicios utilizados en muchos trabajos (Postgres, Redis, Mailhog). El trabajo Initialize containers ahora ha bajado más del 50%, de 20 segundos a 9 segundos.
Lo que tuvimos que arreglar
Los runners de Blacksmith exponen la cantidad de CPU de la máquina host en lugar de los vCPUs asignados (típicamente 4). Playwright, siendo útil, crearía trabajadores para igualar, causando contención de recursos y fallos de prueba inestables. Simplemente necesitábamos limitar explícitamente los trabajadores con --workers=4 para coincidir con nuestra asignación real.
También descubrimos que un mayor paralelismo exponía condiciones de carrera en nuestra lógica de limpieza de pruebas. Las pruebas de integración que funcionaban bien de forma secuencial colisionaban cuando se ejecutaban concurrentemente, produciendo errores de idempotencyKey. La solución requirió repensar cómo las pruebas se limpian a sí mismas: consultando las reservas por eventTypeId en lugar de rastrear IDs de reservas individuales, que se perdían las reservas creadas indirectamente por el código bajo prueba (#26269, #26278, #26283).
Incluso nuestras pruebas de captura de pantalla necesitaban ajustes (#26247). Diferentes runners tienen sutiles diferencias en el renderizado de fuentes, así que añadimos deviceScaleFactor: 1 para un renderizado consistente y aumentamos ligeramente nuestro umbral de diferencia de 0.05 a 0.07 para tener en cuenta las variaciones de suavizado sin enmascarar regresiones reales.
Moviéndose más rápido a menudo expone acoplamientos ocultos en tu sistema. El acoplamiento siempre estaba ahí; simplemente no podías verlo a velocidades más bajas. Arreglar estos problemas es lo que permite mejoras sostenibles en velocidad.
Principio 1: Acortar la Ruta Crítica con Diseño de Dependencias
Para nosotros, un gran problema era que el lint estaba bloqueando cosas que no debería bloquear. Cuando tus pruebas E2E tardan de 7 a 10 minutos, bloquearlas por 2-3 minutos de lint y verificaciones de tipo solo hace que todo el flujo de trabajo sea más largo. El lint terminará mucho antes que E2E, así que ¿por qué no dejarlos correr en paralelo?
Desacoplar suites de prueba no relacionadas
Nuestras pruebas de integración y la suite principal de E2E no necesitan la construcción de API v2, así que eliminamos esa dependencia (#26170). Por el contrario, las pruebas de E2E de API v2 no necesitan la construcción web principal. Cada suite de pruebas ahora depende solo de lo que realmente requiere, permitiendo el máximo paralelismo.
Consolidar trabajos previos
Teníamos trabajos separados para la detección de cambios en archivos (changes) y la verificación de etiquetas E2E (check-label). Cada trabajo tiene un costo de inicio: activar un runner, revisar el código, restaurar el contexto. Al fusionar estos en un solo trabajo de prepare, eliminamos sobrecargas redundantes (#26101). Fuimos más allá y trasladamos nuestros pasos de instalación de dependencias al trabajo de prepare también, ahorrando otros 20 segundos por flujo de trabajo al eliminar otra frontera de trabajo (#26320).
Hacer 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 flujo de trabajo separado activado por workflow_run (#26157). Ahora los ingenieros pueden volver a ejecutar trabajos fallidos de inmediato sin esperar a que se complete la generación de informes.
La regla es simple: sé flexible sobre dejar que los trabajos E2E comiencen a ejecutarse antes que después. No bloquees trabajos de larga duración en prerrequisitos de corta duración a menos que haya una dependencia genuina. El tiempo de reloj de tu CI está determinado por la ruta crítica, y cada dependencia innecesaria extiende esa ruta.
Principio 2: El Caché es Rey (Pero Solo Si Funciona)
Un caché efectivo requiere claves que se invaliden cuando deben, permanezcan estables cuando deben, y un almacén de caché que permanezca saludable con el tiempo.
Hacer uso de lookup-only
Nuestra mejora de caché más dramática vino de una simple observación: estábamos descargando aproximadamente 1.2GB de datos en caché incluso cuando no necesitábamos instalar nada. El caché existía, el golpe fue exitoso, pero aún estábamos pagando el costo de descarga antes de descubrir que podíamos omitir el paso de instalación.
La solución fue usar verificaciones de caché lookup-only (#26314). Tenemos pasos dedicados que se ejecutan antes de todos los trabajos para asegurar que las dependencias estén instaladas, así que para cuando se ejecuten trabajos individuales, sabemos que el caché existe. Antes de intentar restaurar el caché completo, primero verificamos si existe sin descargar. Si todos los cachés son exitosos, omitimos todo el flujo de restaurar e instalar. Sin descargas, sin instalaciones, solo procedemos al trabajo real. Esto solo ahorró tiempo significativo en cada escenario de impacto de caché, que, con un diseño de clave de caché adecuado, debería ser la mayoría de las ejecuciones.
Aplicamos la misma lógica a la instalación del navegador Playwright (#26060). Dado que tenemos un paso dedicado que asegura que los navegadores de Playwright están en caché antes de que se ejecuten los trabajos de prueba, podemos usar el mismo enfoque lookup-only. Si el caché de Playwright es exitoso, omitir la instalación por completo. Ahora verificamos primero, luego decidimos si descargar o no.
Extendimos este patrón a nuestro caché de base de datos también (#26344). Usando verificaciones lookup-only, podemos omitir la reconstrucción del caché de DB cuando ya existe. También simplificamos la clave de caché al eliminar el número de PR y SHA, haciéndola mucho más reutilizable a través de ejecuciones.
Corrección del caché
La corrección del caché es igual de importante que la velocidad del caché. Descubrimos que nuestro patrón glob **/node_modules/ estaba capturando apps/web/.next/node_modules/, que es la salida de construcción de Next.js que contiene symlinks y archivos generados que cambian durante las construcciones. Esto llevó a archivos de caché corruptos con errores de extracción de tar (#26268). La solución fue un simple patrón de exclusión, pero encontrarlo requirió entender exactamente qué estaba siendo almacenado en caché y por qué.
También implementamos limpieza automática del caché (#26312). Cuando un PR es fusionado o cerrado, ahora eliminamos los cachés de construcción asociados. Esto mantiene nuestro almacén de caché ágil y previene la acumulación de entradas obsoletas que nunca volverían a utilizarse. Simplificamos nuestro formato de clave de caché en el proceso, eliminando segmentos innecesarios como runner.os y node_version que añadían complejidad sin mejorar las tasas de aciertos.
Caché Remoto Turbo
Actualizamos a Next.js 16 (#26093), que reduces nuestras construcciones de más de 6 minutos a ~1 minuto (un saludo al equipo de Next.js por las enormes mejoras). Con el Caché Remoto Turbo habilitado, las construcciones subsecuentes tardan solo 7 segundos en obtenerse de la caché en comparación con el ~1 minuto para una construcción completa.
También habilitamos el Caché Remoto Turbo para nuestras pruebas E2E de API v2 (#26331). Anteriormente, cada fragmento de E2E volvería a construir los paquetes de la plataforma desde cero. Ahora cada fragmento se beneficia del caché remoto de Turbo, y optimizamos la compilación de TypeScript de Jest habilitando isolatedModules y deshabilitando diagnósticos en CI, lo que acelera significativamente el tiempo de inicio de las pruebas.
Principio 3: Escalar Pruebas con Sharding
Cuando tienes más de 1,100 casos de prueba en 82 archivos de prueba E2E, ejecutarlos secuencialmente es dejar rendimiento sobre la mesa. El sharding de pruebas, que divide tu suite de pruebas entre múltiples runners paralelos, es la manera más directa de reducir el tiempo de reloj para grandes suites de prueba. Ya teníamos sharding implementado para nuestra suite principal de E2E, pero lo aumentamos de 4 a 8 fragmentos (#26342), lo que redujo el tiempo total de la suite de E2E en otro minuto. Nuestras pruebas E2E de API v2 todavía se ejecutaban como un solo trabajo.
Dividimos nuestras pruebas E2E de API v2 en 4 trabajos paralelos usando la opción --shard incorporada de Jest (#26183). Cada fragmento se ejecuta de manera independiente con sus propios servicios de Postgres y Redis, y los artefactos se nombran de forma única por fragmento. Lo que antes tardaba más de 10 minutos como un solo trabajo ahora se completa significativamente más rápido con el trabajo distribuido entre cuatro runners.
Hacer sharding a gran escala también expone problemas de infraestructura que podrías no notar con una ejecución secuencial. Cuando múltiples trabajos intentan poblar el mismo caché de base de datos simultáneamente, obtienes condiciones de carrera. Solucionamos esto creando un trabajo dedicado setup-db que se ejecuta antes de todos los trabajos de prueba E2E e integración (#26171). Este único trabajo pobla el caché de la base de datos una vez, y todos los trabajos posteriores restauran desde él. Sin carreras, sin trabajo duplicado.
También creamos un flujo de trabajo dedicado para las pruebas unitarias de API v2, separándolas de la suite de pruebas principal (#26189). Esto permite que se ejecuten en paralelo con otras verificaciones en lugar de competir por recursos en un trabajo de prueba monolítico.
El Efecto Compuesto
Ninguno de estos cambios en aislamiento habría reducido nuestro tiempo de CI en un 83%. Las ganancias provienen de su combinación.
Dependencias inteligentes permiten que los trabajos comiencen antes. El caché efectivo significa que esos trabajos pasan menos tiempo en la configuración. El sharding significa que la ejecución real de la prueba ocurre en paralelo. Juntos, comprimen la ruta crítica desde todas las direcciones.
La inversión se compone con el tiempo. Cada PR ahora recibe retroalimentación más rápida. Los ingenieros permanecen en flujo por más tiempo. La tentación de agrupar cambios disminuye, lo que significa diferencias más pequeñas y más revisables. La calidad del código mejora porque el ciclo de retroalimentación es más ajustado.
Continuaremos invirtiendo en el rendimiento de CI porque los beneficios son reales y medibles. El objetivo no es la velocidad por sí misma; es permitir que los ingenieros envíen software de calidad más rápido. Cuando CI es rápido, moverse rápidamente y mantener altos estándares trabajan juntos en lugar de en contra de cada uno.
Pull Requests Referenciados
Fundación: Migración de Buildjet a Blacksmith
#26247 - Migrar flujos de trabajo de GitHub de Buildjet a Blacksmith
Estabilidad de Pruebas
#26269 - Arreglar pruebas e2e inestables con sesiones de usuario aisladas
#26278 - Estabilizar pruebas e2e con localizadores específicos y horarios deterministas
#26283 - Mejorar el aislamiento de pruebas para pruebas e2e de tipo de evento gestionado
Caché Remoto Turbo
#26093 - Actualizar a Next 16 (construcciones reducidas de más de 6 minutos a ~1 minuto)
#26331 - Habilitar Caché Remoto Turbo (las construcciones subsecuentes tardan 7s)
Dependencias de Trabajo y Optimización de Flujos de Trabajo
#26101 - Consolidar trabajos de cambios y verificación de etiquetas en la preparación
#26170 - Desacoplar pruebas no relacionadas de API v2 de la construcción de API v2
#26157 - Hacer que los trabajos de informes de E2E no bloqueen al moverlos a un flujo de trabajo separado
#26320 - Mover el trabajo de dependencias al paso de trabajo de preparación para ahorrar ~20s por flujo de trabajo
Hacer uso de lookup-only
#26314 - Usar verificación de caché lookup-only para omitir descargas de trabajos de dependencias
#26060 - Omitir instalación de Playwright en caso de éxito del caché
#26344 - Usar lookup-only para el caché de DB y simplificar la clave del caché
Corrección del caché
#26268 - Excluir .next/node_modules del caché de yarn para prevenir corrupción
#26312 - Eliminar entradas de caché-build al cerrar PR
Sharding de Pruebas e Infraestructura
#26342 - Aumentar los fragmentos principales de E2E de 4 a 8 (ahorra 1 minuto)
#26183 - Fragmentar pruebas E2E de API v2 en 4 trabajos paralelos
#26171 - Agregar un trabajo de setup-db dedicado para eliminar condiciones de carrera en el caché
#26189 - Crear nuevo flujo de trabajo para pruebas unitarias de API v2

¡Comienza con Cal.com gratis hoy!
Experimenta una programación y productividad sin problemas, sin tarifas ocultas. ¡Regístrate en segundos y comienza a simplificar tu programación hoy, sin necesidad de tarjeta de crédito!


