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.
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.
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).
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.
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.
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.
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 |
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.
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.