wandres.dev
CI/CD · automatizar el ciclo

GitHub Actions con Gradle: el runner, las cachés y el tiempo real de una build limpia

Entre el flujo de trabajo declarado en YAML y los minutos que tarda una compilación de Android hay una máquina concreta con cuatro núcleos, un disco efímero y ninguna memoria de lo ocurrido ayer. Esta lección desmonta el modelo de ejecución del runner, separa las tres cachés que la gente confunde bajo el mismo nombre, explica por qué la caché de configuración y la de tareas se invalidan por motivos distintos, cuantifica el reparto real del tiempo en una build limpia y examina cuándo un runner mayor o una caché remota compensan su coste.

⏱ 22 min

El primer choque con la realidad de la integración continua en Android tiene siempre la misma forma. Alguien copia un flujo de trabajo de la documentación, lo empuja, y descubre que una compilación que en su portátil tarda cuarenta segundos tarda once minutos en la nube. La reacción instintiva es sospechar del proveedor, cuando lo que ha ocurrido es mucho más simple y mucho más instructivo: la máquina remota no tiene el daemon caliente, no tiene el repositorio local poblado, no tiene resultados de tareas anteriores, no tiene el modelo del proyecto ya configurado, no tiene índices de compilación incremental y probablemente tiene menos núcleos que el portátil. Once minutos no es una anomalía: es lo que cuesta de verdad construir el proyecto desde cero, y el tiempo del portátil era la excepción, no la norma.

🎯 Al terminar esta lección sabrás
  • Describir el modelo de ejecución del runner y qué se pierde exactamente entre dos ejecuciones.
  • Distinguir la caché de dependencias, la caché de tareas y la caché de configuración por lo que guardan y por lo que las invalida.
  • Cuantificar el reparto de tiempo de una build limpia y localizar el término dominante.
  • Decidir con criterio cuándo compensa un runner mayor, una caché remota o una matriz de trabajos.

El runner: una máquina desechable sin memoria

Un trabajo de GitHub Actions se ejecuta en una máquina virtual que nace al empezar y se destruye al terminar. El runner estándar de Linux ofrece cuatro núcleos virtuales, dieciséis gigabytes de memoria y un disco efímero, con una imagen que ya trae varias distribuciones de la máquina virtual de Java, el SDK de Android en una versión concreta y una colección de herramientas preinstaladas. Esa preinstalación es la razón de que la acción de preparación del entorno sea rápida cuando pides una versión que la imagen ya trae y sorprendentemente lenta cuando pides una que debe descargar.

Merece la pena fijar también la versión concreta de la imagen en lugar de aceptar la etiqueta genérica más reciente, porque esa etiqueta se actualiza sin aviso y cualquier cambio en las herramientas preinstaladas puede alterar tanto los tiempos como los resultados.

Lo importante no es la potencia sino la amnesia. Entre dos ejecuciones no sobrevive absolutamente nada del sistema de archivos, y esto tiene cuatro consecuencias concretas que juntas explican la práctica totalidad de la diferencia con el portátil. La primera es que el daemon de gradle arranca en frío, paga el coste de iniciar la máquina virtual y no tiene ninguna de las optimizaciones que el compilador acumula durante minutos de trabajo sostenido. La segunda es que el repositorio local de artefactos está vacío y hay que descargar cada dependencia transitiva desde la red. La tercera es que no existen resultados previos de ninguna tarea, de modo que todo se compila. La cuarta, la más olvidada, es que la configuración del proyecto se evalúa entera, y en un proyecto de cincuenta módulos eso solo ya cuesta más de lo que la mayoría de la gente sospecha.

Conviene añadir un matiz sobre la variabilidad, porque desconcierta a quien mide una sola vez. El runner es una máquina virtual compartida cuyo rendimiento de disco y de red no es constante, y dos ejecuciones idénticas del mismo commit pueden diferir en un veinte por ciento sin que haya cambiado nada. Cualquier conclusión sobre si una optimización funcionó debe apoyarse en la mediana de varias ejecuciones, no en una comparación de dos números. Es el error metodológico más frecuente en las discusiones sobre tiempos de compilación.

La aritmética del paralelismo también cambia. Cuatro núcleos limitan el número de trabajadores útiles, y configurar más procesos en paralelo que núcleos disponibles suele empeorar el resultado porque multiplica la presión de memoria y provoca recolecciones de basura. Conviene fijar de forma explícita la memoria de la máquina virtual y el número de trabajadores en lugar de dejar que la heurística por defecto decida en función de una máquina que no es la tuya.

- uses: actions/setup-java@v4
  with:
    distribution: temurin
    java-version: '17'

- uses: gradle/actions/setup-gradle@v4
  with:
    cache-read-only: ${{ github.ref != 'refs/heads/main' }}

- run: ./gradlew assembleDebug testDebugUnitTest
  env:
    GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx5g -Dorg.gradle.workers.max=4
📦

Caché de dependencias

El repositorio local de artefactos descargados. Se invalida cuando cambian los ficheros de declaración de dependencias.

🧱

Caché de tareas

Salidas de tareas indexadas por una huella de sus entradas. Sobrevive entre ramas y entre máquinas distintas.

🧠

Caché de configuración

El grafo de tareas ya calculado. Evita reevaluar los scripts, pero es la más frágil de las tres.

🌐

Caché remota

Un servidor compartido de salidas de tareas. Convierte el trabajo de un compañero en tiempo ahorrado para todos.

Tres cachés que la gente confunde bajo un solo nombre

Decir que hay que cachear gradle no significa nada porque hay al menos tres cosas distintas que se pueden cachear, con mecanismos, claves de invalidación y beneficios completamente diferentes. Confundirlas produce configuraciones que guardan gigabytes inútiles y no ahorran nada.

La caché de dependencias guarda el repositorio local de módulos descargados. Es la más fácil de acertar y la que menos ahorra en proyectos con red rápida, aunque su impacto crece con el número de repositorios remotos configurados y con la latencia de cada uno. Su clave debe derivar del contenido de los ficheros que declaran dependencias, típicamente el catálogo de versiones y los ficheros de compilación, y no de una constante fija, porque una clave fija produce una caché que crece sin límite y que nunca refleja el estado actual.

La caché de tareas guarda las salidas de cada tarea indexadas por una huella criptográfica de todas sus entradas: ficheros de origen, dependencias, opciones del compilador, versión del plugin y propiedades del sistema declaradas como entrada. Es la que produce el ahorro grande, porque permite que una tarea cuyas entradas no han cambiado se resuelva copiando su salida en lugar de recalcularla, incluso si esa salida se produjo en otra rama o en otra máquina. Su enemigo son las tareas mal declaradas, aquellas que leen algo que no anunciaron como entrada o escriben algo que no anunciaron como salida, porque envenenan la caché con resultados que no corresponden.

Hay además un cuarto candidato que la gente olvida y que suele ser el más rentable de todos en proyectos con Compose: la caché de los datos transformados de las dependencias, es decir, el resultado de convertir cada biblioteca al formato que el empaquetador consume. Ese trabajo es determinista, caro y completamente independiente de tu código, de modo que su tasa de aciertos es cercana al cien por cien en cuanto se conserva entre ejecuciones. Vive dentro del directorio de trabajo de gradle y se pierde si la clave de la caché de dependencias se define de forma demasiado estrecha.

La caché de configuración es distinta en naturaleza: no guarda salidas sino el grafo de tareas ya construido, evitando volver a ejecutar los scripts de compilación. En proyectos grandes ahorra decenas de segundos en cada invocación, pero se invalida ante cualquier cambio en los ficheros de compilación, ante el uso de valores del entorno no declarados y ante plugins que no la soportan. Merece activarse, y merece tratarse como una optimización que a veces no aplica en lugar de como una garantía.

⚠️
La caché de escritura solo desde la rama principal

Un error frecuente es permitir que cualquier pull request escriba en la caché compartida. El resultado es que una rama con dependencias experimentales contamina la entrada que usarán todos los demás, y que el límite de almacenamiento del repositorio se consume con estados efímeros que nadie volverá a usar. La configuración correcta es que la rama principal escriba y todo lo demás lea, lo que se expresa condicionando el modo de solo lectura a la referencia que dispara el flujo. El coste es que la primera compilación tras un cambio de dependencias en una rama de trabajo será lenta; el beneficio es que la caché compartida siempre refleja un estado que alguien revisó.

Conviene finalmente recordar que el almacenamiento de caché de la plataforma tiene un límite por repositorio y una política de expulsión por antigüedad de uso. Una configuración descuidada que genere una entrada nueva por cada rama termina desalojando precisamente la entrada de la rama principal, que era la única realmente valiosa. La estrategia sana consiste en una clave primaria derivada del contenido relevante y una clave de recuperación parcial más laxa, de modo que una rama nueva parta del estado más reciente de la principal en lugar de partir de cero.

📝
Una caché no acelera, evita trabajo

La distinción parece pedante y explica muchas decepciones. Una caché no hace que la compilación sea más rápida: hace que ciertas tareas no se ejecuten. Por tanto su beneficio máximo teórico es exactamente el tiempo de las tareas que consigue saltarse, y ni un segundo más. Si el camino crítico está dominado por el arranque del entorno, la resolución de red y la ejecución de pruebas que siempre corren, ninguna caché de tareas lo mejorará por buena que sea su configuración. Antes de invertir horas en afinarla conviene calcular ese techo con el informe de perfilado en la mano.

El reparto real del tiempo en una build limpia

Conviene tener números, aunque sean aproximados, porque la intuición falla sistemáticamente. En un proyecto Android de tamaño medio, digamos entre quince y treinta módulos con Kotlin y Compose, sobre un runner estándar de cuatro núcleos y sin ninguna caché, el reparto típico de una compilación de la variante de depuración con pruebas unitarias se acerca a esto: entre uno y dos minutos de preparación del entorno y arranque del daemon, entre uno y tres minutos de resolución y descarga de dependencias, entre treinta segundos y minuto y medio de configuración del proyecto, entre cuatro y ocho minutos de compilación de Kotlin y procesamiento de anotaciones, entre uno y tres minutos de empaquetado de recursos y generación del artefacto, y entre uno y cuatro minutos de ejecución de pruebas unitarias. El total honesto se sitúa entre nueve y veinte minutos.

El término dominante es casi siempre la compilación de Kotlin, y dentro de ella el procesamiento de anotaciones cuando el proyecto todavía usa el procesador antiguo en lugar del generador de simbolos moderno. Migrar ese punto concreto suele producir un ahorro mayor que cualquier ajuste de caché, porque elimina una etapa completa que obliga a generar código Java intermedio. Es el ejemplo canónico de que optimizar la infraestructura antes de optimizar el trabajo que hace es empezar por el final.

Las pruebas de instrumentación merecen una nota aparte porque su coste no encaja en el reparto anterior. Arrancar un dispositivo virtual en el runner añade entre dos y cinco minutos solo de inicio, antes de ejecutar una sola prueba, y ese coste es fijo e insensible a cuántas pruebas haya. La modalidad de dispositivos gestionados por el propio sistema de compilación mejora la situación porque permite cachear la imagen del dispositivo ya arrancada, pero conviene medir el resultado en lugar de asumirlo: en runners sin virtualización acelerada la ganancia puede evaporarse.

Con las cachés bien configuradas y una rama que no toca dependencias, ese mismo proyecto baja a un rango de tres a seis minutos, y el reparto cambia de forma: la compilación deja de dominar porque la mayoría de los módulos se resuelven desde la caché de tareas, y pasan a dominar la preparación del entorno y la restauración de la propia caché, que no es gratuita porque implica descargar y descomprimir cientos de megabytes. Ahí aparece un efecto contraintuitivo que conviene medir: en proyectos pequeños, restaurar la caché puede costar más que compilar desde cero.

flowchart LR
A[Inicio del job] --> B[Preparar entorno y JDK]
B --> C[Restaurar cache de dependencias]
C --> D[Arranque en frio del daemon]
D --> E[Configuracion del proyecto]
E --> F{Cache de tareas disponible}
F -- Si --> G[Copiar salidas ya calculadas]
F -- No --> H[Compilar Kotlin y procesar anotaciones]
H --> I[Empaquetar recursos y generar artefacto]
G --> I
I --> J[Ejecutar pruebas unitarias]
J --> K[Guardar cache si es rama principal]
style H fill:#f38ba8,color:#11111b
style G fill:#a6e3a1,color:#11111b

Runners mayores, cachés remotas y el punto de rentabilidad

Cuando el presupuesto de tiempo no se cumple hay tres palancas, y conviene tirar de ellas en orden de coste creciente. La primera es reducir el trabajo: eliminar módulos innecesarios del grafo, sustituir el procesador de anotaciones antiguo, desactivar la generación de variantes que nadie usa en el pull request y limitar la compilación a la variante estrictamente necesaria. Esta palanca es gratuita y casi siempre está sin usar.

Dentro de esa primera palanca hay un candidato casi siempre desatendido: la generación de código. Cada procesador de anotaciones o de símbolos añade una etapa completa al grafo, y su coste no es proporcional al código que genera sino al número de módulos que lo activan. Auditar qué módulos aplican realmente el plugin de inyección de dependencias, cuáles lo heredan de una convención copiada y cuáles no generan nada suele revelar que la mitad del proyecto paga por un trabajo que no usa.

La segunda es la caché remota compartida, que convierte el trabajo de compilación de un miembro del equipo en tiempo ahorrado para todos los demás, incluida la máquina de integración. Su rentabilidad depende de la tasa de aciertos, que a su vez depende de que las tareas estén correctamente declaradas y de que el entorno sea reproducible: si la versión de la máquina virtual de Java difiere entre el portátil y el runner, las huellas no coinciden y la tasa de aciertos se desploma sin que nadie entienda por qué. Merece la pena medir esa tasa antes de invertir en el servidor.

Existe además una palanca intermedia que suele pasarse por alto: repartir el trabajo en varios trabajos paralelos en lugar de comprar una máquina mayor. Dos runners estándar ejecutando en paralelo la compilación y las pruebas cuestan lo mismo en minutos facturados que uno haciendo ambas cosas en serie, pero reducen a la mitad el tiempo percibido siempre que las dos mitades sean realmente independientes. La condición es que compartan caché eficazmente, porque si cada trabajo recompila el mismo conjunto de módulos el ahorro desaparece y el gasto se duplica.

La tercera es pagar por máquinas mayores. Un runner de ocho o dieciséis núcleos reduce el tiempo de compilación de forma casi proporcional mientras haya paralelismo disponible en el grafo de tareas, y deja de ayudar en cuanto el camino crítico está formado por módulos encadenados. Ese es el matiz que decide la inversión: en un proyecto con un módulo gigante del que todo depende, duplicar los núcleos no cambia casi nada, porque ese módulo se compila en serie consigo mismo. Antes de pagar conviene mirar el grafo de dependencias entre módulos y preguntarse si es ancho o es profundo.

# Medir la tasa de aciertos de la cache de tareas en local
./gradlew clean assembleDebug --build-cache --scan

# Ver el grafo de dependencias entre modulos y su profundidad
./gradlew :app:dependencies --configuration debugRuntimeClasspath

# Comprobar si la cache de configuracion se esta invalidando y por que
./gradlew assembleDebug --configuration-cache --configuration-cache-problems=warn
ℹ️
Fijar la versión de todo o renunciar a la caché

La reproducibilidad no es un ideal abstracto sino la condición para que la caché funcione. La versión de la máquina virtual de Java, la del envoltorio de gradle, la del plugin de Android y la de las herramientas de compilación forman parte de la huella de las tareas. Cualquiera de ellas que se resuelva de forma flotante convierte cada actualización silenciosa de la imagen del runner en una invalidación total de la caché. Fijar versiones exactas y verificar la suma de comprobación del envoltorio no es solo higiene de seguridad: es la diferencia entre una tasa de aciertos del ochenta por ciento y una del diez.

La build limpia es la medida honesta de tu proyecto

Existe una tendencia natural a considerar el tiempo de la compilación incremental como el tiempo real del proyecto, porque es el que uno experimenta a diario y el que aparece en las demostraciones. Es una ilusión cómoda y profundamente engañosa. El tiempo incremental mide lo que cuesta rehacer una parte diminuta de un estado que ya existe, y depende de una acumulación de trabajo previo que nadie contabiliza. El tiempo de la build limpia mide otra cosa: cuánto trabajo hay realmente en el proyecto, sin descuentos, sin memoria y sin la ayuda de nada. Por eso la integración continua es tan incómoda al principio y tan valiosa después, porque es el único lugar donde el proyecto se enfrenta a su propio coste sin trampas. Un equipo que solo mira el tiempo incremental puede añadir módulos, procesadores de anotaciones y generación de código durante dos años sin percibir ninguna degradación, hasta que alguien nuevo clona el repositorio y descubre que la primera compilación tarda media hora. Ese número, el de la primera compilación, es la medida honesta de la complejidad acumulada, y merece vigilarse como se vigila el tamaño del binario o la cobertura de pruebas. La consecuencia interesante es que la integración continua deja de ser solo un mecanismo de verificación y se convierte en un instrumento de medición continua de la salud estructural del proyecto: cada ejecución es un experimento reproducible sobre el coste total del código, ejecutado sobre hardware conocido, con condiciones iniciales conocidas y resultado registrado. Los equipos que aprovechan esa propiedad grafican el tiempo de build limpia a lo largo de los meses y tratan sus pendientes como tratarían una regresión de rendimiento en tiempo de ejecución. Los que no, descubren el problema el día en que ya es caro arreglarlo.

⚔️ Mide y desmonta tu propia build
  1. Ejecuta una compilación completamente en frío en el runner, sin ninguna caché, y anota el desglose por fase leyendo el informe de perfilado.
  2. Repite con caché de dependencias solamente y después con caché de tareas también, y calcula el ahorro marginal de cada una por separado.
  3. Comprueba la tasa de aciertos de la caché de tareas y busca las tres tareas que nunca aciertan, investigando qué entrada no declarada las invalida.
  4. Sustituye el procesador de anotaciones antiguo por el generador moderno en un módulo y mide la diferencia sobre la build limpia.
  5. Dibuja el grafo de dependencias entre módulos y decide, con ese dibujo delante, si duplicar los núcleos del runner te compensaría.