Your lockfile has a version field too

GH Factory · No. 132

Shipped

ghfactory 0.2.1 is a patch release with two halves. A full verification battery, run against the tool’s own suite, 24 real repo workflows, and a set of wild open-source workflows, surfaced five gaps where a check reported success while verifying the wrong thing; all five got fixes pinned by new two-sided baseline traps. The same release range also carried a repo-wide fix for a quieter problem, and that one is the guide, because you probably have it right now: package-lock.json records your package’s own version, and nothing checks it.

The two fields nobody bumps

The npm docs for package-lock.json describe its top-level version field in five words: “This will match what’s in package.json.” It appears twice, once at the root and once in the packages object under the "" key that describes your own package. Both are written for you whenever npm touches the tree.

The catch is the word “will”. It matches as long as npm is the thing doing your version bumps. The moment a bump happens any other way, a release script that edits package.json directly, a sed one-liner, an AI agent following a checklist that names three files, the lockfile keeps its old answer, and nothing complains.

Here is what that looked like in a real monorepo of independently released packages. The commit that fixed it found five packages drifted, one of them by three minor versions:

shipflow      package.json 0.5.0   package-lock.json 0.2.4
resume        package.json 2.0.0   package-lock.json 1.0.1
devlog        package.json 0.13.0  package-lock.json 0.11.0
skillfactory  package.json 0.3.0   package-lock.json 0.1.0
ghfactory     package.json 0.2.0   package-lock.json 0.1.0

Version bumps had been editing package.json, plus the two other version fields that repo’s lint does check, while the lockfile fell further behind on every release, because no check named it.

Why it matters more than it looks

You could shrug at a stale metadata field. Two reasons not to. First, npm pack and npm publish include package-lock.json’s content in what ships, so a consumer inspecting the published shipflow 0.5.0 tarball found a lockfile claiming 0.2.4; your registry artifact carries a first-party lie about its own identity. Second, the tool you would expect to catch it does not: npm ci errors “when dependencies in the package lock do not match those in package.json”, and your own version field is not a dependency. Five packages drifted across weeks of green CI in that repo, which is the empirical version of the same statement.

The fix, and the trap inside the obvious fix

The obvious repair is npm install, which rewrites the lockfile and syncs the version fields. For a repo that is exactly one command behind, do that. For a repo that has drifted for a while, it is a trap: those packages carried caret ranges like mammoth ^1.12.0 and zod ^4.3.6, and a full resolve is allowed to float every one of them to a newer satisfying version. Your two-line metadata fix becomes an unreviewed dependency upgrade in the same commit.

The drift commit instead touched exactly the two version fields, nothing else:

import { readFileSync, writeFileSync } from 'node:fs';

const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
const lockPath = 'package-lock.json';
const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
lock.version = pkg.version;
if (lock.packages && lock.packages['']) lock.packages[''].version = pkg.version;
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
console.log(`lockfile version -> ${pkg.version}`);

Every per-package diff was exactly two lines, and running npm install afterwards left each lockfile byte-identical, which is the proof the surgical edit agreed with npm’s own behavior.

Keep it from coming back

Two habits, either sufficient. Use npm version for bumps; the docs are explicit that it writes “the new data back to package.json, package-lock.json, and, if present, npm-shrinkwrap.json”, so the lockfile can never fall behind. Or, if your bumps flow through release tooling that edits files itself, add the check nothing ships with:

node -e "
const p = require('./package.json').version;
const l = require('./package-lock.json');
const bad = [l.version, l.packages?.['']?.version].filter(v => v && v !== p);
if (bad.length) { console.error('lockfile says', bad.join(','), 'package.json says', p); process.exit(1); }
console.log('lockfile in lockstep at', p);
"

Wire that into CI next to your tests and the drift dies in the pull request that would have caused it. You should see:

lockfile in lockstep at 0.2.1

Gotchas

A package with no dependencies has no lockfile, and that is correct. Four packages in the same repo carry zero dependencies and therefore no package-lock.json at all. If your lockstep check treats a missing lockfile as a failure, it will teach people to generate empty lockfiles to appease it; the check above passes on absence on purpose, because absence is not drift.

The fix window and the caret window are different sizes. The reason the surgical two-line edit was safe to verify with a follow-up npm install is that it ran immediately, before any dependency published something new. Wait a week and the verification itself floats versions, and you can no longer tell metadata drift from dependency drift in the diff. Fix the fields and run the confirming install in the same sitting.

Lint the fields you generate, not just the ones you write. That repo already had a linter asserting three hand-edited version fields agree; the lockfile field was generated, so nobody thought to include it, and it was the generated one that drifted. The battery run in this same release found the identical shape elsewhere: a header check that compared a marker’s recorded hash to itself, receipt rather than goods. Generated artifacts deserve the same lockstep checks as source, because they are the ones no human reads on review.

Sources

Changelog

  • fix: a lockfile that disagrees with its package.json is a lie about what shipped (#163) (295adc4)
  • fix(ghfactory): close five verification gaps found by a full battery run (#208) (551b4d7)