Skip to content

Publishing a Package

Publishing a package makes it available on the npm registry so that anyone (or members of your organisation) can install it with npm install. The process involves three steps: preparing package.json with the right metadata, bumping the version, and running npm publish. Each step has guardrails that help you ship clean, predictable releases.

A package ready to publish typically carries these fields, on top of name and version:

{
"name": "@myorg/my-util-lib",
"version": "1.0.0",
"description": "A small utility library",
"license": "MIT",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": ["dist", "README.md"],
"bin": {
"my-util": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
},
"./helpers": {
"types": "./dist/helpers.d.ts",
"import": "./dist/helpers.mjs",
"require": "./dist/helpers.cjs"
}
},
"engines": {
"node": ">=18.0.0"
}
}
  • license — the SPDX identifier consumers and registry scanners check before depending on your package.
  • main / module — legacy CJS entry point, and a bundler-only convention some older tools still look for; both are superseded by exports but keep tooling compatible.
  • types — the root TypeScript declaration file, used by editors and tsc when the package has no exports map or as a fallback.
  • files — an allowlist of paths included in the published tarball. Everything else (source files, test fixtures, config) is excluded. If omitted, npm still applies a default set of exclusions (node_modules, .git, anything matched by .npmignore or, absent that, .gitignore).
  • bin — maps a CLI command name to an entry-point script. npm creates a symlink in .bin/ so the command is available in the PATH when the package is installed, and via npx.
  • exports — declares the public API surface. Any path not listed here is inaccessible to consumers. Inside each conditions object, "types" must come first — see Module Resolution for why key order matters.
{
"files": ["dist", "README.md", "LICENSE"]
}

"files" is an allowlist, not a denylist: only the paths listed here (plus a few files npm always includes, like package.json and README) end up in the tarball. This keeps source maps, test fixtures, and local config out of what consumers download.

The demo below reimplements that allowlist logic directly with fs, against a fake package tree that includes a .env file and test/source folders never listed in "files", to make the filtering mechanism concrete rather than asserted:

Node.js
Output (verified on Node 24)
Would ship in the tarball:
  README.md
  dist/index.js
  dist/nested/helper.js
  package.json
Excluded (not in "files"):
  .env
  src/index.ts
  test/index.test.js

.env, the TypeScript source, and the test file never touch consumers’ machines. A real npm pack --dry-run on this exact layout confirms the same three files ship — this demo makes the mechanism explicit rather than asking you to trust the CLI output.

{
"bin": {
"my-util": "./dist/cli.js"
}
}

When the package is installed (locally or globally), npm creates a my-util executable in node_modules/.bin/ (or the global bin directory) pointing at ./dist/cli.js. That is what makes the command available to npx my-util and to npm run scripts in a consuming project without a global install.

All three end up running “some code,” which is exactly why generated snippets swap between them without noticing they are not equivalent:

CommandRunsFalls back to installing from the registry?Runs pre/post lifecycle scripts?
npx <bin-name>A bin entry — local (node_modules/.bin) if present, otherwise a temporary registry installYes (prompts unless --yes/-y)N/A — not a package.json script
npm exec -- <bin-name>The same bin lookup as npx (npm 7+ implements npx on top of exec)Yes, same behavior as npxN/A — not a package.json script
node --run <script-name>A named script in package.json’s "scripts" field, directly — no shell spawnedNo — it only runs what’s already in "scripts"No — this is the sharpest difference from npm run

Real transcripts, all local and offline (a bin entry say-hi and a script greet both defined in the same package):

Terminal window
npx say-hi
hi from local bin
Terminal window
npm exec -- say-hi
hi from local bin
Terminal window
node --run greet
hi from local bin

All three print the same thing here — but add a pregreet lifecycle script and the difference becomes visible:

Terminal window
npm run greet
> echo PRE-HOOK-RAN
PRE-HOOK-RAN
> node cli.js
hi from local bin
Terminal window
node --run greet
hi from local bin

node --run skips pregreet entirely — it executes only the named script, with no lifecycle hook resolution and no shell in between, which is also why it starts faster than npm run. It is a substitute for npm run <script>, not for npx/npm exec: it cannot install anything, and it cannot run an arbitrary binary that isn’t already a package.json script.

npm follows semantic versioning (semver): MAJOR.MINOR.PATCH.

  • PATCH — backwards-compatible bug fixes.
  • MINOR — backwards-compatible new features.
  • MAJOR — breaking changes.

Use npm version to bump and tag in one step:

Terminal window
# Patch bump: 1.0.0 → 1.0.1 (bug fixes)
npm version patch
# Minor bump: 1.0.1 → 1.1.0 (backwards-compatible new features)
npm version minor
# Major bump: 1.1.0 → 2.0.0 (breaking changes)
npm version major

Each command updates the "version" field in package.json, commits the change, and creates a signed git tag (e.g. v1.0.1). Push the tag with git push --follow-tags.

npm pack bundles your package into a .tgz tarball exactly as npm would if you ran npm publish — but it stays local. This lets you inspect the final artifact before shipping it. --dry-run goes one step further: it lists the tarball contents without writing anything to disk, and needs no registry login.

Terminal window
# Create a local tarball (e.g. myorg-my-util-lib-1.0.0.tgz)
npm pack
# Inspect its contents without extracting
tar -tzf myorg-my-util-lib-1.0.0.tgz
# Install it locally in another project to smoke-test
npm install ../my-util-lib/myorg-my-util-lib-1.0.0.tgz

Real output of npm pack --dry-run against a package with "files": ["dist", "README.md"], a .env, and an unrelated secrets file sitting alongside them:

Terminal window
npm pack --dry-run
npm notice
npm notice 📦 @demo/[email protected]
npm notice Tarball Contents
npm notice 14B README.md
npm notice 21B dist/index.js
npm notice 139B package.json
npm notice Tarball Details
npm notice name: @demo/my-util-lib
npm notice version: 1.0.0
npm notice filename: demo-my-util-lib-1.0.0.tgz
npm notice package size: 284 B
npm notice unpacked size: 174 B
npm notice shasum: 59bc21d933556de397e4fa5056ea5a999750a8d3
npm notice integrity: sha512-FBadVz2MkDnJl[...]jO8KfPJW6WRuw==
npm notice total files: 3

.env and the secrets file are simply absent from the listing — exactly the filtering the fs-based demo above reimplements.

You must be authenticated (npm login) before publishing. npm reads package.json to determine the registry target (defaults to https://registry.npmjs.org).

Terminal window
# Log in to the registry
npm login
# Publish a public (unscoped) package
npm publish
# Publish a scoped package as public (scoped packages default to restricted)
npm publish --access public
# Publish under a dist-tag other than "latest" (e.g. a beta)
npm publish --tag beta
flowchart TD
  A["npm version patch/minor/major"] --> B["prepublishOnly script runs (build, test, lint)"]
  B --> C["npm pack --dry-run — inspect the tarball contents"]
  C --> D{"Looks right?"}
  D -->|"no"| E["Fix files/exports/scripts, repeat from npm pack"]
  E --> C
  D -->|"yes"| F["npm publish"]
  F --> G{"2FA required for this account/package?"}
  G -->|"yes"| H["Approve via authenticator or security key"]
  G -->|"no"| I["Tarball uploaded to the registry"]
  H --> I
  I --> J["Provenance attestation attached (if --provenance)"]
From version bump to a published tarball

Provenance, 2FA, and the pre-publish checklist

Section titled “Provenance, 2FA, and the pre-publish checklist”

A published version cannot be unpublished after 72 hours (npm’s abuse-prevention window), so the guardrails belong before npm publish runs, not after:

  • prepublishOnly — a package.json script npm runs automatically right before packing, and only before a real npm publish (unlike prepublish, which historically also ran on plain npm install). Put your build and test steps here so a broken build cannot ship:
    {
    "scripts": {
    "prepublishOnly": "npm test && npm run build"
    }
    }
  • npm pack --dry-run — the free, local, no-login-required check from the previous section. Run it and actually read the file list before every publish.
  • Two-factor authentication (2FA) — npm profile enable-2fa auth-and-writes requires a one-time code (or a hardware security key) for both login and publish, so a leaked npm token alone is not enough to publish a malicious version under your name.
  • Provenance (npm publish --provenance) — from a supported CI environment (GitHub Actions, GitLab CI), this cryptographically attaches the exact source commit and build workflow that produced the tarball, publicly visible on the npm registry page. It answers “was this actually built from the public source repo” for anyone auditing a supply-chain incident.

A scoped package uses the @scope/name format and is typically used for organisation namespacing or to group related packages:

{
"name": "@myorg/my-util-lib",
"version": "1.0.0"
}

Scoped packages are private by default on the npm registry. To publish them publicly, always pass --access public:

Terminal window
# Scaffold a scoped package
npm init --scope=@myorg
# Publish it publicly
npm publish --access public

Private scoped packages require a paid npm account or a private registry.

Everything in this lesson exists because npm publish is closer to a one-way door than a save button. A published tarball’s exact bytes are permanent — you cannot edit or silently replace 1.0.0 after the fact — so every guardrail here ("files", prepublishOnly, npm pack --dry-run, 2FA, provenance) exists to catch a mistake in the few seconds before that door closes, because nothing catches it after.

ChoiceBenefitCost
prepublishOnly running tests/buildA broken build cannot be published — the check runs automatically, no one has to remember itAdds real time to every publish; a flaky test can block a legitimate release
2FA on publishA stolen npm token alone cannot publish a malicious versionEvery publish needs a second factor available — friction for automated release pipelines unless a granular token is set up
--provenancePublic, verifiable link between a published tarball and the exact source commit/workflow that built itOnly works from a supported CI provider — cannot be added retroactively to already-published versions
Strict "files" allowlistSmall, predictable tarballs; nothing accidentally shippedForgetting to add a genuinely needed file (a wasm binary, a font) breaks the package for consumers until the next version
  • “I can just publish a patch to fix a mistake I shipped.” You can publish a new version, but the broken one stays on the registry forever (npm’s unpublish window is 72 hours and has restrictions even within it) — anyone who already resolved that exact version keeps the mistake.
  • “npm pack and npm publish might package different files.” They use the identical "files"/.npmignore logic — npm pack --dry-run is not an approximation, it is the literal tarball-building step, minus the upload.
  • “2FA only protects my npm account login.” enable-2fa auth-and-writes additionally requires a second factor for the publish itself — a stolen or leaked auth token is not sufficient on its own to push a malicious version.

Generated CI/CD publish scripts frequently omit prepublishOnly and go straight from a version bump to npm publish, because the tutorial-shaped examples most training data comes from are demonstrating “how to publish,” not “how to publish safely.”

🐛 Spot the bug

{
"name": "my-cli-tool",
"version": "1.0.0",
"bin": { "my-cli": "./dist/cli.js" },
"devDependencies": {
"commander": "^12.0.0"
}
}
Show the answer

commander is the argument-parsing library the CLI actually calls at runtime — every time my-cli runs, on every consumer’s machine — so it belongs in "dependencies", not "devDependencies". Because npm skips devDependencies on a production install (npm install --omit=dev, or whenever a consumer installs this as a real dependency rather than cloning the repo), any consumer who installs this CLI normally gets Cannot find module 'commander' the moment they run it. The fix is moving it: "dependencies": { "commander": "^12.0.0" }.

What does the "files" field in package.json control?
You run npm version minor on a package at version 2.3.1. What is the new version?
Why must scoped packages use npm publish --access public to be installable by the public?
A consumer installs this CLI as a normal dependency (npm install --omit=dev semantics apply). What happens when they run my-cli?
{
  "name": "my-cli-tool",
  "bin": { "my-cli": "./dist/cli.js" },
  "devDependencies": {
    "commander": "^12.0.0"
  }
}
A Dockerfile-adjacent publish pipeline runs npm version patch then immediately npm publish, skipping npm pack --dry-run and prepublishOnly. What real protection does this lose?