open atlas
↑ Back to track
Node.js, zero to senior NODE · 08 · 05

The release pipeline: versioning, dist-tags, and npm provenance

A release is a tarball plus a contract. The `files` allow-list and a dry-run decide what ships; semver and dist-tags decide who resolves it; provenance proves who built it. You cannot unpublish after 72h, so deprecate and ship a fix.

NODE Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

You shipped 2.1.0 on a Friday, CI was green, npm install reported success in every consumer — and Monday the bug reports said Cannot find module 'your-lib'. The package on the registry was 2 KB and contained only package.json and a README: your dist/ was never published. You had refactored the build into a prepublishOnly step but added a "files": ["lib"] allow-list while the build emitted to dist/, so the tarball matched the allow-list perfectly — an empty match. Installs “succeeded” because npm cheerfully unpacks an empty tarball. You couldn’t unpublish: 200 downloads in 12 hours meant the 72-hour window with no dependents was already gone. The only exit was to publish 2.1.1 and npm deprecate the broken one. A single npm publish --dry-run would have shown the tarball had zero .js files before any of it reached the registry.

The tarball is not your repo — the files field decides

When did you last run npm publish --dry-run before hitting the real publish? If the answer is “never” or “I forgot,” you’ve been publishing on faith — and the registry has no edit button.

npm publish does not upload your directory; it packs a tarball and uploads that. What goes in is governed by a precise, surprising set of rules, and getting them wrong is the most common way to ship a broken or dangerous release. The include set is the "files" allow-list in package.json if present; otherwise everything except what .npmignore excludes, and if there is no .npmignore, npm falls back to .gitignore. The trap: .gitignore is not .npmignore. The moment you add a .npmignore (even to exclude one thing), it fully replaces .gitignore, so files you assumed git was hiding — .env, fixtures, internal docs — are now back in the tarball. A few names are always included no matter what (package.json, README, LICENSE, the main/bin entry) and a few are always excluded (node_modules, .git).

There are two opposite failure modes, both shipped to a public registry that cannot be edited after the fact.

// package.json — the allow-list is the safe default: ship ONLY what you mean to
{
  "name": "your-lib",
  "version": "2.1.0",
  "files": ["dist"],          // ← the allow-list. NOT "lib" if you build to dist/
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "prepublishOnly": "npm run build"   // build runs automatically before publish
  }
}

Too narrow is the hook: "files": ["lib"] when you build to dist/ ships a tarball with no runtime code. Every install succeeds and every require("your-lib") throws MODULE_NOT_FOUND, because the entry point named in main points at a file that isn’t in the tarball. Too broad is worse and irreversible: no files field, a stale .npmignore, or a wildcard that sweeps in .env, a .npmrc with an auth token, test fixtures with real customer rows, or source maps that embed your entire original source. A secret published to the public registry is unrevocable — even after you unpublish or deprecate, anyone who pulled the tarball (and npm’s own mirrors, and the dozens of registry proxies that cache it) has it forever. The only correct response to a leaked credential is to rotate it, immediately, before you even touch the package.

The discipline that prevents both is one command before every release: inspect the exact tarball.

$ npm publish --dry-run        # packs and reports, uploads NOTHING
npm notice 📦  your-lib@2.1.0
npm notice === Tarball Contents ===
npm notice 1.2kB  package.json
npm notice 8.4kB  dist/index.js
npm notice 2.1kB  dist/index.d.ts
npm notice 1.0kB  README.md
npm notice === Tarball Details ===
npm notice package size:  4.8 kB
npm notice unpacked size: 12.7 kB
npm notice total files:   4

You are checking three things in five seconds: the entry file named in main is present (catches the empty-package bug), nothing secret or oversized appears (catches the leak — a sudden jump from 5 KB to 4 MB means you packed node_modules or fixtures), and the total files count is plausible. npm pack --dry-run does the same without needing publish rights. This costs nothing and is the single highest-leverage habit in this entire lesson, because everything after publish is hard or impossible to undo.

Semver and dist-tags: who actually resolves your version

A bare npm install your-lib does not install the highest version on the registry — it installs whatever version the latest dist-tag currently points to. A dist-tag is a named, mutable pointer (latest, next, beta, legacy…) to a specific immutable version; latest is just the one npm picks by default. This indirection is the mechanism behind safe rollouts. When you npm publish, the new version becomes latest unless you say otherwise — and that default is the second-most-common footgun after files, because publishing a prerelease without a tag silently makes it everyone’s default install.

# publish a prerelease WITHOUT moving everyone's default
$ npm publish --tag next          # 3.0.0-beta.1 → tag "next", latest untouched
# early adopters opt in explicitly:
$ npm install your-lib@next

# ship a fix to an OLD major while v3 is current — give it its own tag
$ npm publish --tag legacy        # 2.5.4 → "legacy", does not become latest

# promote a beta to the real release once it's proven, with NO republish:
$ npm dist-tag add your-lib@3.0.0 latest

The deeper contract is semver itself. Consumers pin ranges, overwhelmingly ^ (caret): "^2.1.0" means “any 2.x at or above 2.1.0”. That range is a promise you make about your version numbers — patch and minor are backward-compatible, major is allowed to break. Break it and the failure mode is brutal and silent: publish a breaking change as a patch (2.1.4 → 2.1.5) and every ^-ranged consumer pulls it on their next npm install or CI run, with no opt-in and no warning, and their build breaks in a PR that didn’t touch your library. This is exactly how a “tiny fix” takes down hundreds of downstream CI pipelines in an afternoon. Because the cost of a wrong version number is borne by everyone downstream, senior teams stop hand-bumping: tools like changesets or semantic-release read your conventional commits (fix: → patch, feat: → minor, feat!:/BREAKING CHANGE → major) and compute the version, changelog, and tag mechanically, so the version reflects the actual change instead of a tired human’s guess at 5 PM.

Pick the best fit

v3.0.0 is current and `latest`. You've found a security fix that must go to users still on the v2 line, who pin `^2.0.0`. How do you ship it so v2 users get it and nobody jumps majors?

Provenance and tokens: proving who built the tarball

The registry trusts whoever holds a valid publish token. That is the whole attack surface of the modern supply chain from the publisher side: a single leaked, long-lived publish token lets an attacker npm publish a malicious patch (2.1.4 → 2.1.5) that every ^-ranged consumer auto-installs on their next build — no compromise of your source repo required, no review, no trace. This is not theoretical; it is the shape of the real-world incidents where popular packages suddenly shipped credential-stealers. The defenses are layered, and a senior pipeline uses all of them.

First, token hygiene. Never publish from a personal access token sitting on a laptop or in a CI secret indefinitely. Use a granular automation token: scoped to a single package, no broader account access, ideally short-TTL, and --read-and-write only for what it must touch. Require 2FA on the account, and for the publish step prefer OIDC so there is no static secret to leak at all. The blast radius of a stolen scoped token is one package; the blast radius of a personal token is everything you can publish.

Second, provenance. Publishing with --provenance from a supported CI runner (GitHub Actions, GitLab) makes npm generate a signed, verifiable attestation that cryptographically links the published tarball to the exact source commit and the build that produced it, recorded in a public transparency log. Consumers (and npm audit signatures) can then verify that a given version really came from the repo and workflow it claims to — so a tarball published from a stolen token outside your CI fails verification, and the green “provenance” badge on npm is a checkable claim, not a logo. It does not require a static publish secret because it rides on the CI runner’s OIDC identity.

# .github/workflows/release.yml — publish with provenance, no static npm token
permissions:
  contents: read
  id-token: write          # ← REQUIRED: lets the runner mint an OIDC token for provenance
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      - run: npm ci
      - run: npm publish --provenance --access public
        # no NODE_AUTH_TOKEN needed when the package + repo are configured for
        # trusted publishing (OIDC); otherwise a SCOPED automation token, never a
        # personal one, is the fallback.

The failure mode this closes is precise: even if your publish token leaks, a backdoored version published from the attacker’s machine has no valid provenance for your repo, so verifying consumers and the npm UI flag it as untrusted — turning a silent supply-chain compromise into a visible, refusable one. Provenance does not stop a leaked token from publishing; it makes the result detectable, which is the realistic win.

Why this works

Why can’t you just delete a bad release? In March 2016 the author of left-pad — an 11-line function — unpublished it from npm in a dispute. Thousands of packages depended on it transitively (including Babel and React tooling), and CI and deploys broke across the industry within minutes, because a published-and-depended-on version simply vanished. npm restored it and then changed the rules permanently: you can npm unpublish a specific version only within 72 hours of publishing it, and only if no other package depends on it and it hasn’t been downloaded by anyone but you. Past that window, or once anything depends on it, the version is effectively immutable. The whole npm ecosystem now assumes installed versions never disappear — which is exactly why dry-run and provenance matter: you almost never get to take a mistake back.

Deprecate vs unpublish: how you actually retract

Because unpublish is fenced off by the 72-hour rule, the real retraction tool is npm deprecate. It does not remove anything — the version stays fully installable — but it attaches a warning that npm prints whenever someone installs that version (directly or transitively). This is exactly right for “this release has a serious bug, stop using it” without the violence of yanking code that working installs may still pin.

# the bad release stays installable but every install now warns
$ npm deprecate your-lib@2.1.0 "Empty package — broken build. Upgrade to >=2.1.1."

# you can deprecate a whole range, e.g. a known-vulnerable span:
$ npm deprecate your-lib@"<2.1.1" "Security fix in 2.1.1, please upgrade."

# undo a deprecation by deprecating with an empty message:
$ npm deprecate your-lib@2.1.0 ""

So the canonical recovery from a bad release is not “delete it” — it is publish a corrected version, then deprecate the bad one pointing at the fix. The numbers make the stakes concrete: a leaked secret is unrevocable the instant the tarball is fetched; an empty or broken package can’t be removed after 72 hours or a single external download; and the only edits you get post-publish are a deprecation warning and a moved dist-tag. Everything upstream of npm publish — the files allow-list, the dry-run, the computed semver, the scoped token, the provenance attestation — exists precisely because the registry is, for all practical purposes, append-only.

Quiz

You published `your-lib@4.2.0` two days ago. It has a critical bug, has been downloaded 300 times, and three packages now depend on it. What is the correct way to retract it?

Recall before you leave
  1. 01
    Why does `npm install your-lib` sometimes install an older version than the highest one published, and how do you publish a prerelease without disturbing that?
  2. 02
    A publish token for your package leaks. What can the attacker do, and how does provenance change the outcome even though it can't stop the publish?
Recap

A release is three separable contracts, and every one of them is decided before npm publish because almost nothing is reversible after it. What ships is a tarball, not your repo: the "files" allow-list (or .npmignore, which fully replaces .gitignore the moment it exists) decides the contents — too narrow ships an empty package whose require fails for every consumer, too broad ships .env or source maps to a public registry that can never edit them, so a leaked secret must be rotated, not unpublished. The fix is a --dry-run before every release to read the exact tarball, plus a prepublishOnly build behind the allow-list. Who resolves it is set by semver and dist-tags: npm install follows the latest pointer, not the highest version, so prereleases go under --tag next and old-major fixes under their own tag, and breaking changes must never ride a patch — automate the version with changesets/semantic-release off conventional commits so the number reflects the change. Who built it is proven by provenance: publish from CI with --provenance and a scoped automation token (not a personal one), behind 2FA/OIDC, so a tarball from a leaked token outside your CI fails verification and a silent supply-chain swap becomes a visible one. And when a bad release escapes, you can’t take it back — unpublish is fenced to 72 hours with no dependents (the left-pad rule), so the real retraction is to publish a corrected version and npm deprecate the broken one. Now when you see a broken publish — empty tarball, leaked secret, or a caret-range breakage — you’ll know exactly which gate to add upstream of npm publish to prevent it next time.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.