DEV Community

Cover image for pnpm workspaces: el caché de CI que sobrevivió al fix y me costó 40 minutos de build
Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

pnpm workspaces: el caché de CI que sobrevivió al fix y me costó 40 minutos de build

pnpm workspaces: el caché de CI que sobrevivió al fix y me costó 40 minutos de build

Terminé el post anterior convencido de que el monorepo andaba. Tests en verde, deploy exitoso, pnpm workspaces configurado como la documentación dice. Me fui a dormir contento.

Al día siguiente revisé el tercer run de CI y vi esto en los logs:

Cache not found for input keys: node-modules-cache-abc123
Run pnpm install --frozen-lockfile
...
Progress: resolved 847, reused 0, downloaded 847, added 847
Enter fullscreen mode Exit fullscreen mode

reused 0. Ochocientos cuarenta y siete paquetes descargados de cero. Cuarenta minutos de build donde deberían ser ocho.

Mi tesis, antes de entrar al detalle: el caché de pnpm en GitHub Actions no funciona out-of-the-box con monorepos. No porque pnpm esté roto — pnpm es excelente, lo digo sin ambigüedad — sino porque el store-dir en CI tiene un comportamiento distinto al local que la mayoría no configura explícitamente. Y esa diferencia invisible destruye cualquier estrategia de caché que no la tenga en cuenta.


El problema real: pnpm store-dir en CI no es donde pensás

Cuando corrés pnpm install en tu máquina, el store global está en ~/.local/share/pnpm/store (Linux) o ~/Library/pnpm/store (macOS). Todos los proyectos del sistema comparten ese store: si un paquete ya existe, pnpm lo linkea con hard links. Instantáneo.

En GitHub Actions, el runner arranca limpio con cada ejecución. No hay un store previo. Entonces pnpm tiene dos comportamientos posibles:

  1. Sin configuración explícita: pnpm elige una ruta dinámica para el store — a veces dentro del workspace, a veces en un temp dir del runner. El path cambia entre runners y entre runs.
  2. Con --store-dir explícito: pnpm siempre usa exactamente esa ruta. Podés cachear esa ruta con actions/cache y recuperarla en el próximo run.

El problema con el caso 1 es que actions/cache necesita un path fijo para funcionar. Si el path del store varía, el restore nunca hace match aunque el key sea idéntico. El caché existe en S3 de GitHub, pero nunca se restaura porque pnpm busca en otro directorio.

Esto es exactamente lo que muestra la documentación oficial de pnpm para CI — pero está enterrado en la sección de configuración avanzada, no en el quickstart que todos copian.


El YAML antes del fix: lo que copiaba todo el mundo

Este era el workflow que tenía, armado a partir de varios tutoriales:

# workflow ANTES — caché roto en monorepo
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          # ⚠️ cache: 'pnpm' acá parece que hace algo, pero no configura el store-dir
          cache: 'pnpm'

      - name: Instalar dependencias
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm run build
Enter fullscreen mode Exit fullscreen mode

El cache: 'pnpm' en setup-node cachea node_modules a nivel de proyecto raíz. En un monorepo con workspaces, eso es insuficiente: cada package tiene su propio node_modules con symlinks al store global. Si el store no se restaura correctamente, los symlinks apuntan a la nada y pnpm reinstala todo.

El cache miss en los logs se veía así:

##[group]Cache not found
  Key: node-modules-pnpm-store-Linux-abc1234def5678
  Restore keys attempted:
    node-modules-pnpm-store-Linux-
    node-modules-pnpm-store-
  Cache Size: ~0 B
##[endgroup]
Enter fullscreen mode Exit fullscreen mode

Tamaño de caché restaurado: cero bytes. Cada run partía de cero.


El YAML después: store-dir explícito y hash por workspace

La solución requiere tres cambios concretos:

# workflow DESPUÉS — caché que realmente funciona en monorepo
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

    env:
      # Path fijo del store — crítico para que actions/cache encuentre siempre lo mismo
      PNPM_STORE_PATH: ~/.pnpm-store

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          # Sin cache: 'pnpm' acá — lo manejamos manualmente abajo

      - name: Obtener path del store de pnpm
        id: pnpm-cache
        run: |
          # Forzamos el store-dir explícito para que el path sea predecible
          pnpm config set store-dir $PNPM_STORE_PATH
          echo "store-path=$PNPM_STORE_PATH" >> $GITHUB_OUTPUT

      - name: Restaurar caché del store de pnpm
        uses: actions/cache@v4
        with:
          path: ${{ steps.pnpm-cache.outputs.store-path }}
          # Key con hash del lockfile — invalida cuando cambian dependencias
          key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
          # Restore key más amplia por si el lockfile cambió parcialmente
          restore-keys: |
            pnpm-store-${{ runner.os }}-

      - name: Instalar dependencias
        run: pnpm install --frozen-lockfile

      - name: Build workspaces
        run: pnpm run -r build

      - name: Tests
        run: pnpm run -r test
Enter fullscreen mode Exit fullscreen mode

El cambio crítico está en tres lugares:

1. PNPM_STORE_PATH como variable de entorno fija. Sin esto, cada runner elige su propio path. Con esto, el store siempre vive en ~/.pnpm-store y actions/cache sabe exactamente qué restaurar.

2. pnpm config set store-dir antes del install. No alcanza con definir la variable de entorno: hay que decirle explícitamente a pnpm que use ese path. Esta es la línea que faltaba en el 90% de los ejemplos que encontré.

3. `hashFiles('/pnpm-lock.yaml').** El es importante. En un monorepo podés tener lockfiles por workspace además del raíz. Con /pnpm-lock.yaml el key de caché cambia si cualquier lockfile del repo cambia. Con solo pnpm-lock.yaml` te perdés cambios en workspaces anidados.


Los gotchas que nadie documenta

El restore-keys amplio puede hacer más daño que bien

Con restore-keys: pnpm-store-${{ runner.os }}- le decís a GitHub Actions "si no encontrás el key exacto, usá el caché más reciente que matchee este prefijo". Suena razonable. El problema es que un store parcialmente restaurado (de un lockfile diferente) puede causar conflictos sutiles donde pnpm cree que un paquete está instalado pero le falta una dependencia transitiva.

Mi solución: usar el restore-key amplio solo para reducir el tiempo de descarga inicial, pero siempre correr pnpm install --frozen-lockfile después. El --frozen-lockfile garantiza consistencia aunque el store esté parcialmente stale.

pnpm run -r build no respeta el orden de dependencias entre workspaces por default

Si el package apps/web depende de packages/ui, necesitás que packages/ui se buildee primero. pnpm run -r build corre en paralelo por default. La solución:

# Respetar el orden del workspace graph
- name: Build en orden topológico
  run: pnpm run --filter="..." --workspace-concurrency=1 build
  # O mejor aún, usando el flag --sort:
  # pnpm run -r --sort build
Enter fullscreen mode Exit fullscreen mode

El flag --sort hace que pnpm respete el grafo de dependencias del workspace. Sin esto, en un monorepo con shared packages vas a ver errores de imports que no existen todavía porque el package del que dependés todavía no compiló.

El caché se guarda al final del job, no al principio

Esto es un comportamiento de actions/cache que quema a mucha gente: el caché se persiste cuando el job termina exitosamente. Si el job falla en el step de build (después de instalar dependencias), el nuevo caché del store no se guarda. El próximo run vuelve a descargar todo.

Para mitigar esto, podés separar el install en un job propio:

jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      # Solo instala y cachea — siempre termina exitoso si las deps están bien
      ...

  build:
    needs: install
    runs-on: ubuntu-latest
    steps:
      # Restaura el caché del job anterior y buildea
      ...
Enter fullscreen mode Exit fullscreen mode

Los números concretos

En un escenario reproducible con un monorepo de tres workspaces (apps/web, packages/ui, packages/config) y un total de ~850 dependencias:

Configuración Tiempo de install Tiempo total de CI
Sin caché (descarga todo) ~22 min ~40 min
cache: 'pnpm' en setup-node (caché roto) ~20 min ~38 min
Store-dir explícito + lockfile hash ~1.5 min ~8 min

El "caché roto" de la segunda fila es el caso más traicionero: el workflow muestra que el paso de caché existe, el log dice "Cache found" en algunas corridas, pero el restore es parcial. El tiempo baja apenas 2 minutos porque algo se restaura — pero no lo suficiente para evitar la mayoría de las descargas.

La diferencia entre 38 y 8 minutos es exactamente el tipo de overhead que se acumula silencioso. Un equipo de cuatro personas haciendo diez PRs por día son 1200 minutos de build time desperdiciado por semana.


FAQ: pnpm workspaces cache GitHub Actions CI

¿Por qué cache: 'pnpm' en actions/setup-node no funciona bien con monorepos?

Porque cachea el node_modules del directorio raíz pero no el store global de pnpm. En un monorepo con workspaces, cada package tiene su propio node_modules con symlinks al store. Si el store no se restaura correctamente, pnpm detecta que los symlinks están rotos y reinstala todo de cero. La solución es cachear el store directamente con actions/cache y un path explícito.

¿Qué path tiene el store de pnpm en GitHub Actions runners?

Sin configuración explícita, varía. En runners Ubuntu puede estar en /home/runner/.local/share/pnpm/store o en un path temporal dentro del workspace. Por eso la primera regla es definir store-dir explícitamente con pnpm config set store-dir antes de correr pnpm install.

¿Cuál es la estrategia correcta de key para el caché de pnpm en monorepo?

Usar hashFiles('**/pnpm-lock.yaml') con el glob doble asterisco. Esto incluye el lockfile raíz y cualquier lockfile en subdirectorios. Combinado con runner.os para separar caché entre Linux y macOS si corrés en ambos. El restore-key amplio sin el hash sirve como fallback pero nunca como key principal.

¿Tengo que cambiar algo en pnpm-workspace.yaml para que el caché funcione mejor?

No directamente. pnpm-workspace.yaml define la estructura del workspace, no el comportamiento del store. Lo que sí importa es que todos los packages tengan sus dependencias declaradas correctamente en sus respectivos package.json: si un package usa una dependencia que solo está en el raíz sin declararlo, pnpm puede resolver localmente pero fallar en CI cuando el store se restaura parcialmente.

¿Vale la pena separar el job de install del job de build?

Depende del tamaño del monorepo. Para repos con más de 500 dependencias y builds que pueden fallar frecuentemente (tests, linting), sí vale: garantiza que el caché se persiste incluso cuando el build falla. Para repos chicos donde el install es rápido, es overhead innecesario.

¿Esto funciona igual con pnpm 9 y Node.js 22?

Sí. La configuración del store-dir es estable desde pnpm 8. Con pnpm/action-setup@v4 y actions/setup-node@v4 el setup es el mismo independientemente de la versión de Node. Lo que cambia entre versiones de pnpm son los flags de algunos comandos — por ejemplo, --workspace-concurrency fue renombrado en algún punto — pero la lógica de caché es idéntica.


Lo incómodo que nadie dice sobre pnpm y CI

pnpm es la mejor opción para monorepos — lo dije cuando comparé pnpm vs npm vs yarn con benchmarks reales y lo sostengo. Pero tiene una curva de configuración en CI que es genuinamente frustrante porque los errores son silenciosos. El workflow "funciona" — el CI no explota, los tests pasan — pero el caché está roto y nadie lo ve hasta que alguien mira los tiempos con atención.

El post anterior sobre pnpm workspaces en monorepo con Next.js 16 terminaba con el CI verde. Este post es lo que quedó sin resolver: el caché que sobrevivió al fix inicial y siguió costando tiempo en silencio. La lección no es que pnpm esté mal documentado — la documentación oficial de CI es clara si la leés completa. La lección es que "CI funcionando" y "CI funcionando eficientemente" son dos estados completamente distintos, y el segundo requiere que prestés atención a los números, no solo al check verde.

Si arrancás un monorepo nuevo hoy, copiá el YAML del fix directamente. No uses el cache: 'pnpm' de setup-node como única estrategia. Configurá el store-dir antes del install. Usá el glob **/pnpm-lock.yaml para el hash. Son diez líneas extra que ahorran treinta minutos por run.

Para arquitecturas donde el tiempo de CI importa a escala — y si estás diseñando sistemas distribuidos, importa — estos detalles de infraestructura son parte del trabajo. El mismo criterio que aplico al diseño de sistemas de firma digital o al análisis de tradeoffs de Jakarta EE vs Spring Boot aplica acá: los defaults razonables raramente son los defaults correctos para casos reales.


Fuente original:


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)