npm Registry Publishing Workflows
Ship npm packages deterministically, with the right files, the right access level, and verifiable provenance. This guide covers the full publish lifecycle — authentication, packing, dry runs, the published file allowlist, publishConfig, distribution tags, scoped versus unscoped names, granular access tokens, two-factor enforcement, and end-to-end CI publishing. Every step is reproducible and auditable, so a release from a developer laptop produces byte-identical results to a release from a hardened CI runner.
The Publish Lifecycle
A clean publish moves through four stages: authenticate against the registry, compute the tarball contents, verify those contents, then upload and tag the result. Treat each stage as a checkpoint with an explicit validation command rather than a single opaque npm publish invocation.
This site treats publishing as part of a broader release discipline documented across Package Publishing & Release Engineering. The mechanics below are the registry-facing half; versioning and changelog automation live alongside them.
The publish lifecycle is a sequence of hooks, each an opportunity to stop a bad release before it becomes immutable. prepublishOnly runs the full build and validation, so a broken artifact cannot be published from a laptop; the pack step assembles the tarball from the files allowlist; and only after those gates does the upload happen. Because a published version can never be overwritten — consumers and lockfiles pin it, integrity hashes verify it — these gates are the last chance to catch a mistake, which is why running them automatically rather than trusting a manual checklist matters so much.
Understanding what triggers each hook prevents surprises. prepublishOnly runs only on an explicit publish, making it the right place for release-only validation; prepare runs on local install and on install from a git dependency, useful for building a package consumed directly from a repository; and the pre/post wrappers bracket named scripts automatically. Placing validation in prepublishOnly specifically means it gates real publishes without slowing down every local install.
Packing: What Actually Ships
The single most common source of broken or bloated packages is a mismatch between what you think ships and what the tarball actually contains. npm computes the tarball from one of two mechanisms: the files allowlist in package.json (preferred) or a .npmignore denylist (fallback). They are mutually informed but the files array always wins as the primary filter.
The files allowlist
Declare exactly what consumers receive. An allowlist is safer than a denylist because new build artifacts or stray files never leak by default.
{
"name": "@acme/widget",
"version": "2.1.0",
"files": [
"dist",
"README.md"
],
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
Several files are always included regardless of the allowlist (package.json, README, LICENSE, and the file named by main), and several are always excluded (.git, node_modules, .npmrc, lockfiles). The exports and types wiring that consumers rely on is detailed in Understanding package.json Fields — if files omits the artifacts those fields point at, installs resolve to missing modules.
.npmignore as a fallback
When no files array exists, npm honours .npmignore (and falls back to .gitignore if .npmignore is absent). This denylist model is error-prone: forget one entry and source maps, test fixtures, or .env files ship publicly.
# .npmignore — only consulted when `files` is absent
src
*.test.ts
tsconfig.json
.eslintrc.cjs
coverage
Prefer files. Reserve .npmignore for trimming subdirectories that files already includes wholesale.
Verify before you publish
Never publish blind. npm pack --dry-run prints the exact tarball manifest — file list, unpacked size, and integrity shasum — without writing anything or contacting the registry.
# Print the tarball contents and size without creating a file
npm pack --dry-run
# Or run the full publish in simulation mode
npm publish --dry-run
Read the file list line by line. If dist/ is missing, your build did not run; if src/ appears, your allowlist is wrong.
What ends up in the tarball is decided by the files allowlist, and the difference between a lean package and a bloated one is whether that allowlist exists. Without it, npm ships everything not excluded by .npmignore — source, tests, configs, CI files — which is larger for every consumer to download and a disclosure of implementation detail. An allowlist naming just the built output (npm always adds the README and LICENSE) keeps the published surface minimal, and npm pack --dry-run makes that surface visible in review so a leak is caught before it is immutable.
The allowlist also interacts with source maps and declarations in ways worth checking. If your maps reference original sources the allowlist excludes, a consumer's debugger follows a dangling path; if a declaration references an internal type file that does not ship, the consumer's type-checker reports any. Verifying the packed contents — that everything the maps and declarations reference actually ships — is what keeps the published package self-consistent rather than subtly broken for anyone who looks closely.
A quick way to see exactly what a publish will ship is npm pack --dry-run, which lists the tarball contents without publishing. Running it in review makes the published surface visible in the pull request, so a leaked source directory, a stray test folder, or a missing dist is caught before the version becomes immutable. It also reveals whether source maps reference files that ship: a map pointing at an original source the files allowlist excludes leaves a consumer's debugger following a dangling path, so verifying that everything the maps and declarations reference is actually in the tarball keeps the published package self-consistent.
publishConfig: access, registry, provenance
publishConfig pins publish-time behaviour into the manifest so it cannot drift between machines or be forgotten on the command line. Anything you would otherwise pass as a flag belongs here.
{
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/",
"provenance": true,
"tag": "latest"
}
}
access—publicorrestricted. Scoped packages default torestricted, which is why first publishes of@scope/namefail without it. See Publishing Scoped Packages to npm.registry— pin the target registry so a stray global.npmrccannot redirect a public package to an internal mirror (or vice versa).provenance— emit a signed attestation linking the tarball to its source commit and build. Wiring this into CI is covered in Setting Up npm Provenance with GitHub Actions.tag— the default dist-tag for this package (more below).
publishConfig is where a package encodes its publish intent so the outcome does not depend on a developer's local environment. Setting registry pins the publish target, so an internal package cannot be pushed to the public index because someone's default registry was misconfigured; setting access to restricted keeps a scoped package private by default; and enabling provenance attaches a signed attestation tying the tarball to its source and build. Each is a small guard against a mistake that is often irreversible — a proprietary package published publicly, a private one exposed, or an unverifiable release.
Distribution Tags
A dist-tag is a named pointer to a specific version. latest is special: it is what npm install <pkg> resolves to when no version is requested. Every other tag is a parallel release channel.
# Publish a prerelease without disturbing `latest`
npm publish --tag next
# Promote a previously published version to `latest`
npm dist-tag add @acme/widget@2.2.0-rc.1 latest
# Inspect all tags for a package
npm dist-tag ls @acme/widget
Publishing a prerelease (e.g. 2.2.0-rc.1) under latest is a frequent accident: every default install suddenly pulls a release candidate. Always publish prereleases under next, beta, or canary and promote deliberately. npm refuses to publish a prerelease version to latest implicitly only when you remember to pass --tag; the manifest publishConfig.tag makes that the default.
Scoped vs Unscoped Packages
Unscoped names (widget) occupy a single global namespace and are first-come-first-served — most short names are taken, and a name collision is a permanent 403. Scoped names (@acme/widget) live under a user or organization namespace you control, so naming conflicts disappear and access policy is centralized.
Scoped packages publish as restricted by default. To publish one publicly you must opt in every time, either with --access public or publishConfig.access. Forgetting this is the canonical first-publish failure, dissected in Fixing npm publish 403 Forbidden Errors.
The scoped-versus-unscoped choice has consequences beyond naming. A scoped name is namespaced to your organization, can be routed wholesale to a private registry, and is published public or restricted; an unscoped name shares the global namespace where anyone can register a collision. For internal packages, scoping is the control that makes a private mapping unambiguous and closes dependency confusion, which is why internal packages should essentially always be scoped.
Authentication: Tokens, Granularity, and 2FA
The registry authenticates every write with a token stored in .npmrc. Token type and scope determine your blast radius if one leaks.
Token types
| Token type | Where it lives | Can it bypass 2FA? | Use for |
|---|---|---|---|
| Classic automation | CI secret | Yes (no OTP prompt) | Legacy CI publishing |
| Granular access | CI secret | Configurable | Modern CI, scoped to specific packages/orgs |
| Web login session | local .npmrc |
No | Interactive local publishing |
Granular access tokens are the modern default: scope them to the exact packages or organization they need, set an expiry, and restrict the write permission. A leaked granular token scoped to one package cannot republish your entire account.
Storing a token for CI
Never commit a token. Inject it through an environment variable referenced by .npmrc:
# .npmrc — committed safely; the actual secret stays in CI env
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
2FA and OTP
Enabling two-factor authentication with the auth-and-writes level forces a one-time password on every publish from interactive sessions:
# Supply the OTP inline for an interactive publish
npm publish --otp=123456
Automation and granular tokens configured to bypass 2FA do not prompt for an OTP — that is what makes unattended CI publishing possible. Keep human accounts on auth-and-writes and reserve OTP-exempt tokens for machine identities only.
Token granularity is the lever that limits the blast radius of a leaked credential, and the modern registry model offers real choices. A classic token carries broad rights — often the ability to publish or deprecate anything the account owns — so its exposure is a serious incident; a granular token can be scoped to a single package and given an expiry, so its exposure is contained and time-limited. Preferring granular, expiring tokens over classic ones, and reading them from a secret store rather than embedding them, turns a credential leak from a catastrophe into a bounded problem.
Two-factor authentication and OIDC address the human and machine sides of publishing respectively. Requiring 2FA for human publishes stops a stolen password alone from pushing a release; OIDC-based publishing from CI removes the standing secret entirely by exchanging the job's verified identity for short-lived publish rights per run. The strongest posture combines them — humans protected by 2FA, automation using OIDC with no long-lived token — so there is no single credential whose theft compromises the package, and the provenance that OIDC enables lets consumers verify the release came from the expected build.
CI Publishing Workflow
The following workflow publishes on a version tag, runs the full validation chain, and uses provenance. It pins the registry, requests the OIDC token needed for provenance, and never echoes the secret.
# .github/workflows/publish.yml
name: Publish to npm
on:
push:
tags:
- 'v*'
permissions:
contents: read
id-token: write # required for provenance attestations
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org/' # writes the auth line into .npmrc
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build artifacts
run: npm run build
- name: Verify tarball contents
run: npm pack --dry-run # fail fast if the allowlist is wrong
- name: Publish
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # consumed by setup-node's .npmrc
Two details matter. First, setup-node with registry-url writes the //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} line into a generated .npmrc, so you do not hand-roll auth. Second, id-token: write is mandatory for --provenance; without it the publish fails before uploading.
A CI publishing workflow turns the release into a reproducible, hands-off operation with no laptop in the loop. The job checks out with full history, installs with a frozen lockfile and ignored scripts, builds and validates the artifact, and publishes with a short-lived credential — ideally an OIDC identity that grants publish rights for the run only, so there is no standing token to leak or rotate. Verifying authentication early with a whoami check makes a broken credential fail fast and name the registry, rather than surfacing as a confusing error mid-publish.
The workflow's safety comes from every input being derived or verified rather than typed. The version is computed from the changes so it cannot collide with an existing one; the tarball contents come from the files allowlist so nothing leaks; the credential is short-lived so it cannot be reused if exposed; and a provenance attestation ties the published artifact to the exact source and build. Automating the publish this way converts the riskiest manual step in the lifecycle — a hand-typed npm publish from a developer's machine — into its most reproducible and auditable one.
Common Pitfalls & Remediation
| Mistake | Impact | Resolution |
|---|---|---|
No files array and an incomplete .npmignore |
Source, tests, or secrets ship publicly | Add an explicit files allowlist and confirm with npm pack --dry-run |
Publishing a scoped package without --access public |
402/403 on first publish |
Set publishConfig.access: "public" or pass --access public |
Prerelease published under latest |
Every default npm install pulls an RC |
Publish with --tag next; promote later via npm dist-tag add |
| Classic automation token leaked in logs | Full-account publish compromise | Use expiring granular tokens scoped to one package; inject via env, never commit |
--provenance without id-token: write |
Publish job fails at attestation step | Add permissions: id-token: write to the job |
| Re-publishing an existing version | cannot publish over previously published version |
Bump the version; npm versions are immutable once published |
The recurring publishing failures come down to a few causes: a token committed to a config file, a missing files allowlist that leaks source, a version bumped by hand that collides with an existing one, and a scoped package accidentally published public. Each is prevented by moving the step into an automated, reviewed pipeline — a secret-store token, an allowlist, a computed version, a pinned publishConfig — so the safe path is the default and the unsafe path is structurally difficult rather than merely discouraged.
Distribution tags and scoped packages
Distribution tags decouple publishing a version from promoting it to latest, the tag most installs resolve. Publishing under next or beta makes a version installable only by consumers who opt in, so a risky release can be validated in the wild while stable consumers stay untouched, and promotion is a metadata move — npm dist-tag add — not a rebuild. This is the mechanism behind canary channels and staged rollouts, and it is why a caret range never accidentally pulls a prerelease: prerelease versions sort below stable and are opt-in.
Scoping is the other axis that shapes a publish. A scoped name (@org/pkg) namespaces the package to your organization, enables per-scope private routing, and is published public or restricted via publishConfig.access; an unscoped name lives in the shared global namespace. For anything internal, scoping is a security decision as much as a naming one — it is what makes a private mapping unambiguous and closes the dependency-confusion vector where an internal name could be resolved from a colliding public package.
Authentication and the CI publishing workflow
Every publish is an authenticated write to a shared namespace, so the credential deserves least-privilege treatment. Prefer a granular token scoped to the single package over a classic token with organization-wide rights, require two-factor authentication for human publishes, and mint automation tokens that CI reads from a secret store rather than embedding a personal credential. The strongest option is OIDC-based publishing, where a CI job exchanges its verified identity for short-lived publish rights per run, so there is no standing token to leak or rotate — and it enables provenance as a byproduct.
The CI publishing workflow ties these together into a hands-off, reproducible release. The job checks out, installs with a frozen lockfile and ignored scripts, builds and validates, and publishes with the short-lived credential — no laptop, no manual npm publish. Verifying authentication early with a whoami check makes a broken credential fail fast and name the registry, rather than surfacing as a confusing error mid-publish. Automating the publish this way turns the riskiest manual step in the lifecycle into its most reproducible one, with every input either derived or verified rather than typed by hand.
A CI publishing workflow turns the release into a reproducible, hands-off operation with no laptop in the loop. The job checks out with full history, installs with a frozen lockfile and ignored scripts, builds and validates the artifact, and publishes with a short-lived credential — ideally an OIDC identity that grants publish rights for the run only, so there is no standing token to leak or rotate. Verifying authentication early with a whoami check makes a broken credential fail fast and name the registry, rather than surfacing as a confusing error mid-publish.
The workflow's safety comes from every input being derived or verified rather than typed. The version is computed from the changes so it cannot collide with an existing one; the tarball contents come from the files allowlist so nothing leaks; the credential is short-lived so it cannot be reused if exposed; and a provenance attestation ties the published artifact to the exact source and build. Automating the publish this way converts the riskiest manual step in the lifecycle — a hand-typed npm publish from a developer's machine — into its most reproducible and auditable one.
Frequently Asked Questions
How do I see exactly what files will be published before I run npm publish?
Run npm pack --dry-run. It prints the complete file manifest, the unpacked size, and the integrity shasum without creating a tarball or contacting the registry. Inspect the list for missing build output (your build did not run) or stray source files (your files allowlist is wrong).
What is the difference between the files field and .npmignore?
files is an allowlist in package.json — only listed paths ship, which is the safe default. .npmignore is a denylist consulted only when no files array exists. If both are effectively present, files is the primary filter. Prefer files so new artifacts never leak accidentally.
Why does my scoped package fail to publish even though I am logged in?
Scoped packages default to restricted access. The registry rejects a public first-publish until you opt in with npm publish --access public or publishConfig.access: "public". Being authenticated is necessary but not sufficient.
Can CI publish without a one-time password if I have 2FA enabled?
Yes. Use an automation token or a granular access token configured to bypass 2FA for that machine identity. Human accounts should stay on auth-and-writes 2FA; only the CI token is OTP-exempt, which keeps unattended publishing both possible and auditable.
Should I commit a token into .npmrc?
Never. Commit only the auth line that references an environment variable, for example //registry.npmjs.org/:_authToken=${NPM_TOKEN}, and store the actual secret in your CI secret store. setup-node with registry-url can generate this line for you at runtime.
What actually decides what ships in my tarball?
The files allowlist. Without it, npm ships everything not excluded by .npmignore — source, tests, configs. Name just the built output (README and LICENSE are always included) and verify with npm pack --dry-run so a leak is caught before it is immutable.
Why publish under a dist-tag instead of latest?
A dist-tag like next or beta makes a version installable only by consumers who opt in, so you can validate a risky release without affecting stable consumers. Promote it to latest with npm dist-tag add once it proves out — a metadata move, not a rebuild.
What's the safest way to authenticate a CI publish?
OIDC-based publishing, which exchanges the job's verified identity for short-lived publish rights per run, so there is no standing token to leak or rotate — and it enables provenance. Failing that, a granular, package-scoped automation token read from a secret store.
What's the single most important safeguard before publishing?
A files allowlist plus a prepublishOnly validation gate, run from CI rather than a laptop. Together they ensure only intended, validated output ships, and since published versions are immutable, catching a mistake before publish is the only chance to catch it at all.
How do I test a publish without actually releasing?
Run npm publish --dry-run to see what would ship without publishing, and npm pack --dry-run to inspect the exact tarball contents and size. For end-to-end validation, publish to a prerelease dist-tag (--tag next) that only opt-in consumers receive, then promote it once verified.
What causes a first-time publish to fail?
Most often a name collision on an unscoped name, a scoped package defaulting to private without --access public, or a token lacking publish rights. Publish under a scope you own, set publishConfig.access explicitly, and use a token scoped to the organization to avoid all three.
Related
- Publishing Scoped Packages to npm — handle
@scope/name, organization namespaces, and public access for scoped first publishes. - Fixing npm publish 403 Forbidden Errors — diagnose token scope, access level, and name-collision failures.
- Setting Up npm Provenance with GitHub Actions — emit signed sigstore attestations from a hardened CI pipeline.
- Understanding package.json Fields — the
exports,main, andtypeswiring that your published tarball must actually contain.