[BUG] npm deprecate fails with "deprecations must be strings" once any version is already deprecated
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.
- Publish a package with at least three versions.
npm deprecate 'pkg@1.0.0' "first"— expected to succeed.npm deprecate 'pkg@2.0.0' "second"— expected to fail withE400 deprecations must be strings.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
curlcounts (40 vs 0) against the same package, same minute; - the plain PUT reproducing
400 deprecations must be stringswith no npm involved; - the same PUT succeeding once the pre-existing
deprecatedvalues 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=truedocument once the registry includes them; or - have the registry treat an absent
deprecatedas 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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