Dry-Running npm publish with Pack and Tarball Diffs
See exactly what a release changes before it ships: dry-run output, a committed file manifest, tarball-to-tarball diffs against the published version, and CI gating.
Every packaging regression has the same shape: something that used to ship stopped shipping, or something that never shipped started. Neither is visible in a code diff, and both are obvious in a comparison of what the tarball contained before and after:
- dist/index.d.cts
+ src/internal/debug.ts
+ .env.example
Three lines that would have been an incident: CommonJS consumers losing their types, and two files that should never have left the machine.
Root Cause
npm publish assembles the tarball from the files array, the .npmignore file, npm’s built-in rules and whatever the build produced — four inputs, interacting, none of them reviewed. A change to any one can alter the contents without touching a line of source that a reviewer would notice.
npm publish --dry-run performs the whole pipeline except the upload, including lifecycle scripts, and reports the file list and destination. That answers “what will ship?” for one release. Comparing it against the previously published version answers the more useful question: “what is different about what will ship?”
Minimal Reproduction
{
"name": "@scope/my-library",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
}
}
}
A build-tool upgrade stops emitting index.d.cts. Every check still passes: the code compiles, the tests run, files is unchanged, and the manifest is untouched. The package publishes without types for CommonJS consumers.
Three Views of the Same Release
Step-by-Step Fix
1. Read the dry run on every release
npm publish --dry-run 2>&1 | sed -n '/Tarball Contents/,/Tarball Details/p'
npm notice === Tarball Contents ===
npm notice 1.4kB package.json
npm notice 4.4kB README.md
npm notice 18.9kB dist/index.mjs
npm notice 19.4kB dist/index.cjs
npm notice 6.1kB dist/index.d.ts
npm notice === Tarball Details ===
npm notice total files: 5
Five files where there should be six — index.d.cts is absent, and the manifest still names it.
2. Commit the file manifest so changes are reviewed
npm pack --dry-run --json \
| node -e '
const files = JSON.parse(require("fs").readFileSync(0, "utf8"))[0].files;
console.log(files.map(f => f.path).sort().join("\n"));
' > etc/shipped-files.txt
git diff --exit-code etc/shipped-files.txt || {
echo "package contents changed — review the diff above"; exit 1;
}
-dist/index.d.cts
dist/index.d.ts
dist/index.mjs
A reviewer seeing that removal in a pull request asks the right question before the release exists.
3. Diff the candidate tarball against the published one
#!/usr/bin/env bash
set -euo pipefail
name=$(node -p "require('./package.json').name")
d=$(mktemp -d); trap 'rm -rf "$d"' EXIT
# the version consumers currently have
(cd "$d" && npm pack "$name@latest" --silent >/dev/null && mkdir -p old && tar -xzf ./*.tgz -C old)
# the candidate
npm pack --silent | xargs -I{} mv {} "$d/new.tgz"
mkdir -p "$d/new" && tar -xzf "$d/new.tgz" -C "$d/new"
echo "--- file set ---"
diff <(cd "$d/old/package" && find . -type f | sort) \
<(cd "$d/new/package" && find . -type f | sort) || true
echo "--- manifest ---"
diff <(node -p "JSON.stringify(require('$d/old/package/package.json'), null, 2)") \
<(node -p "JSON.stringify(require('$d/new/package/package.json'), null, 2)") || true
--- file set ---
< ./dist/index.d.cts
--- manifest ---
< "version": "1.3.4",
> "version": "1.4.0",
The manifest diff is worth as much as the file diff: it surfaces a dependency range that changed, an engines bump, or a publishConfig edit that nobody mentioned.
4. Gate the release on both checks
{
"scripts": {
"check:contents": "bash scripts/manifest-check.sh",
"check:diff": "bash scripts/tarball-diff.sh",
"prepublishOnly": "npm run build && npm run check && npm run check:contents"
}
}
HAZARD PREVENTION
Symptom: The dry run looks correct locally and the published tarball is missing files.
Root cause: The dry run ran against a working directory containing build output from an earlier configuration, while CI built from a clean checkout where a step no longer emits that file.
Fix: Run the dry run after a clean build in CI (
git clean -xdfor a fresh runner) and treat the CI output as authoritative. A local dry run is a convenience; the CI one is the check.
Verification
# what will ship, and where
npm publish --dry-run 2>&1 | grep -E "publishing to|total files|package size"
# nothing unexpected changed since the last release
bash scripts/tarball-diff.sh
# every exported path is present in the candidate
npx publint --strict && npx attw --pack .
Expected: an expected destination and file count, a diff containing only intended changes, and clean validator output.
Edge Cases / Gotchas
npm packwrites into the current directory by default. In CI that leaves a stray tarball that a later glob can pick up; move it into a temp directory immediately.- File ordering inside a tarball is not stable across npm versions. Compare sorted file lists rather than raw archive bytes, or the diff will be noise.
- A first release has nothing to diff against. Fall back to reviewing the file list directly, and start the manifest from that release.
- Timestamps and permissions differ between builds. Content-level diffs of every file produce false positives; compare paths, sizes and the manifest, and use checksums only where reproducibility is a goal in itself.
- Private packages need auth to fetch the previous version. The diff script requires read access to the registry, which is worth configuring in CI rather than skipping the check.
Read together, the three views make a release boring in the way releases should be: by the time the publish command runs, somebody has seen the file list, a reviewer has approved any change to it, and the difference from what consumers already have is a diff rather than a discovery. None of that requires new tooling — npm pack, diff and a committed text file are the whole apparatus.
Frequently Asked Questions
What does npm publish --dry-run actually check?
It runs the full pipeline except the upload — lifecycle scripts, tarball assembly, destination resolution — but not whether the registry would accept the result.
Why diff against the previously published version?
Because most packaging regressions are changes rather than absolutes, and a missing file or a doubled size is only obvious relative to what consumers currently have.
Should the file manifest be committed?
Yes — it turns every change to package contents into a reviewable diff in the pull request that caused it.
Does dry-run run prepublishOnly?
Yes, along with prepack and prepare, which is part of its value and also why it is not free.
How do I get the previously published tarball?
npm pack <name>@latest downloads any published version without installing it, needing only read access.
Related
- Validating Packages Before Publish — the validator chain this complements.
- The files Field and Controlling npm pack — the rules that decide what the diff will show.
- Using publint to Catch Exports Errors — catching a manifest that references a file the diff shows as missing.