enhance(packages/npm): expand version metadata and support npm deprecate (#37890)

Fixes #21624

Adds the npm package version metadata fields that Gitea's npm registry
was previously dropping on publish, and implements the `npm deprecate`
command, which Gitea did not accept before.

### New / pass-through metadata fields

The following are now parsed from the publish payload, persisted in the
stored `npm.Metadata`, and re-emitted on the abbreviated version
manifest returned to npm clients:

- `hasInstallScript` — auto-detected from `scripts.preinstall` /
`scripts.install` / `scripts.postinstall` (also honors a client-supplied
value). Without this flag, `npm install` skips lifecycle scripts.
- `_hasShrinkwrap` — authoritatively derived by inspecting the uploaded
tarball for a top-level `*/npm-shrinkwrap.json` entry. Client-supplied
values are ignored. Decompression failures fall back to `false` and do
not block publish (integrity has already been validated).
- `engines` (`map[string]string`)
- `cpu`, `os` (`[]string`)
- `directories` (`map[string]string`)
- `funding` (`any`; preserves the spec's string / object / array shape)
- `acceptDependencies` (`map[string]string`)
- `deprecated` (`string`)

`peerDependenciesMeta` was already in the stored struct but is now
exercised
by tests.

### `npm deprecate` support

`npm deprecate <pkg-spec> <message>` PUTs the package document to the
same URL as publish but with no `_attachments`. The router now detects
that shape and routes to a new handler that updates each affected
version's stored `Metadata.Deprecated` via
`packages_model.UpdateVersion`. An empty message clears the flag
(undeprecate). Unknown versions are silently skipped, matching npm's
behavior. No new routes were added.

Supported invocations include:

- `npm deprecate my-thing@"< 0.2.3" "critical bug fixed in v0.2.3"`
- `npm deprecate my-thing@1.x "1.x is no longer supported"`
- `npm deprecate my-thing@1.0.0 ""` (undeprecate)

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
philip-x-rutkowski-intel-com
2026-08-03 05:26:08 +00:00
committed by GitHub
co-authored by wxiaoguang bircni
parent c2ebe3724f
commit 6487d14855
8 changed files with 553 additions and 28 deletions
+149 -2
View File
@@ -5,12 +5,15 @@ package npm
import (
"bytes"
"compress/gzip"
"crypto/sha512"
"encoding/base64"
"fmt"
"strings"
"testing"
"gitea.dev/modules/json"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -25,14 +28,18 @@ func TestParsePackage(t *testing.T) {
packageAuthor := "KN4CK3R"
packageBin := "gitea"
packageDescription := "Test Description"
data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA"
integrity := "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg=="
repository := Repository{
Type: "gitea",
URL: "http://localhost:3000/gitea/test.git",
Directory: "packages/test-package",
}
dataBytes := buildTarball(map[string]string{
"package/package.json": `{"name": "@scope/test-package","version": "1.0.1-pre","description": "Test Description","author": "KN4CK3R"}`,
})
data := base64.StdEncoding.EncodeToString(dataBytes)
integrity := "sha512-" + base64Sha512(dataBytes)
t.Run("InvalidUpload", func(t *testing.T) {
p, err := ParsePackage(bytes.NewReader([]byte{0}))
assert.Nil(t, p)
@@ -354,3 +361,143 @@ func TestParsePackage(t *testing.T) {
require.Equal(t, "./cli.js", p.Metadata.Bin["dev-null"])
})
}
// buildTarball assembles a gzipped tar with the given entries.
func buildTarball(files map[string]string) []byte {
return test.WriteTarCompression(gzip.NewWriter, files).Bytes()
}
func TestInspectTarball(t *testing.T) {
cases := []struct {
name string
files map[string]string
wantShrinkwrap, wantInstaller bool
}{
{
name: "empty",
files: map[string]string{},
},
{
name: "shrinkwrap only",
files: map[string]string{"package/npm-shrinkwrap.json": "{}"},
wantShrinkwrap: true,
},
{
name: "postinstall only",
files: map[string]string{"package/package.json": `{"scripts":{"postinstall":"echo hi"}}`},
wantInstaller: true,
},
{
name: "preinstall",
files: map[string]string{"package/package.json": `{"scripts":{"preinstall":"noop"}}`},
wantInstaller: true,
},
{
name: "install",
files: map[string]string{"package/package.json": `{"scripts":{"install":"noop"}}`},
wantInstaller: true,
},
{
name: "whitespace-only script does not count",
files: map[string]string{"package/package.json": `{"scripts":{"postinstall":" "}}`},
},
{
name: "unrelated lifecycle script ignored",
files: map[string]string{"package/package.json": `{"scripts":{"test":"jest"}}`},
},
{
name: "both",
files: map[string]string{
"package/npm-shrinkwrap.json": "{}",
"package/package.json": `{"scripts":{"install":"go"}}`,
},
wantShrinkwrap: true,
wantInstaller: true,
},
{
name: "nested shrinkwrap ignored",
files: map[string]string{
"package/subdir/npm-shrinkwrap.json": "{}",
},
},
{
name: "leading ./ prefix stripped",
files: map[string]string{"./package/npm-shrinkwrap.json": "{}"},
// npm pack sometimes emits "./package/..." entries.
wantShrinkwrap: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
data := buildTarball(c.files)
gotShrink, gotInstaller := inspectTarball(data)
assert.Equal(t, c.wantShrinkwrap, gotShrink, "shrinkwrap")
assert.Equal(t, c.wantInstaller, gotInstaller, "installScript")
})
}
t.Run("malformed gzip returns false", func(t *testing.T) {
hasShrinkwrap, hasInstaller := inspectTarball([]byte("not a gzip"))
assert.False(t, hasShrinkwrap)
assert.False(t, hasInstaller)
})
t.Run("malformed package.json falls through", func(t *testing.T) {
data := buildTarball(map[string]string{"package/package.json": "{ this is not json"})
_, hasInstaller := inspectTarball(data)
assert.False(t, hasInstaller)
})
}
func TestParseUpload(t *testing.T) {
pkg := "@scope/test-package"
t.Run("dispatches deprecate on missing _attachments", func(t *testing.T) {
body := fmt.Sprintf(`{"name":%q,"versions":{"1.0.0":{"deprecated":"gone"},"1.0.1":{"deprecated":""},"1.0.2":{},"1.0.3":null}}`, pkg)
p, dep, err := ParseUpload(strings.NewReader(body))
require.NoError(t, err)
assert.Nil(t, p)
require.NotNil(t, dep)
assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions)
})
t.Run("dispatches publish when _attachments present", func(t *testing.T) {
// Reuse a minimal tarball with a package.json.
data := buildTarball(map[string]string{"package/package.json": `{}`})
integrity := "sha512-" + base64Sha512(data)
body := fmt.Sprintf(
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
)
p, dep, err := ParseUpload(strings.NewReader(body))
require.NoError(t, err)
assert.Nil(t, dep)
require.NotNil(t, p)
assert.Equal(t, pkg, p.Name)
})
t.Run("publish whose readme mentions deprecated is not misrouted", func(t *testing.T) {
// The old fast-path used a substring check for "deprecated"; make sure
// the new dispatch keys off _attachments only.
data := buildTarball(map[string]string{"package/package.json": `{}`})
integrity := "sha512-" + base64Sha512(data)
body := fmt.Sprintf(
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","readme":"this package is deprecated!","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
)
p, dep, err := ParseUpload(strings.NewReader(body))
require.NoError(t, err)
assert.Nil(t, dep)
require.NotNil(t, p)
})
t.Run("invalid json errors out", func(t *testing.T) {
_, _, err := ParseUpload(strings.NewReader("not json"))
assert.Error(t, err)
})
}
func base64Sha512(data []byte) string {
h := sha512.Sum512(data)
return base64.StdEncoding.EncodeToString(h[:])
}