wandres.dev
LOCKFILES · reproducibilidad

Qué es un lockfile y por qué existe

El abismo entre lo que declaras (rangos semver) y lo que instalas (un árbol exacto): el lockfile congela el grafo resuelto para que el build sea idéntico en cualquier máquina.

⏱ 13 min

Tu package.json no describe qué se instala: describe un espacio de posibilidades. Un rango como ^4.17.0 admite miles de versiones futuras, y cada dependencia transitiva multiplica esa ambigüedad hasta que “instalar las dependencias” se convierte en una operación no determinista. El lockfile es la respuesta: congela el árbol resuelto exacto —cada versión, cada procedencia, cada hash— para que la instalación de hoy en tu portátil y la de dentro de seis meses en un runner de CI produzcan, byte a byte, el mismo node_modules.

🎯 Al terminar esta lección sabrás
  • Distinguir entre lo declarado (rangos semver) y lo resuelto (versiones exactas).
  • Entender por qué instalar sin lockfile es no determinista.
  • Saber qué fija exactamente un lockfile: versiones, topología, procedencia e integridad.
  • Ver el lockfile como fuente de verdad y base de la cadena de suministro.

El abismo entre declarar y resolver

Lo que escribes en package.json son restricciones, no decisiones. Un rango es un conjunto de versiones aceptables:

{
  "dependencies": {
    "react": "^19.0.0",
    "zod": "~3.23.0"
  }
}

Cada operador semver abre una ventana distinta de versiones futuras, de la más laxa a la más estricta:

  • 1.2.3 — versión exacta, sin margen. Es lo que el lockfile usa por dentro.
  • ~1.2.3 — solo patch: de 1.2.3 hasta antes de 1.3.0.
  • ^1.2.3minor y patch: de 1.2.3 hasta antes de 2.0.0. El valor por defecto de pnpm add.
  • >=1.2.3 — cualquier versión igual o superior, sin techo. Peligroso: invita a un major rompiente.
  • * o latest — literalmente cualquier cosa. Nunca en producción.

Los rangos no son un defecto: existen a propósito. Declarar ^19.0.0 en vez de 19.0.1 significa “quiero recibir los parches de seguridad y las mejoras compatibles sin editar el manifiesto a mano”. El problema es que esa flexibilidad, aplicada a un grafo de dependencias, produce ambigüedad. Y esto es solo la primera capa: tus dependencias directas declaran a su vez sus propios rangos, que declaran los suyos, recursivamente. El resultado no es una lista, es un grafo dirigido de cientos o miles de paquetes. El resolvedor tiene que elegir una versión concreta de cada nodo que satisfaga todas las restricciones a la vez —un problema de satisfacción de restricciones nada trivial—. Sin nada que lo fije, esa elección depende de cuándo instalas y de qué había publicado el registro en ese instante.

Determinismo: quitar el tiempo de la ecuación

La fórmula del caos es simple: semver más reloj igual a no determinismo. Hoy ^19.0.0 resuelve a 19.0.1; el mes que viene se publica 19.1.0 y una instalación limpia se la lleva; una dependencia transitiva saca un patch roto y tu CI se pone en rojo sin que nadie haya tocado una sola línea de código.

Es el célebre “en mi máquina funciona” convertido en deriva silenciosa del árbol de dependencias. Dos personas que clonan el mismo commit con una semana de diferencia pueden acabar ejecutando código distinto, y ninguna lo sabrá hasta que un bug aparezca en una máquina y no en la otra. El efecto se ilustra con una sola línea de package.json observada en dos momentos:

# lunes: instalacion limpia
react  ^19.0.0  ->  19.0.1   (lo ultimo publicado ese dia)

# tres semanas despues: misma linea, otra realidad
react  ^19.0.0  ->  19.2.0   (se publico una minor entremedias)

El lockfile elimina el tiempo de la ecuación. Registra la resolución hecha una vez y convierte cada instalación posterior en una reproducción de esa decisión, no en una nueva negociación con el registro. El reloj deja de importar: el commit define el árbol, la instalación se vuelve idempotente, y el árbol es el mismo para todos.

flowchart LR
A[package.json con rangos] --> B[Resolvedor]
B -->|sin lockfile| C[Arbol variable segun la fecha]
B -->|con lockfile| D[Arbol congelado exacto]
D --> E[node_modules identico en tu portatil]
D --> F[node_modules identico para otra persona]
D --> G[node_modules identico en el runner de CI]
style D fill:#a6e3a1,color:#11111b
style C fill:#f38ba8,color:#11111b

Qué fija exactamente un lockfile

Un lockfile no es “una lista de versiones”. Es la fotografía completa del grafo resuelto más los metadatos para verificarlo. Fija cuatro cosas:

  • La versión exacta de cada paquete, directo y transitivo. Nada de rangos: zod@3.23.8, no ~3.23.0.
  • La procedencia (resolution): de dónde sale ese paquete —el tarball del registro, un tgz local, un tag de git, un enlace de workspace.
  • La integridad: un hash criptográfico (SRI, típicamente sha512) del contenido. Si el tarball descargado no cuadra con el hash, la instalación aborta.
  • La topología: qué depende de qué, incluyendo cómo se resolvieron los peerDependencies. El grafo, no solo los nodos.
'zod@3.23.8':
  resolution: {integrity: sha512-Aek05...MJVR7A==}
  # version exacta + hash que verifica el contenido byte a byte

El concepto es universal, aunque cada gestor lo materialice a su manera:

  • npmpackage-lock.json, formato v3, JSON verboso.
  • Yarnyarn.lock, formato propio; Yarn Berry lo combina con Plug’n’Play.
  • Bunbun.lock, en texto desde 2025 (antes el binario bun.lockb).
  • pnpmpnpm-lock.yaml, YAML legible y el más cómodo de revisar en diffs.

En un monorepo hay un solo lockfile en la raíz que gobierna todos los paquetes del workspace: no se divide por proyecto. Esa foto del grafo es, además, la materia prima de un SBOM (software bill of materials): el inventario verificable de todo lo que compone tu aplicación, cada vez más exigido en entornos regulados.

El lockfile como fuente de verdad

La instalación no ignora el lockfile: lo prefiere. Si el package.json es compatible con lo que el lockfile ya resolvió, pnpm install reproduce ese árbol sin volver a negociar versiones con el registro —más rápido y determinista—. Solo cuando cambias package.json de una forma que el lockfile no puede satisfacer, el resolvedor vuelve a entrar en acción y actualiza el lockfile.

Dicho de otro modo, cada instalación toma uno de tres caminos:

  • Reproducir — si el lockfile es coherente con todos los package.json, materializa el árbol tal cual, sin resolver contra el registro.
  • Reconciliar — si añadiste, quitaste o cambiaste una dependencia, resuelve solo lo afectado y reescribe las entradas correspondientes.
  • Fallar — bajo --frozen-lockfile (nivel 6.3), si haría falta reconciliar, aborta en vez de modificar nada.

Por eso el lockfile se versiona en git y viaja con el repo: es la fuente de verdad que hace que “clonar e instalar” signifique lo mismo para todo el equipo. Un lockfile en .gitignore es un lockfile que no existe.

Interioriza el modelo de dos archivos que recorrerá todo este nivel: el package.json es tu intención (qué quieres, expresado como rangos); el lockfile es la realidad (qué se ejecuta, en versiones exactas y hashes). Se editan juntos, se revisan juntos y se commitean juntos. Divorciarlos —tocar uno sin el otro— es la raíz de casi todos los problemas de reproducibilidad que verás a partir de aquí.

⚠️
No lo edites a mano

El lockfile es generado, no escrito. Editarlo a mano para “arreglar” una versión rompe la coherencia interna —los hashes de integridad y las aristas del grafo dejan de cuadrar— y produce fallos crípticos. Si necesitas cambiar algo, cambia el package.json y deja que pnpm install regenere el lockfile. Lo mismo con los conflictos de merge: no los resuelvas línea a línea, regenéralos.

Reproducibilidad es la base de la confianza

Un lockfile parece un detalle de fontanería, pero es el cimiento sobre el que se sostiene todo lo demás. Sin reproducibilidad no hay depuración fiable (no puedes comparar dos builds si no son el mismo build), no hay bisección honesta de un bug, no hay caché de CI que valga (nivel 33), y no hay seguridad de la cadena de suministro. Piensa en el lockfile como una instantánea criptográfica del universo exacto de código que ejecutas: los hashes de integridad convierten “confío en que el registro me dio lo correcto” en “puedo verificar que me dio lo correcto”. En 2026, con los ataques de cadena de suministro moviéndose hacia versiones maliciosas publicadas a las horas de un compromiso, esa capacidad de fijar y verificar cada byte es la diferencia entre un incidente contenido y uno que se propaga por todo el ecosistema. Marcos como SLSA y la procedencia firmada (provenance) construyen precisamente sobre esta idea. Interiorizar que el lockfile no es un artefacto desechable, sino un documento de seguridad que se revisa en cada pull request, es lo que separa a quien “hace que instale” de quien diseña un pipeline en el que se puede confiar.

⚔️ Rompe el determinismo a propósito
  1. Abre un proyecto con pnpm-lock.yaml y elige una dependencia; en el package.json verás un rango (^ o ~), en el lockfile una versión exacta.
  2. Borra node_modules y pnpm-lock.yaml, ejecuta pnpm install y compara el nuevo lockfile con el anterior en git: ¿cambió alguna versión transitiva?
  3. Busca el campo integrity de un paquete en el lockfile y razona qué garantiza ese hash y qué ataque previene.
  4. Explica en un comentario por qué “en mi máquina funciona” es, casi siempre, un problema de árbol de dependencias no fijado.
  5. Recupera el lockfile original con git checkout y confirma que una instalación limpia lo respeta sin volver a resolver.