Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

DeployLesson

pnpm and the lockfile

package.json names version ranges, so two installs a week apart can get different packages. A lockfile records the exact version of every package, direct or indirect, and an install that follows it gets the same tree each time. Terrarium commits pnpm-lock.yaml, and both the Gate in CI and the live container refuse to install when it disagrees with package.json.

Know pnpm and lockfiles already? Skip to In Terrarium.

Outside the box · General

The concept

A package manager reads each range in package.json, such as ^3.25.76, picks one version, and does the same for every package those depend on. The lockfile writes the result down: every package's exact version and an integrity hash of its contents. Committed, it lets each install repeat the tree you tested instead of resolving again (pnpm: Working with Git).

A frozen install is the strict form. With pnpm install --frozen-lockfile, pnpm never writes the lockfile and fails if it doesn't match package.json; in CI that is the default. A plain install elsewhere updates the lockfile to match instead.

Which pnpm a project expects is the packageManager field of package.json, which pnpm and Corepack both read.

Full referencepnpm docs: Settings

Looking inside

In Terrarium

package.json pins pnpm itself:

package.json ↗5–5as of
5  "packageManager": "pnpm@10.33.0",

CI's setup step, pnpm/action-setup, reads that field when it is given no version, and the live container's image enables Corepack, which reads it too (deploy/Dockerfile). Each range in package.json resolves to one exact version in pnpm-lock.yaml:

pnpm-lock.yaml ↗17–19as of
17      zod:
18        specifier: ^3.25.76
19        version: 3.25.76

Two installs refuse a lockfile that disagrees. The installs in .github/actions/gate/action.yml:

.github/actions/gate/action.yml ↗24–26as of
24    - name: Install
25      shell: bash
26      run: pnpm install --frozen-lockfile

The live container installs in deploy/entrypoint.sh, into a pnpm store on its volume:

deploy/entrypoint.sh ↗57–62as of
57install_deps() {
58  rm -f "$INSTALLED_HASH"
59  log "pnpm install (frozen lockfile)…"
60  ( cd "$REPO_DIR" && pnpm install --frozen-lockfile --store-dir "$STORE_DIR" ) &&
61    lockhash > "$INSTALLED_HASH"
62}

It runs at first boot, and after a new commit only when the lockfile's hash differs from the one saved by the last good install (Continuous deployment follows that loop).

Local installs are plain. Each Claude Code session starts with pnpm install (.claude/settings.json), and pnpm gate:scoped runs one before its first step when package.json or the lockfile is newer than node_modules/.pnpm (scripts/gate/gate.ts).

The repo has no pnpm-workspace.yaml or .npmrc, and package.json has no pnpm field: pnpm runs on its defaults. Since pnpm 10 those skip every dependency's install scripts, the usual way a compromised package runs code (pnpm: Supply chain security): each install prints the ones it skipped, and pnpm ignored-builds lists them. The project's own postinstall runs: it points git at .githooks/ and runs nuxt prepare.

pnpm puts only a project's declared dependencies at the top of node_modules (pnpm: Motivation): Nuxt Content depends on jiti, which alone would leave it only in node_modules/.pnpm; package.json also declares jiti as a dev dependency of its own, which puts it at the top level, where shared/expand.ts imports it to load the manifests. package.json is on the list (Protected paths); pnpm-lock.yaml is not.

Why it's built this way

  • Install what the Gate tested. A plain install in CI could update the lockfile on the fly and test a tree nobody committed. The Gate and the container both install frozen, from the same committed lockfile. The cost: a package.json edit without a matching lockfile fails the Gate at its first step. .github/actions/gate/action.yml and deploy/entrypoint.sh.
  • A new package is new code on the server. Every merge runs on the server, its install and build included, and the Gate checks a package only from the outside. So a person merges a PR that adds a dependency. The cost: even a dev-only tool waits for a person. ADR-0004 (docs/adr/0004-objective-safety-gate.md).
  • A stale install looks like a real break. Agents took type errors from an old node_modules for a broken main. pnpm gate:scoped now installs first when the manifest or lockfile is newer than the install. The cost: that install isn't frozen, so it can rewrite the lockfile. scripts/gate/gate.ts.
  • No patched packages. A content-loading bug was once fixed with a pnpm patch of Nuxt Content's built code, wired in by a pnpm-workspace.yaml that held nothing else. A patch is tied to one version's compiled code and must be redone at every upgrade, so it was dropped for an error dialog in the app (the Midden keeps it). The cost: that failure now needs a reload. ADR-0019 (docs/adr/0019-content-load-error-recovery.md).

What to watch for

  • Commit package.json and the lockfile together. A range changed by hand, with no pnpm install after it, stops the Gate's install with ERR_PNPM_OUTDATED_LOCKFILE. pnpm add and pnpm remove change both files at once.
  • Don't fix the lockfile by hand. After a merge conflict in pnpm-lock.yaml, run pnpm install and pnpm resolves it; read the diff before you commit, since it may not choose the side you meant (pnpm: Merge conflicts).
  • Import only what you declare. A package that only a dependency brings in can't be imported from Terrarium's code. Adding it with pnpm add -D changes package.json, so a person merges the PR.
  • A lockfile-only change passes the path check. So does adding the pnpm-workspace.yaml that pnpm patch-commit writes, or that pnpm approve-builds writes to let the dependencies you approve run their install scripts, yet a patched or new package still needs a person (ADR-0004 (docs/adr/0004-objective-safety-gate.md)); Protected paths says what the script can't see.
  • Read the docs for your pnpm. The unversioned pages on pnpm.io describe a newer major, whose settings can differ. Read the versioned pages for the major that packageManager names, such as /10.x/.

Try it in your project

In a project with a committed pnpm-lock.yaml, change one dependency's range in package.json without installing, then run pnpm install --frozen-lockfile. It should stop with ERR_PNPM_OUTDATED_LOCKFILE and name the package, with the lockfile's range and yours. Undo the edit, then run pnpm ignored-builds to see which dependencies' install scripts pnpm skipped.

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/deploy/pnpm-lockfile