wandres.dev
CI/CD PARA FRONTEND · pipeline y deploy

Cachear en CI: dependencias y caché remota de tareas

Un runner de CI nace con el disco vacío en cada corrida, y sin cachés recomputa el mundo cada vez. Hay dos cachés distintas que atacar y no conviene confundirlas: la caché de dependencias, que evita volver a descargar el store de pnpm y se indexa por el hash del lockfile, y la caché remota de tareas de Turborepo o Nx, que evita volver a ejecutar build, test y lint y se indexa por el hash del contenido de sus entradas, con TURBO_TOKEN como credencial. Juntas convierten un runner frío en uno que reutiliza casi todo.

⏱ 17 min

El runner de CI tiene una propiedad incómoda: es efímero. Nace con el disco en blanco en cada corrida, sin node_modules, sin store de paquetes, sin artefactos de la vez anterior. Sin cachés, eso significa descargar todas las dependencias y recomputar todos los builds y tests desde cero en cada empujón, aunque el cambio fuera de una línea. La solución son dos cachés que atacan dos desperdicios distintos y que no conviene confundir: la caché de dependencias evita volver a descargar lo que ya se descargó, y la caché remota de tareas evita volver a ejecutar lo que ya se ejecutó. La primera se indexa por el lockfile; la segunda, por el hash del contenido de las entradas de cada tarea. Entender esa diferencia es lo que evita el error de esperar que una haga el trabajo de la otra.

🎯 Al terminar esta lección sabrás
  • Distinguir la caché de dependencias de la caché remota de tareas por lo que cada una evita.
  • Cachear el store de pnpm en CI indexado por el hash del lockfile.
  • Conectar la caché remota de Turborepo o Nx en un runner efímero con TURBO_TOKEN.
  • Diseñar claves de caché que ni sirvan resultados obsoletos ni fallen siempre.

Dos cachés que no hay que confundir

El error conceptual más caro en CI es tratar “cachear” como una sola cosa. Son dos, y atacan capas distintas del desperdicio. La caché de dependencias guarda lo que pnpm install descargó de la red: el store de paquetes. Su enemigo es el ancho de banda y el tiempo de resolución, y su clave natural es el lockfile, porque el lockfile determina por completo qué se instala. La caché de tareas guarda lo que build, test y lint produjeron: artefactos de salida y logs. Su enemigo es el cómputo, y su clave es el hash del contenido de todo lo que entra en la tarea —fuentes, configuración, dependencias—, no el lockfile.

flowchart TD
R[runner efimero arranca] --> DC[cache de deps por hash del lockfile]
DC -->|acierto| I[restaura el store sin descargar]
DC -->|fallo| N[pnpm install descarga y siembra]
I --> TC[cache remota de tareas por hash de inputs]
N --> TC
TC -->|acierto| RE[restaura build y test sin ejecutar]
TC -->|fallo| EX[ejecuta build test lint y sube]
style DC fill:#89b4fa,color:#11111b
style TC fill:#cba6f7,color:#11111b
style RE fill:#a6e3a1,color:#11111b

La confusión típica es esperar que la caché de dependencias acelere los builds, o que la de tareas evite instalar. No lo hacen: son ortogonales. Un runner puede acertar la caché de dependencias —no descarga nada— y aun así ejecutar todos los builds porque falló la de tareas; o al revés. El pipeline rápido las tiene las dos, encadenadas: primero restaura el store para no descargar, luego consulta la caché de tareas para no computar, y solo hace trabajo real en lo que ninguna de las dos cubrió.

La caché de dependencias de pnpm

pnpm tiene una arquitectura que la hace ideal para cachear: no copia paquetes dentro de cada node_modules, sino que mantiene un store global direccionado por contenido y enlaza desde él con enlaces duros. Cachear ese store, y no los node_modules, es lo correcto: el store es estable, compartido entre proyectos y se restaura rápido. La forma más limpia en GitHub Actions es delegar en setup-node, que conoce pnpm y calcula la clave a partir del lockfile por ti.

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: pnpm
- run: pnpm install --frozen-lockfile

La clave de esta caché es un hash de pnpm-lock.yaml. La consecuencia es exacta y deseable: mientras el lockfile no cambie, el store se restaura tal cual; en cuanto añades o subes una dependencia, el lockfile cambia, la clave cambia y la caché se rehace. Esto es justo lo que quieres, porque el lockfile es la definición precisa de qué debe haber en el store. Y --frozen-lockfile cierra el círculo: obliga a que la instalación respete el lockfile al pie de la letra y falle si no cuadra, de modo que CI nunca resuelve versiones por su cuenta ni introduce una deriva que la caché luego perpetuaría.

⚠️
Cachea el store, no node_modules

Es tentador cachear directamente node_modules, pero es un error con pnpm. Los node_modules de pnpm son un árbol de enlaces simbólicos y duros hacia el store; empaquetarlos y restaurarlos rompe esos enlaces o los duplica, y a menudo resulta más lento y más frágil que reinstalar desde un store cacheado. La unidad correcta de caché es el store direccionado por contenido, y la reconstrucción de node_modules a partir de él es tan barata que no merece cachearse. Deja que setup-node con cache: pnpm elija la ruta correcta del store por ti.

La caché remota de tareas: Turborepo y Nx

La caché de dependencias resuelve la descarga, pero el cómputo —construir, testear, lintar— sigue ocurriendo de cero en cada runner efímero, porque su disco nace vacío y la caché de tareas local no viaja con él. La respuesta es una caché remota: un almacén compartido, indexado por el hash del contenido de las entradas de cada tarea, del que cualquier máquina puede bajar un resultado que otra ya computó. En Turborepo se activa con dos variables de entorno, porque el runner no puede hacer un login interactivo.

    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    steps:
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build test lint

TURBO_TOKEN autentica al runner contra el almacén remoto y TURBO_TEAM identifica el ámbito cuya caché se comparte; el token vive en los secretos del repositorio y el equipo en una variable normal, porque no es secreto. Con esas dos presentes, cada turbo run consulta el almacén antes de ejecutar: si otro runner, o el portátil de un compañero, ya subió el artefacto para ese hash, lo descarga y reproduce sus logs en milisegundos en lugar de recomputar. Nx tiene el equivalente con Nx Cloud, donde nx connect enlaza el workspace y la misma idea de acierto remoto por hash aplica. En ambos, el runner efímero deja de nacer con la caché fría: aunque su disco esté vacío, pregunta a la red si el trabajo ya existe.

💡
Solo lectura para PRs de forks

La caché remota es estado compartido y mutable, y por tanto una superficie de ataque. Un PR desde un fork no confiable con permiso de escritura podría subir un artefacto envenenado bajo el hash de una tarea legítima, que luego cualquier runner restauraría como bueno. La defensa es el mínimo privilegio: los contextos no confiables leen la caché para acelerarse, pero no escriben en ella. Turborepo lo expresa con turbo run build --remote-cache-read-only en los jobs disparados por forks, y Nx Cloud con permisos de escritura acotados por rama.

Diseñar la clave: ni floja ni imposible

Toda caché vive o muere por su clave, y hay dos formas simétricas de arruinarla. Una clave demasiado floja —que ignora algo que sí afecta al resultado— sirve resultados obsoletos: acierta cuando no debería y restaura un artefacto que ya no corresponde a las entradas. Una clave demasiado apretada —que incluye algo irrelevante y volátil, como una marca de tiempo o el nombre del runner— no acierta nunca: cada corrida produce una clave nueva y la caché se convierte en almacenamiento inútil que solo escribes y jamás lees.

Síntoma Causa de la clave Corrección
Sirve resultados viejos falta una entrada relevante añádela al hash o a los inputs
Nunca acierta incluye algo volátil e irrelevante quítalo del hash o hazlo determinista
Acierta en local, falla en CI una entrada difiere entre entornos declara la variable o el archivo que cambia

El caso “acierta en mi máquina pero falla en CI” merece un nombre propio, porque es el más frecuente y su causa nunca es la red. Es que el hash difiere entre los dos entornos, y difiere porque alguna entrada no está declarada: una variable de entorno presente en tu shell y ausente en el runner, un archivo generado localmente que se cuela en las entradas, una versión de herramienta distinta. Comparar el hash de una corrida en seco en ambos entornos localiza la fuga en minutos.

turbo run build --dry=json    # el plan y los hashes, sin ejecutar
turbo run build --summarize   # cuantas tareas acertaron y cuantas corrieron

Este trabajo de afinar la clave es, en el fondo, un trabajo de empujar tus tareas hacia el determinismo. Una tarea cuya salida depende solo de sus entradas declaradas es memoizable con seguridad; una que incrusta la hora, un identificador aleatorio o el nombre de la máquina rompe la promesa de que un mismo hash implica un mismo resultado, y con ella la legitimidad de toda la caché. Buena parte de configurar bien el cacheado en CI no es tocar la caché, sino purgar el no-determinismo de lo que cacheas.

Cachear es no repetir, y no repetir es memoizar la flota entera

Detrás de las dos cachés hay un solo principio, y es el más antiguo de la optimización: no vuelvas a computar lo que ya computaste. Lo que cambia entre los niveles de una jerarquía de caché es solo el alcance de ese “ya”: la caché de tu disco recuerda lo que tú hiciste hoy; la caché remota recuerda lo que cualquiera del equipo, en cualquier máquina, ha hecho jamás. Subir un peldaño en ese alcance es ampliar la memoria de un individuo a la de una flota, y esa ampliación es exactamente lo que convierte un CI que recomputa el mundo en cada empujón en uno que solo evalúa lo genuinamente nuevo. Pero el peldaño trae consigo una lección que la caché local escondía. Cuando la memoria era solo tuya, su corrección era trivial: nadie más escribía en ella y su clave solo tenía que distinguir tus propias corridas. En cuanto la memoria es compartida y mutable, dos propiedades que eran gratis se vuelven el problema central. La primera es la exactitud de la clave: como muchas máquinas indexan el mismo almacén por el mismo hash, una clave floja ya no te miente solo a ti, le miente a todo el equipo a la vez, propagando un resultado obsoleto por toda la flota. La segunda es la autorización: quién puede escribir en una memoria que muchos van a leer y a ejecutar, porque el output de un build no es un dato inerte sino código que otras máquinas correrán. Por eso el cacheado maduro en CI no es teclear cache: pnpm y un token, que lo hace cualquiera, sino entender que has creado un recurso compartido cuya velocidad y cuya seguridad son la misma decisión vista dos veces: la clave que lo hace útil es la clave que, si se relaja, lo hace peligroso. No repetir trabajo es la meta; hacerlo con una clave exacta y una escritura autorizada es lo que separa una caché que acelera a toda la flota de una que la contamina a toda a la vez.

⚔️ Encadena las dos cachés
  1. Añade setup-node con cache: pnpm a tu pipeline y comprueba en dos corridas seguidas que la segunda no vuelve a descargar el store.
  2. Cambia una dependencia en package.json, reinstala y observa cómo el cambio del lockfile invalida y rehace la caché de dependencias.
  3. Configura TURBO_TOKEN en secretos y TURBO_TEAM en variables, y verifica en el resumen que las tareas se resuelven por acierto remoto.
  4. Provoca un “acierta en local, falla en CI” comparando el hash de turbo run build --dry=json en tu máquina y en el runner, y localiza la entrada que difiere.
  5. Añade --remote-cache-read-only al job que corre en PRs de forks y explica qué ataque concreto previene.