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-02 22:26:08 -07:00
committed by GitHub
parent c2ebe3724f
commit 6487d14855
8 changed files with 553 additions and 28 deletions
+172 -8
View File
@@ -4,7 +4,11 @@
package integration
import (
"compress/gzip"
"crypto/sha1"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"net/url"
@@ -18,6 +22,7 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/packages/npm"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -43,7 +48,14 @@ func TestPackageNpm(t *testing.T) {
repoURL := "http://localhost:3000/gitea/test.git"
repoDirectory := "package-subdir"
data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA"
attachmentBytes := test.WriteTarCompression(gzip.NewWriter, map[string]string{
"package/package.json": `{"name":"` + packageName + `","version":"` + packageVersion + `","scripts":{"postinstall":"echo hi"}}`,
}).Bytes()
attachmentData := base64.StdEncoding.EncodeToString(attachmentBytes)
sha1Sum := sha1.Sum(attachmentBytes)
sha1SumHex := hex.EncodeToString(sha1Sum[:])
sha512Sum := sha512.Sum512(attachmentBytes)
integrity := "sha512-" + base64.StdEncoding.EncodeToString(sha512Sum[:])
buildUpload := func(version string) string {
return `{
@@ -65,8 +77,8 @@ func TestPackageNpm(t *testing.T) {
"` + packageBinName + `": "` + packageBinPath + `"
},
"dist": {
"integrity": "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==",
"shasum": "aaa7eaf852a948b0aa05afeda35b1badca155d90"
"integrity": "` + integrity + `",
"shasum": "` + sha1SumHex + `"
},
"repository": {
"type": "` + repoType + `",
@@ -82,12 +94,29 @@ func TestPackageNpm(t *testing.T) {
"soy-milk": {
"optional": true
}
},
"scripts": {
"postinstall": "echo hi"
},
"engines": {
"node": ">=22.7.0",
"npm": ">=10.8.2"
},
"cpu": ["x64", "arm64"],
"os": ["linux", "darwin"],
"directories": {
"doc": "./doc",
"man": "./man"
},
"funding": "https://example.com/fund",
"acceptDependencies": {
"left-pad": "1.x"
}
}
},
"_attachments": {
"` + packageName + `-` + version + `.tgz": {
"data": "` + data + `"
"data": "` + attachmentData + `"
}
}
}`
@@ -126,7 +155,7 @@ func TestPackageNpm(t *testing.T) {
pb, err := packages.GetBlobByID(t.Context(), pfs[0].BlobID)
assert.NoError(t, err)
assert.Equal(t, int64(192), pb.Size)
assert.EqualValues(t, len(attachmentBytes), pb.Size)
})
t.Run("UploadExists", func(t *testing.T) {
@@ -144,7 +173,7 @@ func TestPackageNpm(t *testing.T) {
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
b, _ := base64.StdEncoding.DecodeString(data)
b, _ := base64.StdEncoding.DecodeString(attachmentData)
assert.Equal(t, b, resp.Body.Bytes())
req = NewRequest(t, "GET", fmt.Sprintf("%s/-/%s", root, filename)).
@@ -185,13 +214,22 @@ func TestPackageNpm(t *testing.T) {
assert.Equal(t, packageDescription, pmv.Description)
assert.Equal(t, packageAuthor, pmv.Author.Name)
assert.Equal(t, packageBinPath, pmv.Bin[packageBinName])
assert.Equal(t, "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==", pmv.Dist.Integrity)
assert.Equal(t, "aaa7eaf852a948b0aa05afeda35b1badca155d90", pmv.Dist.Shasum)
assert.Equal(t, integrity, pmv.Dist.Integrity)
assert.Equal(t, sha1SumHex, pmv.Dist.Shasum)
assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball)
assert.Equal(t, repoType, result.Repository.Type)
assert.Equal(t, repoURL, result.Repository.URL)
assert.Equal(t, map[string]string{"tea": "2.x", "soy-milk": "1.2"}, pmv.PeerDependencies)
assert.Equal(t, map[string]any{"soy-milk": map[string]any{"optional": true}}, pmv.PeerDependenciesMeta)
assert.True(t, pmv.HasInstallScript)
assert.False(t, pmv.HasShrinkwrap)
assert.Equal(t, map[string]string{"node": ">=22.7.0", "npm": ">=10.8.2"}, pmv.Engines)
assert.Equal(t, []string{"x64", "arm64"}, pmv.CPU)
assert.Equal(t, []string{"linux", "darwin"}, pmv.OS)
assert.Equal(t, map[string]string{"doc": "./doc", "man": "./man"}, pmv.Directories)
assert.Equal(t, "https://example.com/fund", pmv.Funding)
assert.Equal(t, map[string]string{"left-pad": "1.x"}, pmv.AcceptDependencies)
assert.Empty(t, pmv.Deprecated)
})
t.Run("AddTag", func(t *testing.T) {
@@ -315,6 +353,132 @@ func TestPackageNpm(t *testing.T) {
`, rendered)
})
t.Run("Deprecate", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
deprecationMessage := "critical bug fixed in v0.2.3"
buildDeprecate := func(message string) string {
return `{
"_id": "` + packageName + `",
"name": "` + packageName + `",
"versions": {
"` + packageVersion + `": {
"name": "` + packageName + `",
"version": "` + packageVersion + `",
"deprecated": "` + message + `"
}
}
}`
}
req := NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(deprecationMessage))).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", root).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
result := DecodeJSON(t, resp, &npm.PackageMetadata{})
assert.Contains(t, result.Versions, packageVersion)
assert.Equal(t, deprecationMessage, result.Versions[packageVersion].Deprecated)
// Empty deprecation message clears the flag (undeprecate).
req = NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(""))).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", root).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
result = DecodeJSON(t, resp, &npm.PackageMetadata{})
assert.Empty(t, result.Versions[packageVersion].Deprecated)
// Unknown versions are silently skipped (idempotent); the known
// version is still updated.
mixedBody := `{
"_id": "` + packageName + `",
"name": "` + packageName + `",
"versions": {
"` + packageVersion + `": {
"name": "` + packageName + `",
"version": "` + packageVersion + `",
"deprecated": "` + deprecationMessage + `"
},
"99.99.99": {
"name": "` + packageName + `",
"version": "99.99.99",
"deprecated": "ghost"
}
}
}`
req = NewRequestWithBody(t, "PUT", root, strings.NewReader(mixedBody)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", root).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
result = DecodeJSON(t, resp, &npm.PackageMetadata{})
assert.Equal(t, deprecationMessage, result.Versions[packageVersion].Deprecated)
assert.NotContains(t, result.Versions, "99.99.99")
// Restore the cleared state for subsequent subtests.
req = NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(""))).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
// A user who has no write access to the URL owner's packages must
// be rejected by reqPackageAccess before deprecatePackage runs.
otherUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
otherToken := "Bearer " + getTokenForLoggedInUser(t, loginUser(t, otherUser.Name), auth_model.AccessTokenScopeWritePackage)
req = NewRequestWithBody(t, "PUT", root, strings.NewReader(buildDeprecate(deprecationMessage))).
AddTokenAuth(otherToken)
MakeRequest(t, req, http.StatusUnauthorized)
})
t.Run("UploadHasShrinkwrapClaim", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// The tarball in `data` does not contain npm-shrinkwrap.json.
// Even when the client claims _hasShrinkwrap: true, the
// authoritative tarball scan must overrule the claim.
claimPackageName := "@scope/test-shrinkwrap-claim"
claimVersion := "1.0.0"
claimRoot := fmt.Sprintf("/api/packages/%s/npm/%s", user.Name, url.QueryEscape(claimPackageName))
body := `{
"_id": "` + claimPackageName + `",
"name": "` + claimPackageName + `",
"versions": {
"` + claimVersion + `": {
"name": "` + claimPackageName + `",
"version": "` + claimVersion + `",
"_hasShrinkwrap": true,
"dist": {
"integrity": "` + integrity + `",
"shasum": "` + sha1SumHex + `"
}
}
},
"_attachments": {
"` + claimPackageName + `-` + claimVersion + `.tgz": {
"data": "` + attachmentData + `"
}
}
}`
req := NewRequestWithBody(t, "PUT", claimRoot, strings.NewReader(body)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
req = NewRequest(t, "GET", claimRoot).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
result := DecodeJSON(t, resp, &npm.PackageMetadata{})
require.Contains(t, result.Versions, claimVersion)
assert.False(t, result.Versions[claimVersion].HasShrinkwrap, "client-claimed _hasShrinkwrap must be overridden by tarball scan")
// Clean up so the subsequent Delete subtest's version counts match.
req = NewRequest(t, "DELETE", claimRoot+"/-rev/dummy").AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
})
t.Run("Delete", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()