Skip to content

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

Description

@MrRefactoring

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions