npm / npm/cli

[BUG] npm deprecate fails with "deprecations must be strings" once any version is already deprecated

Open
#9,803 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
10.1k
Forks
4.7k
Avg merge
2d 2h
Merged PRs (30d)
19

Description

Is there an existing issue for this?

Related, but closed without a root cause: #2282, #3449, #3838. #2282 has a 2023 comment reporting it is still broken. This issue supplies the mechanism and a reproduction that does not involve the CLI.

Current Behavior

npm deprecate fails with 400 Bad Request - deprecations must be strings whenever the package already has at least one deprecated version.

The first deprecation on a package with nothing deprecated always succeeds. Every deprecation after that fails, permanently, until all deprecations are cleared again.

$ npm deprecate "jira.js@>=5.0.0-0 <5.0.0" "Development snapshot, not for use."
npm notice deprecating jira.js@5.0.0-dev20250313212005 with message "..."
...
npm error code E400
npm error 400 Bad Request - PUT https://registry.npmjs.org/jira.js - deprecations must be strings

Observed sequence on one package, in order:

command versions already deprecated result
npm deprecate 'jira.js@<5.0.0' "…" (83 versions) 0 success
npm deprecate 'jira.js@<4.0.0' "" (undeprecate 83) 83 success
npm deprecate 'jira.js@>=4.1.1-0 <4.1.1' "…" (1 version) 0 success
npm deprecate 'jira.js@>=5.0.0-0 <5.0.0' "…" (32 versions) 1 E400
npm deprecate 'jira.js@>=5.3.1-0 <5.3.1' "…" (1 version) 1 E400

The undeprecate step succeeds because an empty string is still a string.

Expected Behavior

Deprecating a version leaves deprecations on other versions untouched, and succeeds regardless of how many versions are already deprecated.

Root cause

lib/commands/deprecate.js fetches the packument with ?write=true, mutates it, and PUTs the whole document back:

const packument = await npmFetch.json(uri, {
  ...this.npm.flatOptions,
  spec: p,
  query: { write: true },
})

const versions = Object.keys(packument.versions)
  .filter(v => semver.satisfies(v, spec, { includePrerelease: true }))

for (const v of versions) {
  packument.versions[v].deprecated = msg
}

return otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, {
  ...opts, spec: p, method: 'PUT', body: packument, ignoreBody: true,
}))

The ?write=true view of the packument does not carry deprecated at all. Both requests below were made within the same minute, against the same package:

$ curl -s 'https://registry.npmjs.org/jira.js' | \
    jq '[.versions[] | select(.deprecated)] | length'
40

$ curl -s -H "authorization: Bearer $TOKEN" 'https://registry.npmjs.org/jira.js?write=true' | \
    jq '[.versions[] | select(.deprecated)] | length'
0

So the document PUT back implicitly clears every deprecation the package already had. The registry rejects that with deprecations must be strings — presumably because the removed entries arrive as undefined rather than a string.

This is not CLI-specific. Reproduced with a plain PUT, no npm involved: taking the ?write=true document, setting deprecated on a single version and PUTting it back returns

HTTP 400
{"success":false,"error":"deprecations must be strings"}

Re-adding the pre-existing deprecated values into the same document before the PUT makes it succeed, which confirms the mechanism.

Steps To Reproduce

These steps are reconstructed from the mechanism, not run against a fresh
package
— everything above was observed on a real package with 145 published
versions, and I did not want to publish a throwaway package to confirm the
minimal case. Please treat this section as the expected minimal reproduction
rather than a verified one.

  1. Publish a package with at least three versions.
  2. npm deprecate 'pkg@1.0.0' "first" — expected to succeed.
  3. npm deprecate 'pkg@2.0.0' "second" — expected to fail with E400 deprecations must be strings.
  4. npm deprecate 'pkg@<9.9.9' "" to clear, after which step 2 succeeds again.
What is measured and what is inferred

Measured directly:

  • the five command outcomes in the table above, in that order;
  • the two curl counts (40 vs 0) against the same package, same minute;
  • the plain PUT reproducing 400 deprecations must be strings with no npm involved;
  • the same PUT succeeding once the pre-existing deprecated values are merged back in.

Inferred:

  • that the registry rejects the request because the dropped entries arrive as
    undefined — the error string is the only evidence for the "why", and the
    server-side check is not visible from here;
  • the minimal reproduction steps above.
Suggested fix

Either of:

  • merge the existing deprecations into the packument before the PUT — read them from the public packument, or from the ?write=true document once the registry includes them; or
  • have the registry treat an absent deprecated as unchanged rather than cleared, so a partial document cannot silently drop deprecations.

The first is fixable in the CLI alone.

Environment
npm: 11.13.0
node: v26.1.0
OS: macOS 15 (darwin arm64)
registry: https://registry.npmjs.org

Package used: jira.js (145 versions, 40 deprecated at time of writing).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in lib/commands/deprecate.js, especially the ?write=true packument fetch, mutation, and whole-document PUT. Reproduce the two-deprecation sequence described in the issue, then verify that a later deprecation succeeds without clearing existing deprecation messages; the issue does not name a test file.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js
Domain
cli
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
70/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.