diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4bd94e9..4be768e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -69,6 +69,14 @@ jobs: else npm install --no-audit --no-fund fi + - name: Lockfile version matches package.json + # `npm ci` above already fails when the lockfile's DEPENDENCY entries + # disagree with package.json; it does not check the lockfile's copy of + # this project's own version. A release that bumps package.json without + # running an install leaves that field behind, and nothing caught it — + # measured in bareguard, stale across three consecutive releases. + # No-ops via --if-present in a repo that has not added the script. + run: npm run check:lockfile --if-present - name: Typecheck run: npm run typecheck --if-present - name: Test diff --git a/CHANGELOG.md b/CHANGELOG.md index 77df17c..2a4154f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Fixed + +- **The publish workflow now fails when `package-lock.json`'s version drifts from `package.json`.** npm writes that field on install, so a release that bumps `package.json` without running one leaves it behind — and nothing caught it: `npm ci` fails when the lockfile's *dependency* entries disagree, but never checks the lockfile's copy of the project's own version. `scripts/check-lockfile.mjs` (`npm run check:lockfile`) compares both places npm writes it and runs in the publish workflow. No lockfile is not a failure. + ## 0.11.2 — 2026-08-31 ### Added diff --git a/package.json b/package.json index 558cc21..6ee2d6b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "prepublishOnly": "npm run build:types", "test": "node --test test/unit/*.test.js test/integration/*.test.js", "test:ios": "node --test test/ios/*.test.js", - "ios:check": "node test/ios/check-prerequisites.js" + "ios:check": "node test/ios/check-prerequisites.js", + "check:lockfile": "node scripts/check-lockfile.mjs" }, "keywords": [ "android", diff --git a/scripts/check-lockfile.mjs b/scripts/check-lockfile.mjs new file mode 100644 index 0000000..6c7da1b --- /dev/null +++ b/scripts/check-lockfile.mjs @@ -0,0 +1,53 @@ +// Fail when package-lock.json's copy of this project's own version disagrees +// with package.json. +// +// npm writes that copy on install. A release that bumps package.json without +// running an install leaves it behind, and nothing else catches it: `npm ci` +// fails when the lockfile's DEPENDENCY entries disagree with package.json, but +// it does not check the lockfile's copy of the project's own version. Measured +// in bareguard, where it sat at 0.13.0 while 0.14.0, 0.15.0 and 0.16.0 all +// shipped. +// +// Harmless to consumers — npm does not include a library's lockfile in the +// published tarball — so this is repo hygiene, not a user-visible bug. It is a +// hard failure anyway because it is trivially fixable and can never be a false +// positive: it compares two numbers this repo owns. +// +// No lockfile is not a failure. Some repos deliberately ship without one, and +// this check runs from a workflow shared across all of them. +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +const read = (f) => JSON.parse(readFileSync(path.join(root, f), "utf8")); + +let lock; +try { + lock = read("package-lock.json"); +} catch (err) { + if (err.code === "ENOENT") { + console.log("✓ no package-lock.json — nothing to check"); + process.exit(0); + } + throw err; +} + +const pkg = read("package.json"); + +// Two places hold it: the lockfile root, and the root package's own entry in +// `packages[""]`. npm writes both, so both are checked — drift in either is the +// same drift, and checking only one lets the other move unseen. +const found = { + "package-lock.json version": lock.version, + 'package-lock.json packages[""].version': lock.packages?.[""]?.version, +}; +const wrong = Object.entries(found).filter(([, v]) => v !== pkg.version); + +if (wrong.length) { + console.error(`✗ lockfile version drift — package.json is ${pkg.version}:`); + for (const [where, v] of wrong) console.error(` ${where} = ${v ?? "(missing)"}`); + console.error(" Fix: npm install --package-lock-only (then commit package-lock.json)"); + process.exit(1); +} + +console.log(`✓ package-lock.json version matches package.json — ${pkg.version}`);