pnpm 12 rewrote the CLI in Rust, and the code deciding which files enter your tarball was rewritten with it: npm-packlist is gone, replaced by pnpm's own crates/fs-packlist. Between 8 and 15 October 2026, pnpm shipped four separate corrections to that crate, and each one changes what pnpm publish uploads for a project whose package.json nobody edited. The Rust rewrite has produced quiet regressions before, like the UnknownIssuer failure against private registries on 12.6. This one is worse to catch, because the publish succeeds, the registry accepts the tarball, and the missing file surfaces in a consumer's node_modules as Cannot find module, which is also what npm 12 produces when a native build silently never runs.
TL;DR: publish from pnpm 12.14.0 or later, and before you publish, run pnpm pack into a temp directory, list the tarball with tar -tzf, and diff that list against npm pack --dry-run. Where the two implementations disagree, pnpm's packlist is wrong, and no amount of files or .npmignore tuning fixes it.
What decides what goes into the tarball?
Four rule sets interact: the files array, root ignore files, subdirectory ignore files, and an always-included floor. The October releases changed three of the four.
What I read while sorting this out: pnpm's pack documentation, the npm-packlist 11.3.0 README for the rules pnpm is reimplementing in Rust, and the 12.14.0 release notes.
What did the Rust packlist change under you?
- Diff pnpm's file list against npm's; the disagreement is the defect. Two independent implementations now compute the same list, pnpm's
fs-packlistcrate and npm'snpm-packlist. A file present in one and absent from the other is a packlist bug rather than a config mistake, and this check needs no theory about which release you are on.
pnpm pack --pack-destination /tmp/p >/dev/null
tar -tzf /tmp/p/*.tgz | sed 's|^package/||' | sort > /tmp/pnpm.txt
npm pack --dry-run --json | jq -r '.[0].files[].path' | sort > /tmp/npm.txt
diff /tmp/pnpm.txt /tmp/npm.txt
In a workspace, sweep every package in one go with pnpm -r pack --pack-destination /tmp/p.
- A negation under an excluded directory was dropping whole build trees. pnpm issue #16743 reports an
.npmignoreof*followed by!lib/**/!(*.js.map|.tsbuildinfo): on 11.28.5 the package shippedlib/, on 12.10.1 it shippedlib/index.jsand nothing else. The fix, PR #16761, merged 8 October 2026, ports ignore-walk'sfilterEntryand the minimatch features it depends on (matchBase,dot,nocase, braces, character classes, extglobs, partial matching) into the Rust walker. If your ignore file exists to re-include a subtree and you are publishing from anything below 12.14.0, run check 1 first. files: []is not a kill switch. Since 12.13.0, an emptyfilesarray still packspackage.json, the README, the license, and whatevermainandbinpoint at. The npm-packlist README documents the same floor: the root readme, license, licence and copying files pluspackage.jsonare always included. An empty array buys you a small tarball, never an empty one, so use"private": truewhen the intent is that nothing ever leaves.
What ships that you never named?
- A
filesfield silences the root ignore files only. npm-packlist is explicit that with afilesarray present, "any top-level.npmignoreand.gitignorefiles are ignored", while ".npmignorefiles in subdirectories are still honored within their subtree". pnpm only restored that second half in 12.14.0 (#16909). Sofiles: ["dist"]plus adist/.gitignoreleft behind by a bundler template breaks in both directions: before 12.14.0 the subtree ignore was skipped, and from 12.14.0 it starts cutting files out. Inventory every one of them:
find . -name '.npmignore' -o -name '.gitignore' | grep -v node_modules
- A gitignored build directory reached only through
exportsgets no rescue. From 12.14.0, pnpm "include[s] complete gitignored directories referenced bymain,browser, orbinfields, matching npm behavior" (#16908). Read the field list literally:exportsis not on it. A modern package that declares only"exports"and gitignores its output publishes without that output, and the consumer fails on first import with something likeCannot find module './chunk-4f2a.js'. Name the build directory infilesinstead of trusting entry-point detection. - Your published
package.jsonis not the one in your repo.pnpm packobfuscates the manifest by default: thepackageManagerfield and the publish lifecycle scripts are stripped, and thepnpmfield is omitted either way, per the pack docs.--skip-manifest-obfuscation(pnpm 11.3.0 and later) keeps the first two. That accounts for "why does my published package have no scripts", and for any downstream tool that readspackageManageroff an installed dependency and finds nothing. Check the shipped manifest directly:
tar -xzOf /tmp/p/*.tgz package/package.json | jq '{packageManager, scripts, pnpm}'
- Grep the tarball for dotfiles. Until 12.13.0, pnpm packed
.npmignoreand.gitignoreinto the tarball, and afilesentry that names one still ships it today. npm-packlist also lists what should never appear regardless of your rules:.git,.npmrc,node_modules, editor leftovers such as..swpand._,.DS_Store, and a rootnpm-debug.log. One line answers it:
tar -tzf /tmp/p/*.tgz | grep -E '/\.[^/]+$'
Treat a shipped .npmrc as a published credential and rotate the token, not as an untidy tarball.
How do I prove the tarball before publishing?
--dry-runruns your build, so the list it prints can be stale. The pack lifecycle includesprepack,prepareandpostpack, and pnpm documents--dry-runas doing "everything a normal run does, except actually packing the tarball". Two consequences follow: a dry run has side effects, and a dry run over a half-finished build enumerates whatever happens to be on disk at that moment. Settle the question on a clean tree, viagit stash -uor a fresh clone, before you trust the output.publishConfig.directorymoves the packlist root. pnpm publishes "the published subdirectory relative to the currentpackage.json" and expects "a modified version of the current package in the specified directory". Your rootfilesarray and root.npmignoredo not govern that directory; the manifest generated inside it does, andpublishConfig.executableFilesis what sets the executable bit on anything outsidebin. Audit the built directory and run check 1 from inside it. pnpm deploy in Docker fails for a related reason: the tree you hand over is generated, so only its own contents decide what the other side gets.- Put a file-count tripwire on the registry, not only in CI. The registry records
dist.fileCountanddist.unpackedSizeper version, which makes a packlist regression queryable after the fact without downloading anything:
npm view your-pkg versions --json | jq -r '.[-5:][]' \
| xargs -I{} sh -c 'echo -n "{} "; npm view your-pkg@{} dist.fileCount'
A version where the count drops by an order of magnitude is the publish that shipped a broken tarball. For the pre-publish side, commit the sorted tar -tzf output as packlist.txt and fail the job on any diff, so a pnpm upgrade that changes the list has to explain itself in review. If that job publishes with trusted publishing, keep its failures separate: an OIDC package not found is an auth problem with the sub claim and has nothing to do with the packlist.
FAQ
Why does my published pnpm package only contain index.js? If you publish from 12.10.x through 12.13.x with an .npmignore that excludes everything and re-includes a subtree, the negation defect in #16743 is the first thing to rule out. Run the diff in check 1 against npm pack --dry-run before you change any config, because the config is probably correct.
Does pnpm pack --dry-run change anything on disk? Yes. It runs the pack lifecycle, so prepack, prepare and postpack execute; the documented difference is that the tarball is not written. Do not treat it as a read-only inspection step in a shared working tree.
Do I need a files field if I already have an .npmignore? Pick one. A files array disables root-level .npmignore and .gitignore while subdirectory .npmignore files keep applying inside their subtree, per npm-packlist and, from 12.14.0, pnpm. Keeping both means every future reader has to hold two rule sets in their head at once.
Which pnpm version should I publish from? 12.14.0 or later, which carries the last of the four October corrections, including gitignored directories named by main, browser or bin and the subtree ignore behaviour. Even there, list the tarball before pushing it.
Wrap-up
Never publish a tarball you have not listed. pnpm pack into a temp directory, tar -tzf it, diff against npm pack --dry-run, and keep the sorted list in version control so the next pnpm release shows up as a diff in your repository rather than as a bug report from a user. Four corrections in six days means the packlist is code moving on its own schedule, and the registry will not validate it for you.
Comments
Be the first to comment.