Back to publishing & release Automate semantic versioning Publish to the npm registry Harden the supply chain

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.

npm publish pipeline Authentication feeds into packing, packing into a dry-run verification, then publish uploads the tarball to the registry which records a dist-tag. authenticate token + OTP npm pack files allowlist --dry-run verify contents npm publish access + provenance registry tarball + dist-tag
The publish pipeline: authenticate, pack against the allowlist, verify with a dry run, then upload with an explicit access level and provenance.

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"
  }
}
  • accesspublic or restricted. Scoped packages default to restricted, which is why first publishes of @scope/name fail without it. See Publishing Scoped Packages to npm.
  • registry — pin the target registry so a stray global .npmrc cannot 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

Package Publishing & Release Engineering