Skip to content
oss-kit
GitHubInstall

npm

npm is present when a package.json turns up anywhere in the checkout, or any of package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or bun.lock does. Depth and directory do not matter, and neither does whether the manifest declares anything publishable: the present axis exists to find dependency trees, and a tree under site/ resolves packages exactly like one at the root.

npm is shipped when a manifest the repository publishes from carries name and version, which npm documents as required, and publish evidence exists: a release workflow running npm publish, or an existing page on npmjs.com for that name.

Source: package.json fields.

Three cases decide most arguments:

  • "private": true settles the shipped answer on its own. npm’s own words: “If you set "private": true in your package.json, then npm will refuse to publish it.” The manifest is still present.
  • A docs-only manifest, a package.json under docs/, site/, www/, or examples/ whose whole job is building a page or an example, is present and not shipped. oss-kit itself is this case twice over: a root manifest marked "private": true for its maintenance scripts and a site/package.json that builds the docs, with a bun.lock beside each, which puts npm firmly on the present axis in a repository that publishes no package at all.
  • A lockfile with no manifest beside it is present. So is a workspace root whose package.json carries only workspaces and devDependencies; each member answers the shipped question for itself.

npm takes the registry-push track. npm publish uploads a built tarball to the registry under a credential, so there is an upload to secure and a credential to scope, which is what assigns the track. The roster records "track": "registry-push" for npm and the release area’s preamble names npm in its registry-push list.

Verified 2026-07-31 against https://docs.npmjs.com/cli/v11/configuring-npm/package-json.

Shields.io serves this one at img.shields.io/npm/v/PACKAGE, and its route accepts a scope as part of the package name, so a scoped package needs no escaping and reads img.shields.io/npm/v/@scope/name. A further path segment selects a dist-tag: img.shields.io/npm/v/PACKAGE/next reports whatever next currently points at.

The default label is npm. Keep it. A dist-tag badge relabels itself to npm@next on its own, which is the one case where the label carries information the package name does not, so leave that alone too. Reach for the dist-tag form only when the README is documenting a pre-release channel; the plain form is what a reader wants to see.

Link the badge to the package page:

[![npm](https://img.shields.io/npm/v/PACKAGE)](https://www.npmjs.com/package/PACKAGE)
npm install PACKAGE

npm install is the documented command name. add, i, in, ins, and a dozen more are aliases of it, so a README showing npm i shows the same command in a form the reader has to decode. Write the package name exactly as published, scope included.

One command is enough. A README that stacks npm, Yarn, pnpm, and Bun rows for the same package spends four lines restating one fact, and every row is a line that can go stale independently. Show the second manager only where installing under it genuinely differs.

Verified 2026-07-31 against npm-install, NPM Version badge, and services/npm/npm-version.service.js plus services/npm/npm-base.js and services/version.js in badges/shields.

package.json declares the license in a single license string holding an SPDX license expression. npm’s field reference gives "license": "BSD-3-Clause" for one identifier and "(ISC OR GPL-3.0)" for a choice between two. The older forms, a license object and a licenses array, are deprecated; npm pkg fix rewrites them.

Two values name no license at all, and each changes what there is to compare. "SEE LICENSE IN <filename>" points at a file the package ships at its top level, so it is the next thing to read rather than the answer. "UNLICENSED" marks proprietary work, which is a package granting nobody a license rather than a package whose license is unstated.

The manifest side of R-COM-01 is therefore the license string of every package.json the repository publishes from, and the file side is the root license file. A workspace has one value per member manifest, so several comparisons rather than one. A manifest carrying "private": true and no license key declares nothing, which leaves that manifest out of the comparison without leaving the repository out of the rule.

Source: package.json fields.

Tidelift’s platform name for this ecosystem is npm, so the GitHub funding file’s entry reads tidelift: npm/<package-name> against the name the package publishes under. The accepted key format is PLATFORM-NAME/PACKAGE-NAME, described in github.md beside the rest of the accepted keys.

The roster at skills/oss-audit/ecosystems.json records "tidelift": "npm" and is the canonical copy. This line exists because a single-skill install of oss-community does not carry that file; where the two disagree, the roster is right and this line is corrected to it.

Verified 2026-07-31 against package.json fields and Displaying a sponsor button in your repository.

On GitHub Actions, actions/setup-node installs a Node version and puts it on the PATH. Drive the matrix from engines.node in package.json, which is where a package states the Node versions it works on. npm treats that field as advisory unless the installing user sets engine-strict, so it is a support claim rather than an enforced constraint, and that is exactly the claim the matrix has to cover.

strategy:
matrix:
node-version: ['20', '22', '24']
steps:
- uses: actions/setup-node@v7 # oss-harden pins this to a commit SHA
with:
node-version: ${{ matrix.node-version }}

node-version-file reads package.json, .nvmrc, .node-version, or .tool-versions. It resolves one version, so it describes the contributor toolchain and does not build a matrix; node-version wins when both are given.

On GitLab, run the job in the Node official image and vary the tag with parallel:matrix, as references/gitlab.md shows.

Sources: actions/setup-node, package.json, nodejs/docker-node.

actions/setup-node caches the package manager’s global download data rather than node_modules. Since v5 it enables caching by default when no cache input is given; v6 narrowed the automatic case to projects that name npm in devEngines.packageManager or in the top-level packageManager field, and left yarn and pnpm to an explicit cache: yarn or cache: pnpm. cache-dependency-path names the file whose hash goes into the primary key.

- uses: actions/setup-node@v7 # oss-harden pins this to a commit SHA
with:
node-version: ${{ matrix.node-version }}
cache: npm
cache-dependency-path: package-lock.json

The roster lists five lockfiles under npm, package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, and bun.lock, so read which one the repository actually commits before naming a path. setup-node’s own README recommends package-manager-cache: false for a workflow running with elevated privileges.

On GitLab there is no equivalent action. Key cache:key:files on the lockfile and point npm’s cache at a project-local directory, which is the npm ci --cache .npm --prefer-offline shape in references/gitlab.md; GitLab caches only paths inside the project directory, so npm’s default global cache under the home directory cannot be cached where it lies.

Sources: actions/setup-node, GitLab CI/CD YAML reference, cache.

npm test runs whatever the test property of the scripts object in package.json holds, so package.json is where the project declares the command and CI calls the alias rather than the runner. npm init scaffolds that property as echo "Error: no test specified" && exit 1, which is a repository with no suite rather than one with a failing suite; read the property’s body before treating its presence as an answer.

Sources: npm test, package.json.

Verified 2026-07-31 against https://github.com/actions/setup-node, https://docs.npmjs.com/cli/v11/commands/npm-test, https://docs.npmjs.com/cli/v11/configuring-npm/package-json, https://github.com/nodejs/docker-node, and https://docs.gitlab.com/ci/yaml/.

Dependabot’s npm value covers npm, Yarn v1 through v4, and pnpm, with both version updates and security updates. Bun is a separate value, bun, supported from Bun 1.1.39 for the text bun.lock and not for the legacy binary bun.lockb, and it carries version updates only: Dependabot’s table marks security updates as not supported for it. A Bun project therefore gets scheduled bumps and no advisory-driven ones, which is the whole-feed residual the Vulnerability watch section below picks up.

On GitLab, where nothing equivalent to Dependabot ships with the platform, the Renovate managers are npm and bun.

Every package manager here writes a lockfile without being asked: package-lock.json or npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, or bun.lock. The frozen-install command differs by manager, and each fails rather than re-resolving:

  • npm ci, which needs an existing package-lock.json or npm-shrinkwrap.json, and where “If dependencies in the package lock do not match those in package.json, npm ci will exit with an error, instead of updating the package lock”.
  • yarn install --immutable, documented as “Abort with an error exit code if the lockfile was to be modified”.
  • pnpm install --frozen-lockfile, which “doesn’t generate a lockfile and fails to install if the lockfile is out of sync with the manifest / an update is needed or no lockfile is present”.
  • bun install --frozen-lockfile, which installs the exact versions in the lockfile and exits with an error when package.json disagrees with bun.lock.

Yarn and pnpm both default that flag on in CI, and pnpm’s default also requires a lockfile to be present. Write the flag anyway. A default that depends on the runner exporting CI is not evidence in a workflow file, and the same command run locally or on a runner that does not set it silently re-resolves.

What breaks the first time you commit the lockfile

Section titled “What breaks the first time you commit the lockfile”

Regenerate the lock with no node_modules present, or it records one platform. A dependency gated by the os or cpu field is optional, and npm records the variant it resolved. Regenerating while a node_modules tree built on the maintainer’s own machine is present has been reported to record that machine’s variant alone, and npm ci on a Linux runner then skips the variant the runner needs rather than failing. Delete the lockfile and node_modules together, then reinstall. npm install takes --os, --cpu, and --libc, each documented as an override of the platform native modules are installed for, so one machine can confirm the lock covers the runner.

Pin the npm version, not only the Node version. The lockfile format is versioned against the npm major rather than the runtime. lockfileVersion 2 is what npm 7 and 8 write, and 3 is what npm 9 and above write. A job whose bundled npm predates the one that wrote the file rewrites the format during install, which surfaces as a lockfile the job modified rather than as a version difference.

Every lockfile needs a directory the updater is configured to read. Dependabot reaches the directories its configuration lists and no others, so a workspace or a nested package whose directory is unlisted keeps a lockfile nobody bumps. List each one, and give the project a single command that regenerates them all.

Verified 2026-07-31 against npm install, package-lock.json, and npm/cli issue 4828.

CodeQL supports JavaScript and TypeScript, so CodeQL default setup covers this ecosystem on GitHub with no workflow to maintain. GitLab’s SAST table lists JavaScript and TypeScript under its Semgrep-based analyzer with GitLab-managed rules, available in the tier that ships the open source analyzers.

The dependency graph parses package-lock.json as its recommended file, with package.json as an additional file, and separately parses yarn.lock and pnpm-lock.yaml. All three carry static transitive dependency support, so a repository committing one of them gets direct and transitive coverage. bun.lock appears nowhere in that table, so a Bun project’s graph falls back to package.json and reports direct dependencies alone.

Advisories come from the GitHub Advisory Database, which names this ecosystem Npm against the npmjs.com registry.

osv-scanner reads package-lock.json, pnpm-lock.yaml, yarn.lock, and bun.lock, so it closes both the Bun parsing gap and the Bun security-update gap in one job.

Verified 2026-07-31 against Dependabot supported ecosystems and repositories, Dependency graph supported package ecosystems, GitHub Advisory Database, npm-ci, yarn install, pnpm install, bun install, CodeQL supported languages and frameworks, GitLab SAST, Renovate managers, and osv-scanner supported languages and lockfiles.

Concrete flow for the decisions SKILL.md makes, for a package published to the public npm registry. npm accepts three trusted publishing providers: GitHub Actions, GitLab CI/CD on GitLab.com shared runners, and CircleCI Cloud. This file covers GitHub Actions and GitLab CI/CD because oss-kit’s forge scope is GitHub and GitLab. Self-hosted runners are not supported. Trusted publishing needs npm CLI 11.5.1 or newer and Node 22.14.0 or newer. Staged publishing needs npm CLI 11.15.0 or newer. Resolve an exact supported Node release from Node’s official archive immediately before writing the workflow, and read the npm version that release bundles from the same archive rather than assuming one. Do not install a floating npm range in the credentialed job.

Source: npm Docs, Trusted publishing for npm packages, npm Docs, Staged publishing, and Node.js download archive, Node 24.18.0.

Read the root package.json. If it declares workspaces, or a pnpm-workspace.yaml exists, the repository is a monorepo: enumerate every workspace package.json. Only a package without "private": true needs npm settings. Get the owner and repository from the repository field, normalizing git+https://github.com/owner/repo.git, github:owner/repo, or owner/repo, falling back to git remote get-url origin. Check whether each public package is already published with npm view <name> version; an E404 means it is not, and Not yet published packages below covers that case.

On npmjs.com, open https://www.npmjs.com/package/<name>/access for the package (in a monorepo, once per public workspace package) and add a trusted publisher.

Enter:

  • Organization or user: the owner from Step 1
  • Repository: the repository name from Step 1
  • Workflow filename: release.yml, the filename only, not the full path
  • Environment: the approval environment name from SKILL.md Step 4, for example release
  • Allowed actions: enable npm stage publish and leave npm publish disabled, so a compromised or bypassed CI run still cannot ship a version without the 2FA approval in Step 4 below

In the workflow, the publish job needs:

permissions:
id-token: write
contents: read
steps:
- uses: actions/setup-node@v7
with:
node-version: '24.18.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false

No NPM_TOKEN or any other registry secret is needed; npm’s CLI exchanges the workflow’s OIDC token for a publish token automatically once the trusted publisher above is configured.

Enter, at https://www.npmjs.com/package/<name>/access:

  • Namespace: the GitLab username or group name that owns the project
  • Project name: the project name
  • Top-level CI file path: the path to the pipeline file, for example .gitlab-ci.yml
  • Environment name: the approval environment name from Step 4, for example release
  • Allowed actions: enable npm stage publish only, matching GitHub Actions above

In the pipeline, the publish job needs:

id_tokens:
NPM_ID_TOKEN:
aud: "npm:registry.npmjs.org"

Write the hardened release workflow (Step 3)

Section titled “Write the hardened release workflow (Step 3)”

The publish job installs no dependencies, uses no cache, and calls no third-party action beyond checkout, actions/setup-node, and actions/download-artifact; anything else running in that job could publish the package. Build the package in a separate job, upload the output as an artifact, and download it in the publish job:

name: Release
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: '24.18.0'
package-manager-cache: false
- run: npm ci --ignore-scripts
- run: npm test # oss-ci decides the actual command from CONTRIBUTING.md (R-CI-02)
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: '24.18.0'
package-manager-cache: false
- run: npm ci --ignore-scripts
- run: test "${GITHUB_REF_NAME#v}" = "$(node -p "require('./package.json').version")"
- run: npm run build
- run: npm pack --ignore-scripts
- uses: actions/upload-artifact@v7
with:
name: package-tarball
path: '*.tgz'
retention-days: 1
publish:
runs-on: ubuntu-latest
needs: [test, build]
environment: release
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
name: package-tarball
path: package/
- uses: actions/setup-node@v7
with:
node-version: '24.18.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- run: npm stage publish package/*.tgz --ignore-scripts

The version comparison assumes tags such as v1.2.3; derive the comparison from the repository’s actual tag format. npm pack creates the exact tarball handed to npm stage publish, so the credentialed job does not check out source, install dependencies, or rebuild. package-manager-cache: false prevents setup-node from automatically restoring a package-manager cache in the credentialed job.

oss-harden pins every uses: line above to a commit SHA and sets the test and build jobs’ minimal permissions, including the contents: read this skill left off them; do not pin them or narrow them here. This skill writes only the grants a job needs to authenticate, to publish, and to attest: the publish job’s two above, and the github-release job’s four in Step 6. On GitLab CI/CD, use separate test and build jobs, pass the resulting .tgz as an artifact, and make the publish job run only npm stage publish package/*.tgz --ignore-scripts. Give that job environment: name: release with when: manual, plus the id_tokens block from Step 2.

If an existing workflow uses secrets.NPM_TOKEN, remove it from the YAML now and tell the user to delete the corresponding secret and revoke the token once the new flow is verified.

Two gates apply together, not as alternatives:

The workflow-level gate is the environment: release on the publish job above, with required reviewers configured at https://github.com/<owner>/<repo>/settings/environments, or, on GitLab Premium or Ultimate, a protected environment with approval rules at the project’s Settings > CI/CD > Protected environments. GitHub required reviewers work for public repositories on current plans; private or internal repositories need GitHub Enterprise Cloud. If the forge plan lacks this gate, keep the environment binding because it is part of the npm trusted-publisher identity and rely on the registry gate below for R-PUB-04.

Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser.

ENV=release
GHUID=$(gh api user --jq .id)
gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \
-F wait_timer=0 \
-F prevent_self_review=false \
-f 'reviewers[][type]=User' -F "reviewers[][id]=$GHUID" \
-F 'deployment_branch_policy[protected_branches]=false' \
-F 'deployment_branch_policy[custom_branch_policies]=true'
gh api -X POST "repos/{owner}/{repo}/environments/$ENV/deployment-branch-policies" \
-f 'name=v*' -f type=tag

Three details decide whether that runs. gh api substitutes {owner} and {repo} from the checkout it runs in. Use -F for the booleans and the reviewer id, because -f sends every value as a string and the endpoint rejects a quoted boolean. Do not name the shell variable UID: zsh marks it read only, so the assignment fails before gh runs.

reviewers[][id] takes a numeric user or team id rather than a login. A team needs type=Team and that team’s id.

The registry-level gate is npm’s staged publishing: the workflow runs npm stage publish, which uploads the package to a staging area without requiring 2FA, and a maintainer then runs npm stage approve <stage-id> from the CLI or approves it on npmjs.com, which does require 2FA. Because the trusted publisher above only allows npm stage publish and not npm publish, no run of this workflow, compromised or not, can ship a version without that 2FA step. Also set publishing access at https://www.npmjs.com/package/<name>/access to “Require two-factor authentication and disallow tokens”, which revokes any existing publish token; warn the user first if another automation still uses one.

Source: npm Docs, Staged publishing for npm packages.

When publishing a public package from a public GitHub or GitLab repository through trusted publishing, npm generates a provenance attestation automatically; no --provenance flag is needed. npm does not generate provenance for a private repository, even when the package is public. After approval publishes the staged version, verify the exact installed version in a clean temporary project. This runs on the maintainer’s own machine and needs the npm CLI version named at the top of this file, not the one the workflow used:

npm install <name>@<version> --ignore-scripts
npm audit signatures

The output must report verified provenance for that dependency. The package page also shows provenance details. A bare npm audit signatures in the source repository does not verify the newly published package unless that exact version is installed there.

Source: npm Docs, Trusted publishing for npm packages and npm Docs, Viewing package provenance.

Describe and sign what the release attaches (Step 6)

Section titled “Describe and sign what the release attaches (Step 6)”

Only for a release that attaches a built asset to the forge release. Publishing to npm attaches nothing to GitHub, and the source archives GitHub generates for a tag are not built assets, so a project that only publishes goes to Step 7 instead.

npm generates the bill of materials itself, so no third-party tool enters the release workflow. npm sbom reads the installed tree and writes JSON to stdout, so it runs in the build job after npm ci:

- run: npm sbom --sbom-format=cyclonedx --omit=dev > <name>-<version>.cyclonedx.json # placeholder: the package name and version from Step 1
- uses: actions/upload-artifact@v7
with:
name: package-tarball
path: |
*.tgz
*.cyclonedx.json
retention-days: 1

Name --omit=dev rather than relying on the default. npm sbom omits development dependencies only when NODE_ENV is production, so the default describes the build environment rather than what a consumer installs. Where the job has a lockfile but no node_modules, add --package-lock-only.

Attach and attest in a third job, after the publish job:

github-release:
runs-on: ubuntu-latest
needs: [publish]
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- uses: actions/download-artifact@v8
with:
name: package-tarball
path: dist/
- run: gh release upload "$GITHUB_REF_NAME" dist/*.tgz dist/*.cyclonedx.json
env:
GH_TOKEN: ${{ github.token }}
- uses: actions/attest@v4
with:
subject-path: dist/*.tgz
- uses: actions/attest@v4
with:
subject-path: dist/*.tgz
sbom-path: dist/<name>-<version>.cyclonedx.json # placeholder: the same filename as above

The two attestation steps are not a duplicate. actions/attest generates SLSA build provenance when it is given a subject alone, and an SBOM attestation when it is also given sbom-path, so the first step records how the tarball was built and the second records what is inside it. A consumer reads the second with gh attestation verify <file>.tgz --repo <owner>/<repo> --predicate-type https://cyclonedx.org/bom, because gh looks for SLSA provenance unless told otherwise.

gh release upload fails when no release exists for the tag. Either create it in this job with gh release create "$GITHUB_REF_NAME" --generate-notes before the upload, or have the maintainer publish the release from the tag first. This reference does not choose between them, because release notes are the project’s own.

A third job is what makes the grants above safe, so copy the job boundary along with them. The build job runs npm ci and npm run build, so giving it release-asset writes and an attestation identity is exactly the credential split Step 3 exists to enforce, and it would write assets before the approval gate. The publish job holds the registry identity and runs nothing beyond the actions Step 3 names. Only a separate job satisfies both, and needs: [publish] is what keeps the assets behind the gate.

The four grants above are copied exactly, on this job only. The workflow’s top-level block stays contents: read. Narrowing anything else, pinning each uses: to a commit SHA, and auditing the result are oss-harden’s.

A trusted publisher is configured on the package’s npm settings page, which does not exist until the package is published once. If npm view <name> returned E404: confirm the name is actually free rather than restricted, then have the user publish the first version manually and interactively, with 2FA, from their own machine: npm publish --ignore-scripts (add --access public for a scoped package). That single release has no provenance and no trusted publisher; every release after it goes through the flow above, starting with adding the trusted publisher immediately after the manual publish.

Every public workspace package needs its own trusted publisher entry pointing at the same repository and workflow filename; a package left out stays unprotected. npm stage is unaware of workspaces. Pack each publishable workspace in the build job, then stage each resulting tarball explicitly, preferably in one approval-gated publish job per independently versioned package. Match each job’s tag trigger and version check to that package. npm sbom --workspace <name> scopes Step 6’s bill of materials to one of them.

Step 7 is in SKILL.md: read each R-PUB rule’s Check: line against what this file produced, and fix what fails before reporting done.

Verified 2026-07-31 against npm Docs, Trusted publishing for npm packages and npm Docs, Staged publishing for npm packages.

package.json’s version field is the source. npm requires it to be parseable by node-semver, and states that the name and version together form an identifier assumed to be completely unique.

npm version <newversion> writes the new number into package.json, package-lock.json, and npm-shrinkwrap.json where that file is present, so a committed lockfile is a second version source that drifts the moment someone edits the manifest by hand. In a git repository the same command also makes a commit and a tag by default, under the git-tag-version config, so the tag being compared usually comes out of the release command rather than being typed separately.

A workspaces repository has one package.json per workspace. Each is its own release unit unless the project deliberately versions them together, so check each against its own tag and changelog rather than forcing one number across all of them.

node-semver is Semantic Versioning 2.0.0 as npm implements it, so a published npm version already has the syntax R-CHG-02 asks for. The registry’s own constraint is only that the string parses; npm’s guidance says it recommends publishing a new version following the specification, not that it enforces the semantics behind the number.

Two things sit beside the version and are not versions. A distribution tag is a named pointer the registry resolves for an install, so latest is a moving alias rather than a number, and npm publish --tag next ships a stable-looking version that no plain npm install selects. A v prefix belongs on the git tag only; package.json carries the bare number.

Major version in package identity (R-CHG-07)

Section titled “Major version in package identity (R-CHG-07)”

npm does not encode the major version in package identity. A package name is stable across majors and foo@1 and foo@2 are the same registry entry, so R-CHG-07 does not reach this ecosystem. A project that maintains two majors side by side does it by publishing a second package under a different name, which makes them two release units for R-CHG-03 rather than one identity to check.

npm has two mechanisms and only one of them removes anything.

Unpublishing inside 72 hours of the publish has no conditions. After that window npm permits it only when no other package in the public registry depends on it, it had fewer than 300 downloads over the last week, and it has a single owner. The version string is spent either way: “Once package@version has been used, you can never use it again. You must publish a new version even if you unpublished the old one.” Republishing the same package name is blocked for 24 hours after an unpublish.

Deprecation is what npm recommends where those criteria are not met, and it removes nothing. npm deprecate <pkg>@"<range>" "<message>" updates the registry entry so an install prints the message, and passing an empty string undoes it. The range form covers versions in bulk, including prereleases, so npm deprecate my-thing@1.x "1.x is no longer supported" also flags 1.0.0-beta.0.

An unpublished version, and a deprecation whose message tells users to stop using that version, are both withdrawal for changelog purposes: mark that version’s heading [YANKED] and leave the entry in place, which is what R-CHG-01 asks for. A reader who installed the version before it went is exactly who the record exists for. A deprecation that only points at a newer release is not a withdrawal, and belongs under Deprecated in the release that adds it.

Verified 2026-07-31 against package.json, npm version, About semantic versioning, Unpublishing packages from the registry, npm unpublish policy, and npm deprecate.