I publish seven packages out of one monorepo. Last week, with 572 tests passing, typecheck clean, lint clean and the tarball-contents check green, three of them were one command away from being published in a state that would fail on install.
The bug is boring. The reason nothing caught it is not.
What happened
I added one subpath export to the shared package, @hyuga/spar:
{
"exports": {
".": "./src/spar.mjs",
"./cli": "./src/cli.mjs"
}
}
Then I changed the three packages that depend on it to use that subpath:
import { runDirectly, readStdin } from '@hyuga/spar/cli';
And I forgot to raise the dependency floor:
"dependencies": { "@hyuga/spar": "^0.1.0" }
^0.1.0 resolves to the 0.1.0 already on the registry, whose exports are:
$ npm view @hyuga/spar@0.1.0 exports
{ '.': './src/spar.mjs' }
No ./cli. Three lines to reproduce:
mkdir t && cd t && npm init -y
npm i @hyuga/spar@0.1.0
node -e "import('@hyuga/spar/cli')"
# ERR_PACKAGE_PATH_NOT_EXPORTED - Package subpath './cli' is not defined by "exports"
It cannot reproduce in the workspace. npm install symlinks all seven packages to each other, so @hyuga/spar always resolves to the working tree. Of course the tests passed.
Why nothing surfaced it
These three run as hooks. Throwing inside a hook takes the user's session with it, so they are all written like this:
try {
if (sub === 'pre') emit('PreToolUse', check(payload));
} catch {
// a limiter that breaks the session is worse than no limiter
}
I still think that is the right call. It also swallows ERR_PACKAGE_PATH_NOT_EXPORTED.
From outside: no error, no warning, exit 0, a counter that stays at zero forever, and a part meant to keep a copy of your draft before it is overwritten that keeps nothing. It reads as "a quiet day." You find out when you go looking for a draft that was never saved.
What was green
| check | result |
|---|---|
node --test, 7 packages, 572 tests |
pass |
tsc --noEmit |
pass |
| biome lint | pass |
| Node 18 / 20 / 22 / 24 matrix | pass |
| integration against real MySQL 8.4 and PostgreSQL 16 | pass |
npm pack --dry-run contents (no config / dump / env) |
pass |
tag vs package.json vs src/version.ts
|
pass |
Every one of them runs inside the working tree. That is the whole problem. It was not that I had too few checks — it was that all of them were checking the wrong place.
The check I added
scripts/smoke-install.mjs, and it is exactly what it sounds like:
npm pack- make an empty directory
-
npm i ./the-tarball.tgzthere — letting npm resolve dependencies from the registry -
import()every subpath the package declares inexports - run every command in
binwith--help
Step 3's parenthesis is the entire point: no workspace links.
It failed immediately:
@hyuga/redline@0.2.0
✗ npm install from the tarball
npm error code ETARGET
npm error notarget No matching version found for @hyuga/spar@^0.2.0.
A side effect worth having: the publish order is now enforced rather than documented. Publishing redline before spar reaches the registry fails with ETARGET. It used to be a sentence in RELEASING.md.
Then the check was wrong. Twice.
It stopped a release and explained nothing
First run in CI:
✗ npm pack
undefined
I had written the destination path already-quoted, because npm is a .cmd on Windows — and since Node 20, spawning a .cmd without a shell fails with EINVAL, so it needs shell: true, which needs the quotes. Linux runs it with shell: false, where those quote marks are just characters in a directory name.
So: a check whose entire purpose is "what works in here is not what happens out there" failed for exactly that reason. Written on Windows, first run on Linux.
The fix moves quoting inside the runner, which is the only place that knows whether a shell is involved:
const WINDOWS = process.platform === 'win32';
const quote = (a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a);
const runNpm = (args, opts = {}) => (WINDOWS
? run('npm.cmd', args.map(quote), { shell: true, ...opts })
: run('npm', args, { shell: false, ...opts }));
I also fixed the undefined. A gate that halts a release and says nothing is worse than the bug it caught — the next person's first move is not to read it, it is to delete it.
It called correct behaviour a defect
Last package:
✗ import '@hyuga/llm-safe-sql/mysql'
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'mysql2'
Nothing wrong with the package. mysql2 is an optional peer: you install it if you want the MySQL adapter, and you never touch that subpath if you don't.
"peerDependencies": { "mysql2": ">=3.9.0", "pg": ">=8.11.0" },
"peerDependenciesMeta": { "mysql2": { "optional": true }, "pg": { "optional": true } }
My check imported every declared subpath against a bare install, so it was calling documented behaviour a bug. Peers now get installed alongside:
const peers = Object.keys(manifest.peerDependencies ?? {});
runNpm(['i', `./${tarball}`, ...peers, ...]);
This is the failure mode that actually matters. A gate that cries wolf gets switched off, and after it is switched off, the one call that should have stopped you looks exactly like the nineteen that should not have.
Where it ended up
Twelve subpaths and eight bins, all checked against a registry install:
@hyuga/llm-safe-sql@0.10.1
· peers installed: mysql2, pg
✓ import @hyuga/llm-safe-sql
✓ import @hyuga/llm-safe-sql/mysql
✓ import @hyuga/llm-safe-sql/postgres
✓ import @hyuga/llm-safe-sql/sqlite
✓ import @hyuga/llm-safe-sql/mcp
✓ llm-safe-sql --help
✓ llm-safe-sql-mcp --help
All seven went out through it.
Takeaways
- Green means "it worked under the conditions I built," not "it works." If you have workspaces, a monorepo, or path mapping, those conditions differ from your users' by construction.
-
A
catchyou added for a good reason still makes everything that dies inside it invisible. Being right about thecatchdoes not help. Keep a list of the places your system is designed to stay quiet. - Anything that can stop a release must say why it stopped. A check that does not is a check somebody deletes.
- A false positive can cost more than a miss. A miss loses one case. A false positive loses the check, and therefore every case after it.
Who this is likely to hit
- npm / pnpm / yarn workspaces monorepos
- packages with subpath
exportsconsumed by a sibling package in the same repo - TypeScript
pathsor project references doing the resolving - anything with optional peer dependencies
If you are not reinstalling the tarball in CI, you are probably not checking this. I wasn't.
The script is under 100 lines: smoke-install.mjs. MIT — take it.
Top comments (0)