Un workflow que ejecuta npm ci en cada push descarga del registro los mismos cientos de paquetes cada vez, incluso cuando el lockfile no ha cambiado en una semana. En un proyecto con un árbol de dependencias real eso son uno o dos minutos perdidos antes de que arranque el primer test, y los pagas de nuevo en cada push y en cada pull request. actions/cache conserva esas descargas entre ejecuciones y te las devuelve cuando no ha cambiado nada relevante.

Cómo decide la action si hay hit o miss

actions/cache guarda cada entrada bajo una cadena que eliges tú, la key, con alcance al branch que la creó. Una ejecución puede leer cachés de su propio branch y del branch por defecto. Al restaurar, la action compara tu key con lo que hay guardado:

  • Una coincidencia exacta con key es un hit. Se descargan los archivos y el job continúa.
  • Si no hay coincidencia exacta, se pasa a restore-keys, comprobadas en orden, cada una como coincidencia de prefijo contra las keys existentes. Gana la coincidencia creada más recientemente.
  • Si no hay ninguna coincidencia, el paso no restaura nada. El job arranca en frío y, si tiene éxito, guarda una nueva entrada bajo key.

Por eso el patrón estándar incluye el hash del lockfile en la key:

1
2
3
4
5
6
- uses: actions/cache@v6
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

Cambia una línea en package-lock.json y el hash cambia, así que la key exacta falla a propósito. Quieres una caché que coincida con el lockfile nuevo, no con el de la semana pasada. El prefix de restore-keys sigue coincidiendo, así que el job arranca desde la caché anterior en lugar de un directorio vacío, descarga solo lo que ha cambiado, y guarda una entrada nueva de coincidencia exacta cuando tiene éxito.

Aplican dos límites. Un repositorio tiene 10 GB de almacenamiento de caché, compartidos entre todos sus workflows, y cualquier entrada sin usar durante 7 días se elimina. No hay que limpiar nada a mano: cuando el repositorio supera el límite se eliminan las entradas más antiguas, y la siguiente ejecución que las necesitaba paga una sola vez el coste de la instalación en frío.

Cachear npm con la opción cache de setup-node

Para los gestores de paquetes más comunes ni siquiera necesitas escribir un paso actions/cache. actions/setup-node, actions/setup-python y actions/setup-java aceptan un input cache que se encarga de la key, la restauración y el guardado por ti:

1
2
3
4
5
6
7
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: 'npm'
- run: npm ci
- run: npm test

cache: 'npm' busca package-lock.json (o npm-shrinkwrap.json) en la raíz del repositorio, calcula su hash y cachea las descargas de npm. cache: 'yarn' hace lo mismo con yarn.lock. En un monorepo donde el lockfile está en packages/api/package-lock.json, apunta a él explícitamente:

1
2
3
4
5
- uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: 'npm'
    cache-dependency-path: packages/api/package-lock.json

setup-python funciona igual: cache: 'pip' (o 'pipenv', 'poetry'), con el hash calculado por defecto sobre requirements.txt. setup-java acepta cache: 'maven' o cache: 'gradle'. Empieza por aquí. Pasa a un paso actions/cache manual solo cuando necesites una key o un path que la setup action no te da, o cuando caches algo que no tiene nada que ver con un runtime de lenguaje.

Cachear Maven y pip con actions/cache directamente

Un paso manual siempre tiene las mismas tres partes: el directorio donde la herramienta escribe su caché, una key construida a partir del archivo que fija las versiones de las dependencias, y un fallback de prefix.

1
2
3
4
5
6
- uses: actions/cache@v6
  with:
    path: ~/.m2/repository
    key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
    restore-keys: |
      ${{ runner.os }}-m2-
1
2
3
4
5
6
- uses: actions/cache@v6
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

path es la caché de la herramienta, no la de tu proyecto: la caché de descargas de pip, no tu virtualenv; el repositorio local de Maven, no target/. También puedes cachear la salida del build como target/ o dist/, pero solo compensa si el build es incremental. Si no, estás comprimiendo y subiendo archivos que la siguiente ejecución tira.

Si ya cacheas capas de Docker con cache-to: type=gha, como en Construir y Publicar una Imagen Docker con GitHub Actions, esas capas van al mismo servicio de caché de Actions. Cuentan contra los mismos 10 GB y la misma eliminación a 7 días que tus cachés de dependencias. Un repositorio que hace ambas cosas alcanza el límite antes de lo que esperarías, y cachés de capas grandes pueden expulsar tu caché de npm.

Keys de caché en builds con matrix

Una matrix con varias versiones de Node o sistemas operativos necesita el valor de la matrix dentro de la key, o los tres trabajos competirán por una sola entrada:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
strategy:
  matrix:
    node-version: [20, 22, 24]
steps:
  - uses: actions/cache@v6
    with:
      path: ~/.npm
      key: ${{ runner.os }}-npm-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
      restore-keys: |
        ${{ runner.os }}-npm-${{ matrix.node-version }}-

Las entradas de caché no se pueden sobrescribir. Quita matrix.node-version de la key y los tres trabajos intentarán guardar Linux-npm-<hash>: gana el primero que termine, y los demás registran un warning de que el guardado falló. Para ~/.npm eso es casi inofensivo, porque los tarballs descargados no dependen de la versión de Node. Deja de ser inofensivo cuando el path cacheado contiene salida compilada, como node_modules con un addon nativo. En ese caso el trabajo de Node 24 restaura binarios compilados para Node 20, y obtienes fallos que parecen cualquier cosa menos un problema de caché. runner.os en la key cumple la misma función entre trabajos de distintos sistemas operativos.

Qué no cachear en GitHub Actions

Trata una entrada de caché como una capa de imagen: cualquiera que pueda ejecutar un workflow que la restaure puede leerla. Mantén tokens y archivos .env fuera de cualquier directorio cacheado. La regla es la misma de Variables de Entorno y Secrets en Docker: los secrets vienen de secrets: en tiempo de ejecución, nunca de algo escrito en disco y reutilizado.

Dos casos en los que cachear cuesta más de lo que ahorra:

  • Cualquier cosa que se instale en unos segundos. Un puñado de paquetes Python puros, sin extensiones compiladas, se instala casi tan rápido como se restaura. El paso de caché tiene su propio overhead para comprimir y transferir el archivo, y eso puede comerse un ahorro tan pequeño.
  • Una key demasiado genérica para hacer hit alguna vez. Si haces el hash de todo el directorio src/ en lugar del lockfile, cada cambio de código invalida la caché. Basa la key en el archivo que fija las versiones de las dependencias y en nada más.

Cómo saber si la caché está haciendo hit

El paso actions/cache registra Cache restored from key: ... en un hit y Cache not found for input keys: ... en un miss. Lee esa línea antes de confiar en la configuración.

Para medirlo, añade cache: 'npm' o el paso manual a un workflow, haz push dos veces y compara cuánto tarda el paso npm ci en la ejecución en frío y en la caliente. Esa diferencia es lo que vale el cambio. Si un job debe construir la caché y otros jobs solo deben leerla, sustituye actions/cache por actions/cache/restore@v6 y actions/cache/save@v6, que separan las dos mitades para que decidas dónde corre cada una.

Artículos relacionados