Using publishConfig for Registry and Access
Pin a package's publish destination and visibility in the manifest: registry, access, tag and provenance in publishConfig, and why ambient config is not enough.
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
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
{
"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:
publishConfignames the internal registry, and a publish still went to the public one.Root cause: The publish command carried an explicit
--registryflag — 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
publishConfigper package, not from the root. Each publishable package needs its own block; a root-level one is ignored for the children. publishConfig.provenanceonly 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
accessfield cannot make an already-public package private. Visibility is set at first publish and changed afterwards withnpm access, not by editing the manifest. - Some fields in
publishConfigare 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
publishConfigis 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
publishConfigfrom 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.
Related
- Publishing to Private and Alternative Registries — the configuration this pins down.
- Managing Prerelease and Dist-Tags on npm — the tag mechanics
publishConfig.tagparticipates in. - Configuring .npmrc Auth Tokens in CI — the credentials that must match the destination pinned here.