Skip to content
oss-kit
Install

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 npm stage 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 tools/package.json 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-shrinkwrap.json stays in the present list and no longer means what it used to. npm 12 removed the npm shrinkwrap command and stopped loading a file of that name, so one in a checkout today proves a dependency tree exists, which is all the present axis asks of it, and proves nothing about how that tree resolves. oss-harden treats it as a finding.

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-08-03 against https://docs.npmjs.com/cli/v12/configuring-npm/package-json and https://github.com/npm/cli/releases/tag/v12.0.0.

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-08-03 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-08-03 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: ['22', '24', '26']
steps:
- uses: actions/setup-node@v7 # oss-harden pins this to a commit SHA
with:
node-version: ${{ matrix.node-version }}

Those three are the lines Node maintains as of 2026-08-03: 22 in maintenance until 2027-04-30, 24 in active long-term support, and 26 current. Node 20 reached end of life on 2026-04-30 and belongs in no matrix. Read the release schedule rather than copying the numbers above, because the set turns over on a published date: 26 becomes long-term support on 2026-10-28 and 24 drops to maintenance on 2026-10-20, and from Node 27 onward there is one major each April, every release becomes long-term support, and the odd-numbered and even-numbered distinction ends.

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, Node.js release schedule.

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. npm 12 removed the npm shrinkwrap command and no longer loads a file named npm-shrinkwrap.json, so one still committed today is a cache key pointing at a file the install ignores; it stays in the roster because repositories still carry it, and a repository that has one should rename it to package-lock.json. 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-08-03 against https://docs.npmjs.com/cli/v12/commands/npm-test, https://docs.npmjs.com/cli/v12/configuring-npm/package-json, and https://github.com/nodejs/Release/blob/main/schedule.json. The setup-node, docker-node, and GitLab claims were last read on 2026-07-31 and are unchanged here.

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

npm-shrinkwrap.json is no longer a lockfile. npm 12 removed the npm shrinkwrap command and no longer loads or honors a file of that name, so a repository still carrying one is installing from nothing. Rename it to package-lock.json, or use bundleDependencies where the point was to ship a locked tree. The roster still lists the filename because repositories still carry it, and finding one is now a finding rather than a pass.

Verified 2026-07-31 against npm install, package-lock.json, and npm/cli issue 4828, and the shrinkwrap paragraph on 2026-08-03 against npm CLI v12.0.0.

The rule is scored against the updater, so cooldown in dependabot.yml or minimumReleaseAge in a Renovate configuration is what closes it, and references/github.md and references/gitlab.md carry those. Three of the four managers here also carry their own, which is worth setting alongside because it covers an install the updater never touched, such as a contributor adding a dependency by hand.

  • npm has min-release-age, default null, counted in days: “only versions that were available more than the given number of days ago will be installed”. min-release-age-exclude takes package names or minimatch globs. Note the interaction with npm audit fix: where the fix is younger than the threshold, npm keeps the vulnerable version and exits non-zero, which is a failure a project has to be ready to read.
  • yarn has npmMinimalAgeGate and it is already on, defaulting to one week.
  • pnpm has minimumReleaseAge, counted in minutes, and it is on by default at 1440, one day, from pnpm v11. Setting it to 0 turns the protection off.
  • bun has minimumReleaseAge under [install] in bunfig.toml, counted in seconds, with minimumReleaseAgeExcludes. It is off by default and it leaks: it is bypassed for a version already pinned in bun.lock, including under --frozen-lockfile, bunx --minimum-release-age is a silent no-op, and bun upgrade ignores it. Set it, and do not report it as the project’s only cooldown.

Three units across four managers, so read which manager the repository uses before writing a number. A value meant as one day is 1 in npm, 1440 in pnpm, and 86400 in bun.

Verified 2026-08-03 against npm config, Yarn configuration, pnpm supply chain security, bun install, and the three bun issues at 30525, 30748, and 30533.

An install-time lifecycle script is the shortest path from a compromised package to a shell on the runner, holding whatever token the job holds. Every manager in this ecosystem now blocks dependency scripts by default, but they arrived there at different times and they are configured in four different places, so read which manager the repository uses before concluding anything.

  • npm blocks dependency lifecycle scripts by default from version 12, released 2026-07-08, and governs the exceptions with an allowScripts field in the root package.json. Maintain it with npm install-scripts approve|deny|ls|prune; npm approve-scripts and npm deny-scripts are aliases for the first two. approve writes a version-pinned entry such as pkg@1.2.3 unless --no-allow-scripts-pin is passed, deny writes a name-only false entry that survives a later approve --all, ls reports what is blocked without editing anything, and npm rebuild runs a script that was approved after the install. npm 12 also changed allow-git and allow-remote to default to none, so a git or tarball-URL dependency no longer resolves until one of them is set to all or root.
  • yarn defaults enableScripts to false, documented as “Yarn will not execute the postinstall scripts from third-party packages”.
  • pnpm disabled automatic execution of dependency postinstall scripts in version 10. allowBuilds lists the dependencies permitted to build; dangerouslyAllowAllBuilds turns the protection off wholesale and its name is the review comment.
  • bun never runs a dependency lifecycle script, and trustedDependencies in package.json is the opt-in list.

--ignore-scripts is a blunter and older instrument that still works and is worth writing in CI anyway, because it is evidence in the workflow file rather than a default the runner’s manager version happens to carry. Note what it costs: it suppresses the project’s own scripts too, which is why a job that needs prepare or prepublishOnly runs them as an explicit step rather than relying on the install to trigger them.

Verified 2026-08-03 against npm config, npm install-scripts, npm CLI v12.0.0, Yarn configuration, pnpm supply chain security, and bun install.

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.

Those are floors rather than recommendations. npm 12 is the current major, released 2026-07-08, and it requires Node ^22.22.2 || ^24.15.0 || >=26.0.0, so a job that pins an older patch release within a supported line cannot run it. Where the release the workflow pins bundles an npm below the floor, install one exact npm version in the job rather than widening the Node pin, and write the exact version rather than a range.

Source: npm Docs, Trusted publishing for npm packages, npm Docs, Staged publishing, npm CLI v12.0.0, and Node.js download archive, Node 24.21.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: for one package without public forge assets, enable npm stage publish and leave npm publish disabled. For multiple packages or any public forge assets, enable npm publish and leave staged publishing disabled. Step 4 configures the matching approval gate.

In the workflow, the publish job needs:

permissions:
id-token: write
contents: read
steps:
- uses: actions/setup-node@v7
with:
node-version: '24.21.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: select staged or direct publishing from the release-flow rule 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)”

Before choosing the pack command, inspect the existing package manager, workspace protocols, and packing hooks. For pnpm workspace: dependencies or .pnpmfile.cjs beforePacking hooks, use the project’s frozen pnpm install and pack commands in the uncredentialed build job. Verify the package name, version, and rewritten dependency fields inside the resulting tarball. Hand that exact tarball to the publish job. The npm example below applies when npm can reproduce the project’s packaging behavior; do not switch the credentialed client without establishing a separate need.

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. This example uses direct publishing behind Step 4’s forge approval gate, which also covers Step 6’s release assets. For one package without forge assets, Step 4 gives the staged-publishing variant.

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.21.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.21.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.21.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- run: npm 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 the publish command. 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 and pass the resulting .tgz as an artifact. Run only the direct or staged publish command selected in Step 4. Give that job environment: name: release plus the id_tokens block from Step 2. For the forge gate, set when: manual and allow_failure: false on the protected environment’s publish job.

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.

Where a release tool already owns the release, keep it and fit the publish job inside it rather than adding a second release path. release-please, semantic-release, and changesets all work the same way: the workflow triggers on a merge to the default branch, the tool decides whether that merge is a release, and where it is, the tool creates the tag and the forge release and the publish runs behind it in the same job graph. That satisfies R-PUB-01 through the clause covering a run that creates the release tag itself, so do not rewrite it into a push: tags trigger to make the rule read more literally. Three things do change. The tool’s own job outputs whether a release happened, and every job after it needs the condition, or the workflow publishes on every merge; the version check against the tag comes from the tool’s manifest rather than from GITHUB_REF_NAME; and any selected forge gate belongs on the publish job, after the tool creates the tag.

For multiple packages or any public forge assets, use one forge gate before direct publication. Configure every trusted publisher for npm publish and disable staged publishing. Run one npm publish ./package/<tarball>.tgz --ignore-scripts command per package. On GitHub, add a required reviewer to release. On GitLab Premium or Ultimate, protect release and make the publish job blocking and manual. Asset-upload jobs must depend on successful completion of that publish job, without always() or another condition that bypasses failure. Do not add a second approval to the asset-upload job.

GitHub required reviewers are available for public repositories on all plans. Private repositories need GitHub Enterprise for this gate. If the native gate is unavailable, report R-PUB-04 unmet for a flow that publishes forge assets.

For GitHub’s forge gate, create a new environment with a required reviewer and tag policy:

ENV=release
GHUID=$(gh api users/REVIEWER_LOGIN --jq .id) # replace REVIEWER_LOGIN with the chosen reviewer
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

gh api substitutes {owner} and {repo} from the checkout it runs in. Use -F for booleans and the numeric reviewer ID. A team reviewer needs type=Team and that team’s ID.

For one package without public forge assets, use npm staged publishing as the publication gate. Replace the example’s publish command with npm stage publish ./package/*.tgz --ignore-scripts. Keep environment: release as part of the trusted-publisher identity and restrict it to version tags. Configure no required reviewer on a new environment for this branch; omit the reviewer lookup and reviewer fields above. Preserve an established gate until the maintainer accepts its removal.

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.

npm creates one stage and one approval per package. A completed staging job has not passed npm’s approval step and cannot authorize a public asset upload.

If an established flow has both forge review and staged approval, preserve both until the maintainer accepts a migration. Report how many actions the flow requires. Select the branch above from both its package count and public asset inventory.

Approval scope checked 2026-10-11 against npm staged publishing and GitHub deployment environments.

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 direct publication or staged approval, 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.

A newly published version is not installable straight away. npm scans every publish for malware before the version becomes available, “typically around five minutes” and “up to 15 minutes or more, at peak times or depending on a package’s content and size”. The install above fails while the scan is pending, and so does anything else the release does with the version it just shipped: npm deprecate and npm unpublish both wait on availability, and only npm dist-tag keeps working throughout. So do not read a failed install in the first minutes as a failed publish. Wait and repeat it, and where a workflow step installs the package it just published, npm’s own instruction is to “update it to tolerate a short availability delay” rather than to retry immediately and fail the release on a scan that has not finished.

Source: npm Docs, Trusted publishing for npm packages, npm Docs, Viewing package provenance, and npm publish-time malware scanning and dual-use metadata.

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.

This flow requires Step 4’s forge gate and direct npm publication. Attach and attest in a third job, after the approved publish job succeeds:

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 preserves the credential split, so copy the job boundary along with it. The build job runs project code and must not upload release assets before approval. The publish job holds the registry identity and runs only the actions Step 3 permits. With Step 4’s forge gate, needs: [publish] places asset upload after approval and successful direct publication. That dependency alone cannot wait for approval of an npm stage. Keep the asset job’s default success condition and omit another environment approval.

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. Independently tagged packages are separate release flows. Each can use staged publishing only when its flow has no public forge assets. Packages released together, or with public forge assets, use direct publishing behind one forge gate. Pack every package in the build job and publish each tarball explicitly. Match each independently versioned 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.

npm’s token track is being withdrawn, so a reader who arrives here with a working NPM_TOKEN should know it has an end date rather than a trade-off. Classic tokens stopped being issued on 2025-11-05, and every one of them was revoked by 2025-12-09. What replaced them, granular access tokens, expire in at most 90 days. From early August 2026 a 2FA-bypass granular token can no longer change trusted publishing configuration or perform account, package, and organization management, and around January 2027 it loses the ability to publish directly, leaving it able to read private packages and to stage a publish for someone else to approve.

npm’s own recommendation for automated publishing is trusted publishing, or staged publishing with a human approval step, which is what the rest of this file builds. R-PUB-02 permits a scoped token as a below-the-bar fallback where a registry documents no OIDC flow at all. npm documents one, so that fallback does not reach this ecosystem, and a token here is a finding rather than a slower road to the same place.

Source: npm classic token creation disabled, npm classic tokens revoked, and npm install-time security and GAT bypass2fa deprecation.

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-08-07 against npm Docs, Trusted publishing for npm packages and npm Docs, Staged publishing for npm packages. The npm 12 floor, the malware-scan delay, and the token section were added on 2026-08-03 against npm CLI v12.0.0 and the three GitHub changelog entries cited beside them.

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 and package-lock.json, so a committed lockfile is a second version source that drifts the moment someone edits the manifest by hand. It used to write npm-shrinkwrap.json too; npm 12 removed that file from the ecosystem, so a repository still carrying one has a third version source nothing updates. 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.

A newly created package can be unpublished within 72 hours only if no npm Public Registry package depends on it. 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-10-11 against package.json, npm version, About semantic versioning, Unpublishing packages from the registry, npm unpublish policy, and npm deprecate.