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
+9
View File
@@ -71,6 +71,15 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
OptionalDependencies: metadata.OptionalDependencies,
Readme: metadata.Readme,
Bin: metadata.Bin,
HasInstallScript: metadata.HasInstallScript,
HasShrinkwrap: metadata.HasShrinkwrap,
Engines: metadata.Engines,
CPU: metadata.CPU,
OS: metadata.OS,
Directories: metadata.Directories,
Funding: metadata.Funding,
AcceptDependencies: metadata.AcceptDependencies,
Deprecated: metadata.Deprecated,
Dist: npm_module.PackageDistribution{
Shasum: pd.Files[0].Blob.HashSHA1,
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
+59 -1
View File
@@ -17,6 +17,7 @@ import (
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/json"
"gitea.dev/modules/optional"
packages_module "gitea.dev/modules/packages"
npm_module "gitea.dev/modules/packages/npm"
@@ -154,7 +155,7 @@ func DownloadPackageFileByName(ctx *context.Context) {
// UploadPackage creates a new package
func UploadPackage(ctx *context.Context) {
npmPackage, err := npm_module.ParsePackage(ctx.Req.Body)
npmPackage, deprecation, err := npm_module.ParseUpload(ctx.Req.Body)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
apiError(ctx, http.StatusBadRequest, err)
@@ -164,6 +165,12 @@ func UploadPackage(ctx *context.Context) {
return
}
// `npm deprecate` reuses the publish endpoint with no `_attachments`.
if deprecation != nil {
deprecatePackage(ctx, deprecation)
return
}
repo, err := repo_model.GetRepositoryByURLRelax(ctx, npmPackage.Metadata.Repository.URL)
if err == nil {
canWrite := repo.OwnerID == ctx.Doer.ID
@@ -252,6 +259,57 @@ func DeletePreview(ctx *context.Context) {
ctx.Status(http.StatusOK)
}
// deprecatePackage handles an `npm deprecate` request, which is a PUT to the
// package URL with no attachments and a `deprecated` string set on each
// affected version (empty string means undeprecate).
func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation) {
if len(dep.Versions) == 0 {
apiError(ctx, http.StatusBadRequest, "npm deprecate request contains no versions")
return
}
// Run per-version updates in one transaction so a partial failure does
// not leave the package in a half-applied state.
err := db.WithTx(ctx, func(txCtx std_ctx.Context) error {
for version, message := range dep.Versions {
pv, err := packages_model.GetVersionByNameAndVersion(txCtx, ctx.Package.Owner.ID, packages_model.TypeNpm, dep.PackageName, version)
if err != nil {
if errors.Is(err, packages_model.ErrPackageNotExist) {
continue
}
return err
}
metadata := &npm_module.Metadata{}
if err := json.Unmarshal([]byte(pv.MetadataJSON), metadata); err != nil {
return err
}
if metadata.Deprecated == message {
continue
}
metadata.Deprecated = message
raw, err := json.Marshal(metadata)
if err != nil {
return err
}
pv.MetadataJSON = string(raw)
if err := packages_model.UpdateVersion(txCtx, pv); err != nil {
return err
}
}
return nil
})
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
ctx.Status(http.StatusOK)
}
// DeletePackageVersion deletes the package version
func DeletePackageVersion(ctx *context.Context) {
packageName := packageNameFromParams(ctx)