Reproducibilidad en CI con frozen-lockfile
Por qué CI debe instalar con --frozen-lockfile, qué significa que el lockfile no coincida con package.json, por qué esa falla es deseable, y por qué versionar el lockfile no es negociable.
El determinismo que fija el lockfile solo sirve si alguien lo hace cumplir. En tu portátil, pnpm install es amable: si el lockfile no cuadra con el package.json, lo actualiza por ti para no frenarte. En CI eso es exactamente lo que no quieres. Un pipeline que “arregla” el lockfile en silencio ya no está construyendo lo que revisaste: está resolviendo un árbol nuevo, distinto del que probaste. La solución es una sola bandera, --frozen-lockfile, que convierte la instalación en una verificación estricta: instala exactamente esto, y si no puedes, falla.
- Usar
--frozen-lockfilepara hacer la instalación de CI reproducible. - Entender por qué falla cuando el lockfile no coincide con
package.json, y por qué eso es bueno. - Diferenciar la instalación de desarrollo de la instalación estricta de CI.
- Justificar por qué el lockfile se versiona siempre en git.
El comando que hace CI reproducible
En un runner limpio, la instalación de dependencias debe ser una reproducción, no una negociación. Eso se pide así:
# instalacion estricta: reproduce el lockfile o falla
pnpm install --frozen-lockfile
Con esta bandera, pnpm no toca la resolución: lee pnpm-lock.yaml, verifica que es coherente con los package.json del workspace y materializa ese árbol exacto, con sus hashes de integridad. No consultará al registro para “ver si hay algo mejor”. El resultado es que cien ejecuciones seguidas producen el mismo node_modules, que es la precondición para que la caché de tareas (nivel 33) tenga sentido: no puedes cachear de forma fiable la salida de un build cuyas entradas cambian solas.
Conviene distinguir tres modos de instalación, porque se confunden a menudo:
pnpm install(desarrollo) — reconcilia: si el lockfile está desfasado, lo actualiza y sigue.pnpm install --frozen-lockfile(CI) — verifica: reproduce el lockfile tal cual, o aborta.pnpm install --prefer-frozen-lockfile— usa el lockfile si está al día, y solo si no, resuelve. Útil localmente, insuficiente para CI.
La idea —una instalación de CI que verifica en vez de reconciliar— es transversal a todos los gestores; solo cambia el nombre del comando:
- pnpm —
pnpm install --frozen-lockfile. - npm —
npm ci: borranode_modulesy exige unpackage-lock.jsoncoherente. - Yarn —
yarn install --immutable(Berry): falla si elyarn.lockcambiaría. - Bun —
bun install --frozen-lockfile.
La garantía es idéntica en todos: instala exactamente lo escrito, o no instales. Y encaja con la naturaleza de un runner: efímero, sin caché de node_modules previa, arrancando desde un checkout limpio en cada ejecución.
Por qué falla, y por qué eso es exactamente lo que quieres
La falla clásica dice, en esencia, que el lockfile no está actualizado respecto al package.json:
ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile"
because pnpm-lock.yaml is not up to date with package.json
Recuerda la dualidad del nivel 6.2: cada dependencia guarda su specifier (el rango declarado) junto a su version (lo resuelto). La instalación congelada compara el specifier del lockfile con el rango actual del package.json. Si alguien cambió una versión en package.json y no regeneró el lockfile, esos dos rangos ya no coinciden y pnpm se niega a adivinar.
El escenario que la dispara casi siempre es un cambio manual sin regenerar:
# alguien edita a mano package.json y commitea SOLO eso
- "vite": "^6.0.0"
+ "vite": "^8.0.0" # el lockfile sigue apuntando a 6.x
pnpm install --frozen-lockfile # aborta: specifier ^8 frente a lockfile 6.x
Esto no es una molestia: es la característica funcionando. Significa que el commit está inconsistente —declara una intención en package.json que su propio lockfile no refleja— y CI lo detecta antes de construir y desplegar algo que nadie revisó. Un pipeline que en cambio “arreglara” el lockfile sobre la marcha camuflaría el problema y desplegaría un árbol de dependencias fantasma, distinto del que corría en local.
Hay además una consecuencia sutil en el caching: las claves de la caché de tareas (nivel 33) se derivan del lockfile. Si CI resolviera el árbol al vuelo, esas claves podrían variar entre ejecuciones —invalidando aciertos legítimos o, peor, reutilizando artefactos construidos con dependencias distintas—. La instalación congelada mantiene el lockfile estable y, con él, un espacio de claves de caché fiable.
flowchart TD A[pnpm install en CI] --> B[Coincide el specifier del lockfile con package.json] B -->|si| C[Reproduce el arbol exacto] C --> D[Build determinista y cacheable] B -->|no| E[ERR_PNPM_OUTDATED_LOCKFILE] E --> F[CI falla: el commit es inconsistente] F --> G[Corrige local con pnpm install y commitea el lockfile] style C fill:#a6e3a1,color:#11111b style E fill:#f38ba8,color:#11111b
Dos instalaciones, dos intenciones
La misma orden hace cosas distintas según el contexto, y confundirlas es el origen de la mayoría de sustos:
- En desarrollo (
pnpm install): la instalación es reconciliadora. Si tocaspackage.jsona mano, actualiza el lockfile para dejarte seguir. Su trabajo es acomodarte. - En CI (
pnpm install --frozen-lockfile): la instalación es verificadora. No modifica nada; o el lockfile ya describe un árbol válido y coherente, o aborta. Su trabajo es protegerte.
En pnpm 10 este comportamiento es, de hecho, el valor por defecto en CI: si la variable de entorno CI está presente, pnpm install asume --frozen-lockfile sin que lo pidas. Aun así, hazlo explícito en tus scripts y workflows: la intención escrita vale más que un comportamiento implícito que alguien podría no conocer.
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run test
La regla de oro operativa: nunca modifiques dependencias a mano en el package.json y las commitees sin correr pnpm install para regenerar el lockfile en el mismo commit. Deja que las herramientas (pnpm add, pnpm update) mantengan ambos archivos en sincronía. Si tu CI falla con ERR_PNPM_OUTDATED_LOCKFILE, la cura es siempre la misma: pnpm install en local, y commitea el lockfile resultante.
Versionar el lockfile no es negociable
Todo lo anterior se desmorona si el lockfile no está en git. Sin él, cada install es una resolución nueva y el determinismo desaparece. Por eso:
- El lockfile se commitea siempre, para aplicaciones y también para librerías (aunque al publicar no se incluya en el tarball, en el repo garantiza builds y tests reproducibles del propio proyecto).
- No se añade a
.gitignorejamás. Ignorarlo es renunciar a la reproducibilidad. - En un monorepo hay un único lockfile en la raíz: no lo dividas por paquete.
- Los conflictos de merge en el lockfile no se editan a mano; se resuelven regenerándolo (
pnpm installreconcilia el resultado de la fusión).
Puedes anclar además la versión exacta de pnpm con packageManager y Corepack, de modo que todo el equipo y el CI usen el mismo gestor y no aparezcan diferencias por versiones de herramienta:
{
"packageManager": "pnpm@10.13.1"
}
Más allá de instalar, añade un paso que verifique que el lockfile está sincronizado antes de compilar nada. Un pnpm install --frozen-lockfile como primer paso ya cumple esa función: si alguien tocó dependencias sin regenerar el lockfile, el pipeline se detiene en segundos, no tras un build de diez minutos. Algunos equipos añaden un git diff --exit-code pnpm-lock.yaml tras la instalación para detectar cualquier deriva. La idea es fallar pronto, ruidosamente y barato.
Hay una asimetría profunda entre una herramienta que te ayuda y una que te protege, y --frozen-lockfile es el paso de una a la otra. En desarrollo quieres velocidad y perdón: instala lo que puedas, arregla lo que falte, no me interrumpas. En la frontera hacia producción quieres lo contrario: cero perdón, cero cambios silenciosos, falla ruidosamente ante la menor inconsistencia. Esa inversión de valores es la esencia de la ingeniería de release. Un ERR_PNPM_OUTDATED_LOCKFILE en CI no es un pipeline “roto”; es el sistema haciendo cumplir un contrato que dice: lo que se despliega es, exactamente y verificablemente, lo que se revisó. La tentación de desactivar la bandera para “que pase el CI” es el equivalente moderno de silenciar una alarma de incendios porque hace ruido. Los equipos que entienden esto tratan el lockfile como parte del código revisable —con su diff escrutado en cada pull request— y la instalación congelada como el guardián que garantiza que ese código, y no un primo suyo resuelto al vuelo, es lo que llega a los usuarios.
- En una rama, edita a mano una versión en
package.jsonsin correrpnpm install, y commitea solo ese cambio. - Ejecuta
pnpm install --frozen-lockfiley observa elERR_PNPM_OUTDATED_LOCKFILE: lee el mensaje entero. - Corrige con
pnpm install(sin la bandera), revisa el diff del lockfile y commitea ambos archivos juntos. - Repite
pnpm install --frozen-lockfiley confirma que ahora pasa: relaciona el éxito con la coincidenciaspecifierfrente a rango. - Añade el paso
pnpm install --frozen-lockfilea un workflow de CI y verifica que se ejecuta antes de build y tests.