DEV Community

jidonglab
jidonglab

Posted on

npm publish Ignores .gitignore: The .npmignore Override Rule

A teammate ran npm pack --dry-run on one of our published packages during standup and read the file list out loud. Forty-one files. Two of them were a local env file and a scripts/notes.md containing an internal hostname.

Both were in .gitignore. Both had been in .gitignore for a year.

Here is the part that took me an hour to accept: npm publish ignores .gitignore the moment a .npmignore file exists next to it. It does not merge the two. It does not warn. It picks one file and forgets the other exists.

TL;DR

  • Three mechanisms decide what ends up in your tarball: the files array in package.json, .npmignore, and .gitignore. Only the first one found applies.
  • Adding a two-line .npmignore to drop your test folder silently switches off every rule your .gitignore was enforcing.
  • npm has a small fixed always-excluded list (.git, node_modules, .npmrc, package-lock.json, and friends). Env files and key files are not on it.
  • npm pack --dry-run prints the exact file list before anything leaves your machine. So does npm publish --dry-run.
  • Use the files whitelist in package.json instead of .npmignore, and assert the tarball contents in CI.

Why does npm publish ignore .gitignore?

Because .gitignore is only a fallback. When npm builds the tarball it looks for an ignore source in this order, and stops at the first hit:

  1. The files array in package.json (a whitelist)
  2. .npmignore
  3. .gitignore

The documented sentence is "if there is no .npmignore file, npm will use .gitignore." Everyone reads that as "npm also uses .gitignore." The word doing all the work is no.

So the failure mode is not a typo. It is a perfectly reasonable action: you notice your tarball is 4 MB because it ships fixtures, you add .npmignore with test/ in it, the tarball drops to 300 KB, you ship. What you actually did was revoke every exclusion in .gitignore in one commit.

It gets more granular than people expect: the swap happens per directory. An .npmignore sitting in src/ overrides the .gitignore in src/, while a directory without one still falls back to its own .gitignore. A single stray ignore file deep in the tree can change what ships out of that subtree only.

How do I reproduce the .npmignore override rule?

Six commands in an empty directory. No git repo required, since npm reads the ignore files by name rather than asking git.

mkdir leak-demo && cd leak-demo
npm init -y > /dev/null
printf 'TOKEN=hunter2\n' > .env.local
printf '.env.local\ncoverage/\n' > .gitignore
mkdir test && echo '// 2MB of fixtures' > test/fixture.js

npm pack --dry-run
Enter fullscreen mode Exit fullscreen mode

Output lists package.json and test/fixture.js. No env file. Your .gitignore is quietly doing its job.

Now add the innocent-looking file everybody adds:

printf 'test/\n' > .npmignore
npm pack --dry-run
Enter fullscreen mode Exit fullscreen mode

test/fixture.js is gone, exactly as intended. And .env.local is now in the list, shipping your token to the registry, because .gitignore stopped being consulted the instant .npmignore appeared.

Same directory. Same .gitignore. One new file, opposite behaviour.

What does npm always exclude no matter what?

A short, fixed list that you cannot turn off, and that is much smaller than most people assume. It includes .git, CVS, .svn, .hg, .DS_Store, ._*, .*.swp, npm-debug.log, .npmrc, node_modules, config.gypi, *.orig, and package-lock.json.

Read that list again and notice what is missing: env files of every flavour, *.pem, *.key, terraform.tfvars, your notes.md, your .vscode/launch.json with a database URL in it. None of those are special to npm. They are ordinary files that only your ignore rules were protecting.

There is also a short always-included list that beats your ignore rules in the other direction: package.json, README, LICENSE / LICENCE, and whatever file your main field points at.

How do I see exactly what npm will publish?

Run the pack before you publish and read the list. Both of these are safe and upload nothing:

npm pack --dry-run      # prints the file list npm would include
npm publish --dry-run   # same list, plus the publish-time checks
Enter fullscreen mode Exit fullscreen mode

If you want ground truth instead of a printed summary, build the real tarball and open it:

tar -tf "$(npm pack --silent)"
Enter fullscreen mode Exit fullscreen mode

Every path inside is prefixed with package/, which is just how npm tarballs are laid out. npm pack also runs your prepack and prepare scripts, so anything those generate shows up in the list too. That is the exact set of bytes a consumer gets.

One more habit worth two seconds: npm publish prints a summary with the total file count and unpacked size. If that number jumps from 12 files to 41 between releases, something changed in your ignore rules.

What should I use instead of .npmignore?

Use the files array in package.json. It is a whitelist, it sits in the file you already review during releases, and it wins over both ignore files:

{
  "name": "my-pkg",
  "version": "1.4.0",
  "main": "dist/index.js",
  "files": ["dist", "bin"]
}
Enter fullscreen mode Exit fullscreen mode

Now the default is nothing ships, and each new thing you ship is a visible diff in a file your reviewers already read. Compare that to .npmignore, where the default is everything ships and a leak looks like the absence of a line.

Then make it impossible to regress. This script fails if a blocked pattern makes it into the real tarball:

#!/usr/bin/env bash
# scripts/check-tarball.sh
set -euo pipefail

tarball="$(npm pack --silent)"
files="$(tar -tf "$tarball")"
rm -f "$tarball"

blocked='(/\.env($|\.)|\.pem$|\.key$|^package/(test|tests|__tests__)/)'

if printf '%s\n' "$files" | grep -Eq "$blocked"; then
  echo "Blocked files found in tarball:" >&2
  printf '%s\n' "$files" | grep -E "$blocked" >&2
  exit 1
fi

printf '%s\n' "$files" | wc -l | xargs echo "tarball file count:"
Enter fullscreen mode Exit fullscreen mode

Wire it in as a release gate:

{
  "scripts": {
    "prepublishOnly": "bash scripts/check-tarball.sh"
  }
}
Enter fullscreen mode Exit fullscreen mode

prepublishOnly runs only on npm publish, and calling npm pack inside it triggers prepack, not prepublishOnly, so it will not recurse. Run the same script in CI on pull requests too, since that is where a new .npmignore shows up in a diff nobody connects to the package contents.

If you prefer machine-readable output, npm pack --dry-run --json gives you a structured manifest to assert on. Check the shape once on your npm version before you depend on it in CI; the tar -tf version above works everywhere.

What if you already published a secret?

Assume it is public. Registries and mirrors copy tarballs fast, and a published version can be fetched by anything that saw it. npm does allow unpublishing under narrow conditions within 72 hours, but treat that as cleanup, not containment. Rotate the credential first, publish a clean version second, worry about the old tarball third.

The short answer

npm publish ignores .gitignore whenever a .npmignore file is present, because npm checks files in package.json, then .npmignore, then .gitignore, and stops at the first one it finds rather than combining them. Adding an .npmignore to trim your tarball therefore disables every rule in .gitignore at the same time, and npm's own always-excluded list does not cover env files, key files, or private notes. Check what actually ships with npm pack --dry-run, switch to the files whitelist so the default is to ship nothing, and assert the tarball contents in CI so the next person who adds one small ignore file cannot leak anything.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)