Two publish accidents account for most emergency unpublish requests, and both are prevented by four lines in the manifest. The first is a private package reaching the public registry. The second is a public package failing to publish because a scoped name defaulted to restricted:

npm error code E402
npm error 402 Payment Required - PUT https://registry.npmjs.org/@acme%2fmy-library
npm error You must sign up for private packages

publishConfig moves both decisions out of ambient configuration and into the file that travels with the package.


Root Cause

Where a package publishes and how visible it becomes are, by default, properties of the environment rather than of the package. The registry comes from whichever .npmrc is in effect; the access level comes from npm’s default for the name’s shape — unscoped names default to public, scoped names default to restricted.

That means the same npm publish command produces different results from a maintainer’s laptop, a CI runner, and a colleague’s machine, and none of those differences are visible in the repository. publishConfig is the mechanism for stating the intent once, in the manifest, so every publish path agrees.


Minimal Reproduction

{
  "name": "@acme/internal-tools",
  "version": "1.0.0"
}
npm publish

With a .npmrc configuring the internal registry, this publishes internally. On a machine without that file — a new laptop, a fresh CI runner, a contributor’s fork — the same command publishes to the public registry, and because the failure mode depends on the account’s plan it may succeed rather than error.


What Each Field Prevents

Four fields, four prevented accidents registry prevents publishing to the wrong host, access prevents an unintended visibility, tag prevents a maintenance release becoming the default install, and provenance prevents a release without an attestation. Each line removes one class of publish accident "registry" prevents: an internal package reaching the public registry "access" prevents: a scoped package failing, or publishing more visibly than intended "tag" prevents: a maintenance or beta release becoming the default install "provenance" prevents: a release published without an attestation by a path that forgot the flag

Step-by-Step Fix

1. Pin the destination and the visibility

   {
     "name": "@acme/internal-tools",
-    "version": "1.0.0"
+    "version": "1.0.0",
+    "publishConfig": {
+      "registry": "https://npm.pkg.github.com",
+      "access": "restricted"
+    }
   }

For a public package the same two fields state the opposite intent, and the access line is the one that makes a first publish work without a remembered flag:

{
  "publishConfig": {
    "registry": "https://registry.npmjs.org",
    "access": "public",
    "provenance": true
  }
}

2. Confirm the effective destination before publishing

npm publish --dry-run 2>&1 | grep -E "npm notice (publishing|package|name|version)"
npm notice publishing to https://npm.pkg.github.com
npm notice name:          @acme/internal-tools
npm notice version:       1.0.0

That line is worth reading on every release. It reflects the merged configuration — command line, then publishConfig, then .npmrc — rather than any single source.

3. Use the tag field for maintenance branches

Pinning a dist-tag per branch The main branch publishes to the latest tag while a maintenance branch pins its publishConfig tag to legacy, so a release from the older line never becomes the default for new installs. The manifest states which line a release belongs to main branch version 3.2.0 publishConfig.tag: "latest" what npm install resolves to 2.x maintenance branch version 2.9.5 publishConfig.tag: "legacy" installed only on request
{
  "version": "2.9.5",
  "publishConfig": { "tag": "legacy" }
}

Without it, publishing a patch from a maintenance branch moves latest backwards, and every consumer installing fresh gets the older major until somebody notices.

HAZARD PREVENTION

Symptom: publishConfig names the internal registry, and a publish still went to the public one.

Root cause: The publish command carried an explicit --registry flag — often inherited from a shared release script — and command-line flags outrank the manifest.

Fix: Remove registry flags from release scripts so the manifest is the single source of truth, and add a pre-publish assertion that the effective registry matches publishConfig.registry.

4. Assert the destination in a pre-publish check

{
  "scripts": {
    "prepublishOnly": "node scripts/check-registry.mjs && npm run validate"
  }
}
// scripts/check-registry.mjs
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";

const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const intended = pkg.publishConfig?.registry;
if (!intended) {
  console.error("publishConfig.registry is not set — refusing to publish");
  process.exit(1);
}
const effective = execSync("npm config get registry", { encoding: "utf8" }).trim();
const scope = pkg.name.startsWith("@") ? pkg.name.split("/")[0] : null;
const scoped = scope
  ? execSync(`npm config get ${scope}:registry`, { encoding: "utf8" }).trim()
  : "undefined";

const target = scoped !== "undefined" ? scoped : effective;
if (!target.startsWith(intended.replace(/\/$/, ""))) {
  console.error(`registry mismatch: intended ${intended}, effective ${target}`);
  process.exit(1);
}
console.log(`registry ok: ${target}`);

Verification

# the manifest states an intent
node -p "JSON.stringify(require('./package.json').publishConfig ?? {}, null, 2)"

# the dry run agrees with it
npm publish --dry-run 2>&1 | grep "publishing to"

# after publishing, the package is where and how it should be
npm view @acme/internal-tools --registry="$REGISTRY" --json | node -e '
  const d = JSON.parse(require("fs").readFileSync(0, "utf8"));
  console.log("version:", d.version, "| tags:", Object.keys(d["dist-tags"]).join(", "));
'

Expected: a publishConfig block, a matching publishing to line, and the published version under the intended tag.


Edge Cases / Gotchas

  • Workspaces merge publishConfig per package, not from the root. Each publishable package needs its own block; a root-level one is ignored for the children.
  • publishConfig.provenance only works where attestations are supported. Setting it against a registry that cannot store them fails the publish rather than silently skipping it, which is the preferable behaviour but surprises people mid-migration.
  • The access field cannot make an already-public package private. Visibility is set at first publish and changed afterwards with npm access, not by editing the manifest.
  • Some fields in publishConfig are silently ignored by other package managers. pnpm supports the common ones and adds its own (such as directory-based publishing); Yarn Berry’s handling differs again.
  • A missing publishConfig is itself a signal. For a package that has ever been intended for a specific registry, the absence of the field means the destination is decided by whichever machine runs the publish — worth treating as a finding in any release-process review rather than as a neutral or acceptable default state.
  • It is published metadata. Anyone can read your publishConfig from the registry, which is fine for these fields and a reason never to put anything sensitive there.

Taken together, the four fields turn publishing from an action whose outcome depends on the machine running it into one whose outcome is stated in the repository and reviewed like any other change. That is the real argument for filling them in even where the defaults happen to be correct today — defaults change, machines differ, and the manifest is the only artefact that travels with the package.


Frequently Asked Questions

What can publishConfig actually contain?

Any npm configuration key that applies at publish time. Four matter in practice: registry, access, tag and provenance.

Does publishConfig override the command line?

No — an explicit flag wins. Precedence is command line, then publishConfig, then merged .npmrc, which makes it a strong default rather than a lock.

Why does a scoped package publish privately by default?

npm treats scoped names as restricted unless told otherwise. Setting access: "public" makes the intent explicit and removes the remembered flag from first publish.

Can publishConfig set the dist-tag?

Yes, and it is the safest way to keep a maintenance branch off latest — a release from that branch cannot accidentally become the default install.

Is publishConfig included in the published tarball?

Yes, and it is visible in the package metadata. Harmless for these fields, and a reason never to put anything credential-like there.



↑ Back to Publishing to Private and Alternative Registries