Package Publishing & Release Engineering
Take a built package from a working dist/ folder to a versioned, signed, reproducible release on the npm registry. This guide is for library authors and platform teams who need publishing to be deterministic, auditable, and safe enough to run unattended in CI — not a manual npm publish from a laptop.
Publishing is the last mile where a healthy package becomes a liability if done carelessly. A leaked token, a missing files allowlist, an unsigned tarball, or a botched version bump all ship to thousands of installs before anyone notices. Release engineering treats that last mile as code: every step gated, versioned, and traceable back to a Git commit.
How This Section Is Organized
Three connected topics carry the full publishing lifecycle. Semantic Versioning and Release Automation governs how you choose the next version number and bump it automatically from commit history, including dist-tags and prerelease channels. npm Registry Publishing Workflows covers the mechanics of the publish itself — scoped packages, authentication, the 403 errors that block first-time publishes, and provenance attestation. Supply-Chain Security Hardening closes the loop with audit thresholds, lockfile linting, and SLSA build provenance so that what you publish is exactly what you built. Read them in that order if you are standing up a release pipeline from scratch; jump straight to the relevant one if you are debugging a single broken stage.
Version Strategy: Semver, Prerelease Channels, and Dist-Tags
A published version number is a contract. Consumers pin ranges like ^2.3.0 against the promises encoded in semantic versioning: patch releases fix bugs, minor releases add backward-compatible features, and major releases may break the public API. Violate that contract once — ship a breaking change in a minor bump — and you break every downstream npm ci that resolves your package.
Decide the bump from the change, not from how big it feels. A renamed export, a removed function, a stricter type signature, or a raised engines floor are all breaking. New optional parameters and new exports are minor. Internal refactors and bug fixes are patch.
# Inspect what npm thinks the next versions would be
npm version --help
# Manual bumps create a commit + git tag by default
npm version patch # 2.3.0 -> 2.3.1
npm version minor # 2.3.1 -> 2.4.0
npm version major # 2.4.0 -> 3.0.0
Prerelease versions let you ship a release candidate without disturbing the stable line. A version like 3.0.0-beta.2 sorts below 3.0.0, so range matchers such as ^2.0.0 never resolve to it, and a fresh npm install your-pkg never picks it up. Pair every prerelease with a dist-tag so consumers opt in explicitly:
# Publish a prerelease under the "next" channel, not "latest"
npm version 3.0.0-beta.0
npm publish --tag next
# Consumers opt in deliberately
npm install your-pkg@next
npm install your-pkg@beta
The latest dist-tag is what a bare npm install your-pkg installs. Never publish a prerelease without --tag, or it silently becomes latest and every consumer pulls an unstable build. The full set of bumping rules, ranges, and automated changelog generation is covered in Semantic Versioning and Release Automation.
The Publish Lifecycle: Hooks, the Files Allowlist, and Exports
npm publish runs a fixed sequence of lifecycle scripts. Understanding the order prevents the classic mistake of shipping uncompiled source or, worse, never running the build at all.
{
"name": "@scope/widget",
"version": "2.3.1",
"scripts": {
"build": "tsup",
"test": "vitest run",
"prepublishOnly": "npm run test && npm run build"
}
}
prepublishOnly runs only on npm publish (not on local npm install), which makes it the correct gate for tests and the production build. The older prepare script runs on both publish and on npm install from a Git URL, so reserve it for steps that must also run for Git-dependency consumers. If your dist/ is committed, you can skip the build hook — but committing build output is a maintenance trap; prefer building in prepublishOnly or in CI.
What actually ends up in the tarball is the highest-leverage decision in the whole lifecycle. Use the files allowlist — an explicit include list — rather than .npmignore, which is an error-prone denylist. With files, anything you forget to mention is simply excluded; with .npmignore, anything you forget to exclude leaks into the published package, including .env files, test fixtures, and internal scripts.
{
"files": ["dist", "README.md"],
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}
Note that package.json, README, LICENSE, and the file pointed to by main are always included regardless of files. The exports map is the public surface of the package — it both routes consumers to the right artifact and blocks access to anything not listed, so deep imports into your internals fail loudly. Getting exports, main, and files consistent is the same problem as configuring dual modules; the field-level details live in Understanding package.json Fields, and the routing rules for shipping both formats are in ESM and CJS Interoperability.
Always dry-run the pack before publishing to see the exact file list and tarball size:
# Show every file that would ship, plus integrity hash and size
npm pack --dry-run
# Produces a real tarball you can inspect with `tar -tf`
npm pack
Registry Authentication: Tokens, Granular Access, and 2FA
Authentication is where most first publishes fail with a 403 or 404. The registry needs to know who you are and that you are allowed to write to that package name.
For automation, never use a classic publish token that grants account-wide write access. Use a granular access token scoped to the specific packages or scope it may publish, with a short expiry and an IP allowlist where possible. Store it as a CI secret and expose it through .npmrc at build time only:
# .npmrc — written in CI, never committed with a real token
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
@scope:registry=https://registry.npmjs.org/
# In CI, the token comes from a secret, not the file
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc
npm whoami # confirms the token authenticates before you publish
Two-factor authentication adds a one-time password (OTP) requirement on publish. Interactive publishes prompt for it; automation cannot answer a prompt, so you either set the package's 2FA mode to "authorization only" (2FA for login and settings, but automation tokens may publish without an OTP) or pass --otp from a provisioning step. The cleanest path is a granular automation token plus the registry's "automation" 2FA exemption, which keeps humans on full 2FA while letting CI publish unattended. The specific failure modes — wrong scope, missing access, expired token — are diagnosed in Fixing npm publish 403 Forbidden Errors.
Provenance and Supply-Chain Integrity
A published tarball, by default, is an unsigned blob with no verifiable link back to the source that produced it. Provenance closes that gap. When you publish with --provenance from a supported CI environment, npm generates a signed attestation binding the tarball to the exact Git commit, repository, and workflow run that built it, recorded in a public transparency log.
# Requires OIDC-capable CI (e.g. GitHub Actions) and a public package
npm publish --provenance --access public
Provenance is meaningful only when the build is itself trustworthy: the published bytes must come from the attested commit and nothing else. That depends on a frozen, verified dependency graph at build time — installs that reproduce exactly from the committed lockfile, as covered in Lockfile Management Strategies, so a tampered transitive dependency cannot slip into the artifact. The deeper hardening — audit gates, lockfile linting, and SLSA build levels — is the subject of Supply-Chain Security Hardening, and the CI wiring specifically for npm attestations is in Setting Up npm Provenance with GitHub Actions.
Release Automation in CI
Manual releases drift: someone forgets to bump, forgets to tag, publishes from a dirty tree, or ships from a stale dist/. Automating the release into CI makes every publish identical and removes the laptop — and its long-lived token — from the loop entirely.
The canonical flow ties version, pack, and publish into a single job triggered on a merge to main:
# .github/workflows/release.yml
name: release
on:
push:
branches: [main]
permissions:
contents: write # create tags / commits
id-token: write # OIDC for provenance attestation
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for version inference
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org' # injects auth into .npmrc
cache: 'npm'
- run: npm ci # frozen install from the lockfile
- run: npm run build # produce dist/ deterministically
- run: npm test # gate the release on green tests
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Two automation styles dominate, and both layer on top of this skeleton: changeset-driven releases, where contributors declare intent in changeset files and a bot batches the version bump into a release PR, and commit-driven releases, where the version is inferred from Conventional Commit messages. Both are detailed under Semantic Versioning and Release Automation.
Common Pitfalls & Remediation
| Mistake | Impact | Resolution |
|---|---|---|
Publishing a prerelease without --tag |
The unstable build becomes latest; every npm install pulls it. |
Always pass --tag next (or beta) for prereleases; reserve latest for stable. |
Using .npmignore instead of files |
Forgotten excludes leak .env, tests, and source into the tarball. |
Switch to a files allowlist; verify with npm pack --dry-run. |
Build only in prepare or not at all |
Stale or uncompiled dist/ ships to consumers. |
Run tests and build in prepublishOnly; never commit dist/. |
| Long-lived account-wide publish token in CI | A single leaked secret can hijack every package on the account. | Use a granular, scoped, short-lived automation token; store it as a CI secret. |
| Breaking change shipped as a minor bump | Downstream ^ ranges silently break on npm ci. |
Classify by public-API impact; major-bump any removal, rename, or signature change. |
First scoped publish without --access public |
Scoped packages default to private and fail or stay hidden. | Pass --access public (or set publishConfig.access) on the first publish. |
Frequently Asked Questions
What is the difference between a dist-tag and a Git tag?
A Git tag marks a commit in your repository; a dist-tag is a named pointer on the npm registry (like latest, next, or beta) that maps to a published version. Installing pkg@next resolves the dist-tag, not a Git ref. They are independent, though release automation usually creates a matching Git tag for each published version.
Should I commit my dist/ directory so consumers always have built output?
No. Committed build output drifts from source, bloats the repository, and produces noisy diffs. Build in prepublishOnly or in CI so the published tarball is always freshly compiled from the tagged commit, and keep dist/ in .gitignore.
Do I need --access public every time I publish a scoped package?
Only the first publish of a new scoped package needs it, because scoped packages default to restricted. After the package exists as public, subsequent publishes inherit that access. Setting "publishConfig": { "access": "public" } in package.json makes it automatic and removes the flag from your command.
Can I unpublish a version if I made a mistake?
Unpublishing is heavily restricted: within 72 hours for versions with no dependents, and effectively blocked afterward to protect the ecosystem from broken installs. Treat publishes as permanent — prefer publishing a corrected patch version and, if needed, deprecating the bad one with npm deprecate.
How does provenance help if my npm token is stolen? A stolen token still lets an attacker publish, but provenance makes the tampering visible: a legitimately published version carries a signed attestation tying it to your repository and CI run, while a malicious publish from a stolen token cannot forge that link from your real source. Consumers and scanners can verify provenance and flag the discrepancy.
Related
- Semantic Versioning and Release Automation — choosing version numbers and bumping them automatically from commit history.
- npm Registry Publishing Workflows — the mechanics of authentication, scoped packages, and the publish itself.
- Supply-Chain Security Hardening — audit gates, lockfile linting, and build provenance for safe releases.
- Understanding package.json Fields — the
exports,files, andmainfields that define your published surface. - Lockfile Management Strategies — reproducible installs that make provenance attestations trustworthy.
← Home