From 4b6ccbd63188c1f7a43ac9d427e60778d949022c Mon Sep 17 00:00:00 2001 From: Julian Schlarb Date: Sun, 8 Jun 2025 00:13:37 +0200 Subject: [PATCH 1/7] feat: auto cleanup of offline runners (#7803) Fixes #7646 Adds a cron job to cleanup action runners that have been offline or inactive for X amount of time. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7803 Reviewed-by: Gusted Co-authored-by: Julian Schlarb Co-committed-by: Julian Schlarb --- models/actions/runner.go | 51 +++++++++++ models/actions/runner_test.go | 67 +++++++++++++++ models/fixtures/action_runner.yml | 119 ++++++++++++++++++++++++++ options/locale_next/locale_en-US.json | 1 + services/actions/cleanup.go | 6 ++ services/cron/setting.go | 7 ++ services/cron/tasks_actions.go | 21 +++++ tests/integration/api_admin_test.go | 4 +- 8 files changed, 274 insertions(+), 2 deletions(-) diff --git a/models/actions/runner.go b/models/actions/runner.go index 4d5056b425..bece1ae301 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -16,6 +16,7 @@ import ( repo_model "forgejo.org/models/repo" "forgejo.org/models/shared/types" user_model "forgejo.org/models/user" + "forgejo.org/modules/log" "forgejo.org/modules/optional" "forgejo.org/modules/timeutil" "forgejo.org/modules/translation" @@ -353,3 +354,53 @@ func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) { } return res.RowsAffected() } + +func DeleteOfflineRunners(ctx context.Context, olderThan timeutil.TimeStamp, globalOnly bool) error { + log.Info("Doing: DeleteOfflineRunners") + + if olderThan.AsTime().After(timeutil.TimeStampNow().AddDuration(-RunnerOfflineTime).AsTime()) { + return fmt.Errorf("invalid `cron.cleanup_offline_runners.older_than`value: must be at least %q", RunnerOfflineTime) + } + + cond := builder.Or( + // never online + builder.And(builder.Eq{"last_online": 0}, builder.Lt{"created": olderThan}), + // was online but offline + builder.And(builder.Gt{"last_online": 0}, builder.Lt{"last_online": olderThan}), + ) + + if globalOnly { + cond = builder.And(cond, builder.Eq{"owner_id": 0}, builder.Eq{"repo_id": 0}) + } + + if err := db.Iterate( + ctx, + cond, + func(ctx context.Context, r *ActionRunner) error { + if err := DeleteRunner(ctx, r); err != nil { + return fmt.Errorf("DeleteOfflineRunners: %w", err) + } + lastOnline := r.LastOnline.AsTime() + olderThanTime := olderThan.AsTime() + if !lastOnline.IsZero() && lastOnline.Before(olderThanTime) { + log.Info( + "Deleted runner [ID: %d, Name: %s], last online %s ago", + r.ID, r.Name, olderThanTime.Sub(lastOnline).String(), + ) + } else { + log.Info( + "Deleted runner [ID: %d, Name: %s], unused since %s ago", + r.ID, r.Name, olderThanTime.Sub(r.Created.AsTime()).String(), + ) + } + + return nil + }, + ); err != nil { + return err + } + + log.Info("Finished: DeleteOfflineRunners") + + return nil +} diff --git a/models/actions/runner_test.go b/models/actions/runner_test.go index 0623e66046..1916c35a76 100644 --- a/models/actions/runner_test.go +++ b/models/actions/runner_test.go @@ -6,10 +6,12 @@ import ( "encoding/binary" "fmt" "testing" + "time" auth_model "forgejo.org/models/auth" "forgejo.org/models/db" "forgejo.org/models/unittest" + "forgejo.org/modules/timeutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -73,3 +75,68 @@ func TestDeleteRunner(t *testing.T) { idAsBinary[6], idAsBinary[7]) assert.Equal(t, idAsHexadecimal, after.UUID[19:]) } + +func TestDeleteOfflineRunnersRunnerGlobalOnly(t *testing.T) { + baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC) + timeutil.MockSet(baseTime) + defer timeutil.MockUnset() + + require.NoError(t, unittest.PrepareTestDatabase()) + + olderThan := timeutil.TimeStampNow().Add(-timeutil.Hour) + + require.NoError(t, DeleteOfflineRunners(db.DefaultContext, olderThan, true)) + + // create at test base time + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 12345678}) + // last_online test base time + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000001}) + // created one month ago but a repo + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000002}) + // last online one hour ago + unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000003}) + // last online 10 seconds ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000004}) + // created 1 month ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000005}) + // created 1 hour ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000006}) + // last online 1 hour ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000007}) +} + +func TestDeleteOfflineRunnersAll(t *testing.T) { + baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC) + timeutil.MockSet(baseTime) + defer timeutil.MockUnset() + + require.NoError(t, unittest.PrepareTestDatabase()) + + olderThan := timeutil.TimeStampNow().Add(-timeutil.Hour) + + require.NoError(t, DeleteOfflineRunners(db.DefaultContext, olderThan, false)) + + // create at test base time + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 12345678}) + // last_online test base time + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000001}) + // created one month ago + unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000002}) + // last online one hour ago + unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000003}) + // last online 10 seconds ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000004}) + // created 1 month ago + unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000005}) + // created 1 hour ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000006}) + // last online 1 hour ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000007}) +} + +func TestDeleteOfflineRunnersErrorOnInvalidOlderThanValue(t *testing.T) { + baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC) + timeutil.MockSet(baseTime) + defer timeutil.MockUnset() + require.Error(t, DeleteOfflineRunners(db.DefaultContext, timeutil.TimeStampNow(), false)) +} diff --git a/models/fixtures/action_runner.yml b/models/fixtures/action_runner.yml index 94deac998e..fcf26d49b6 100644 --- a/models/fixtures/action_runner.yml +++ b/models/fixtures/action_runner.yml @@ -18,3 +18,122 @@ created: 1716104432 updated: 1716104432 deleted: ~ +- id: 10000001 + uuid: 10d3b248-6460-4bf5-b819-1f5b3109e10f + name: global-online + version: v6.3.1+7-gc4c0ca0 + owner_id: 0 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: 7e9ed71f64e98ce1f70e94c63f3cb6c41a8cb0b90de3e1daf7ec5c35361d60ed44da67c5ac393b7aaf443dcfc766007dc828 + token_salt: WUcgZWl7mW + last_online: 1716104422 + last_active: 0 + agent_labels: '["docker"]' + created: 1716104431 + updated: 1716104422 + deleted: ~ +- id: 10000002 + uuid: 1d188484-dd97-4a70-b707-5e87b578ab6b + name: repo-never-used + version: v6.3.1+7-gc4c0ca0 + owner_id: 0 + repo_id: 1 + description: "" + base: 0 + repo_range: "" + token_hash: 51e88c17ac8b54dd101dc2e4f530a71643c703adba7170f4b1a28f1cb483b4cfb107798c521e0532ef3c6480b64518a5c6a5 + token_salt: 4rh8ncXYIO + last_online: 0 + last_active: 0 + agent_labels: '["docker"]' + created: 1713512432 + updated: 1713512432 + deleted: ~ +- id: 10000003 + uuid: 7a039c6b-b0b2-4cf5-a93d-715d617f99e2 + name: global-offline + version: v6.3.1+7-gc4c0ca0 + owner_id: 0 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: c76960c56bc6069f0d1648991ec626500abe8c15286f5c355d565c3b5ba945d7d6f1272a6c77849e592528179511b94f5d69 + token_salt: TFMe2jhOkB + last_online: 1715499632 + last_active: 0 + agent_labels: '["docker"]' + created: 1715499632 + updated: 1715499632 + deleted: ~ +- id: 10000004 + uuid: 93ca7fdd-faca-4df6-a474-8345263ef10b + name: user-online + version: v6.3.1+7-gc4c0ca0 + owner_id: 1 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: 6ddf7f0f2301d2b3f66418145dc497a6d09fa6586e659afcb5ae2a0c5b639561d795aff8062537db9df73b396842ea826134 + token_salt: QcdGuReAp4 + last_online: 1716104422 + last_active: 0 + agent_labels: '["docker"]' + created: 1716104431 + updated: 1716104422 + deleted: ~ +- id: 10000005 + uuid: a8534df6-c4be-40f4-9714-903b69d973d9 + name: user-never-used + version: v6.3.1+7-gc4c0ca0 + owner_id: 1 + repo_id: 0 + description: desc + base: 0 + repo_range: "" + token_hash: 4441de7defcfc3d21baa608dec66a562cf23307abddaabdbb836907ac5f48c8780c354891916c525b79ec7af8e95be7a09b4 + token_salt: ONNqIOnj3t + last_online: 0 + last_active: 0 + agent_labels: '["docker"]' + created: 1713512433 + updated: 1713512433 + deleted: ~ +- id: 10000006 + uuid: e1c5bb6c-de68-4335-8955-5192f76708ac + name: orga-fresh-created + version: v6.3.1+7-gc4c0ca0 + owner_id: 35 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: a61f9ee48c6847d243ace0a8936efe80af9277c7bc46d6da6e03d1d406608b8023ee66600ad24f0effaa8e3338f92ac97ac9 + token_salt: fZJKjrFGWA + last_online: 0 + last_active: 0 + agent_labels: '["docker"]' + created: 1716100832 + updated: 1716100832 + deleted: ~ +- id: 10000007 + uuid: ff755f06-948e-479b-8031-5b3e9f123e32 + name: orga-offline + version: v6.3.1+7-gc4c0ca0 + owner_id: 35 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: 9372efb38f9b64efe65065380abe2f24ef34a59d9619f4cdc08f1151e9849f0b6e722aa10538e8730288de6e2f09acdac695 + token_salt: TnU7iiIdCb + last_online: 1716100832 + last_active: 0 + agent_labels: '["docker"]' + created: 1736085520 + updated: 1716100832 + deleted: ~ diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 3015be3ecd..96d0c4bc63 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -92,5 +92,6 @@ "discussion.locked": "This discussion has been locked. Commenting is limited to contributors.", "editor.textarea.tab_hint": "Line already indented. Press Tab again or Escape to leave the editor.", "editor.textarea.shift_tab_hint": "No indentation on this line. Press Shift + Tab again or Escape to leave the editor.", + "admin.dashboard.cleanup_offline_runners": "Cleanup offline runners", "meta.last_line": "Thank you for translating Forgejo! This line isn't seen by the users but it serves other purposes in the translation management. You can place a fun fact in the translation instead of translating it." } diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index fde5286e60..918be0f185 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -126,3 +126,9 @@ func CleanupLogs(ctx context.Context) error { log.Info("Removed %d logs", count) return nil } + +// CleanupOfflineRunners removes offline runners +func CleanupOfflineRunners(ctx context.Context, duration time.Duration, globalOnly bool) error { + olderThan := timeutil.TimeStampNow().AddDuration(-duration) + return actions_model.DeleteOfflineRunners(ctx, olderThan, globalOnly) +} diff --git a/services/cron/setting.go b/services/cron/setting.go index 7fd4c4e1d8..2db6c15370 100644 --- a/services/cron/setting.go +++ b/services/cron/setting.go @@ -46,6 +46,13 @@ type CleanupHookTaskConfig struct { NumberToKeep int } +// CleanupOfflineRunnersConfig represents a cron task with settings to clean up offline-runner +type CleanupOfflineRunnersConfig struct { + BaseConfig + OlderThan time.Duration + GlobalScopeOnly bool +} + // GetSchedule returns the schedule for the base config func (b *BaseConfig) GetSchedule() string { return b.Schedule diff --git a/services/cron/tasks_actions.go b/services/cron/tasks_actions.go index a7fd3cd0bc..2cd484fa69 100644 --- a/services/cron/tasks_actions.go +++ b/services/cron/tasks_actions.go @@ -5,6 +5,7 @@ package cron import ( "context" + "time" user_model "forgejo.org/models/user" "forgejo.org/modules/setting" @@ -20,6 +21,7 @@ func initActionsTasks() { registerCancelAbandonedJobs() registerScheduleTasks() registerActionsCleanup() + registerOfflineRunnersCleanup() } func registerStopZombieTasks() { @@ -74,3 +76,22 @@ func registerActionsCleanup() { return actions_service.Cleanup(ctx) }) } + +func registerOfflineRunnersCleanup() { + RegisterTaskFatal("cleanup_offline_runners", &CleanupOfflineRunnersConfig{ + BaseConfig: BaseConfig{ + Enabled: false, + RunAtStart: false, + Schedule: "@midnight", + }, + GlobalScopeOnly: true, + OlderThan: time.Hour * 24, + }, func(ctx context.Context, _ *user_model.User, cfg Config) error { + c := cfg.(*CleanupOfflineRunnersConfig) + return actions_service.CleanupOfflineRunners( + ctx, + c.OlderThan, + c.GlobalScopeOnly, + ) + }) +} diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go index 104fdf4f67..9351dd9c20 100644 --- a/tests/integration/api_admin_test.go +++ b/tests/integration/api_admin_test.go @@ -333,11 +333,11 @@ func TestAPICron(t *testing.T) { AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, "28", resp.Header().Get("X-Total-Count")) + assert.Equal(t, "29", resp.Header().Get("X-Total-Count")) var crons []api.Cron DecodeJSON(t, resp, &crons) - assert.Len(t, crons, 28) + assert.Len(t, crons, 29) }) t.Run("Execute", func(t *testing.T) { From a8e375eb282d447db7739707a585b8b2011385af Mon Sep 17 00:00:00 2001 From: Julian Schlarb Date: Mon, 9 Jun 2025 08:43:41 +0200 Subject: [PATCH 2/7] fix: omit Content-Length on 307 redirects when serving direct manifest for containers (#8037) Containers have been refactored to use the same serve method as other packages, ensuring consistent response handling. fixes #7888 ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. ## Release notes - Bug fixes - [PR](https://codeberg.org/forgejo/forgejo/pulls/8037): omit Content-Length on 307 redirects when serving direct manifest for containers Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8037 Reviewed-by: Earl Warren Co-authored-by: Julian Schlarb Co-committed-by: Julian Schlarb --- modules/httplib/serve.go | 8 +++++ routers/api/packages/container/container.go | 35 +++++++++---------- routers/api/packages/helper/helper.go | 16 ++++----- services/context/base.go | 2 +- services/context/base_test.go | 22 ++++++++++++ .../api_packages_container_test.go | 28 +++++++++++++-- 6 files changed, 81 insertions(+), 30 deletions(-) diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index cd35367bc9..c5f0658d4e 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -35,6 +35,8 @@ type ServeHeaderOptions struct { Filename string CacheDuration time.Duration // defaults to 5 minutes LastModified time.Time + AdditionalHeaders http.Header + RedirectStatusCode int } // ServeSetHeaders sets necessary content serve headers @@ -82,6 +84,12 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat)) } + + if opts.AdditionalHeaders != nil { + for k, v := range opts.AdditionalHeaders { + header[k] = v + } + } } // ServeData download file from io.Reader diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index 5276dd5706..4d59e391a5 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -691,33 +691,30 @@ func DeleteManifest(ctx *context.Context) { func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) { serveDirectReqParams := make(url.Values) serveDirectReqParams.Set("response-content-type", pfd.Properties.GetByName(container_module.PropertyMediaType)) - s, u, _, err := packages_service.GetPackageBlobStream(ctx, pfd.File, pfd.Blob, serveDirectReqParams) + s, u, pf, err := packages_service.GetPackageBlobStream(ctx, pfd.File, pfd.Blob, serveDirectReqParams) if err != nil { + if errors.Is(err, packages_model.ErrPackageFileNotExist) { + apiError(ctx, http.StatusNotFound, err) + return + } apiError(ctx, http.StatusInternalServerError, err) return } - headers := &containerHeaders{ - ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest), - ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType), - ContentLength: pfd.Blob.Size, - Status: http.StatusOK, + opts := &context.ServeHeaderOptions{ + ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType), + RedirectStatusCode: http.StatusTemporaryRedirect, + AdditionalHeaders: map[string][]string{ + "Docker-Distribution-Api-Version": {"registry/2.0"}, + }, } - if u != nil { - headers.Status = http.StatusTemporaryRedirect - headers.Location = u.String() - - setResponseHeaders(ctx.Resp, headers) - return + if d := pfd.Properties.GetByName(container_module.PropertyDigest); d != "" { + opts.AdditionalHeaders["Docker-Content-Digest"] = []string{d} + opts.AdditionalHeaders["ETag"] = []string{fmt.Sprintf(`"%s"`, d)} } - defer s.Close() - - setResponseHeaders(ctx.Resp, headers) - if _, err := io.Copy(ctx.Resp, s); err != nil { - log.Error("Error whilst copying content to response: %v", err) - } + helper.ServePackageFile(ctx, s, u, pf, opts) } // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery @@ -725,7 +722,7 @@ func GetTagList(ctx *context.Context) { image := ctx.Params("image") if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil { - if err == packages_model.ErrPackageNotExist { + if errors.Is(err, packages_model.ErrPackageNotExist) { apiErrorDefined(ctx, errNameUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) diff --git a/routers/api/packages/helper/helper.go b/routers/api/packages/helper/helper.go index 99c0867bbb..f9b91d9a09 100644 --- a/routers/api/packages/helper/helper.go +++ b/routers/api/packages/helper/helper.go @@ -39,16 +39,9 @@ func LogAndProcessError(ctx *context.Context, status int, obj any, cb func(strin } } -// Serves the content of the package file +// ServePackageFile Serves the content of the package file // If the url is set it will redirect the request, otherwise the content is copied to the response. func ServePackageFile(ctx *context.Context, s io.ReadSeekCloser, u *url.URL, pf *packages_model.PackageFile, forceOpts ...*context.ServeHeaderOptions) { - if u != nil { - ctx.Redirect(u.String()) - return - } - - defer s.Close() - var opts *context.ServeHeaderOptions if len(forceOpts) > 0 { opts = forceOpts[0] @@ -59,5 +52,12 @@ func ServePackageFile(ctx *context.Context, s io.ReadSeekCloser, u *url.URL, pf } } + if u != nil { + ctx.Redirect(u.String(), opts.RedirectStatusCode) + return + } + + defer s.Close() + ctx.ServeContent(s, opts) } diff --git a/services/context/base.go b/services/context/base.go index 0275ea8a99..dc3d226bb0 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -250,7 +250,7 @@ func (b *Base) PlainText(status int, text string) { // Redirect redirects the request func (b *Base) Redirect(location string, status ...int) { code := http.StatusSeeOther - if len(status) == 1 { + if len(status) == 1 && status[0] > 0 { code = status[0] } diff --git a/services/context/base_test.go b/services/context/base_test.go index bf746766d9..9e058d8f24 100644 --- a/services/context/base_test.go +++ b/services/context/base_test.go @@ -36,6 +36,7 @@ func TestRedirect(t *testing.T) { cleanup() has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy" assert.Equal(t, c.keep, has, "url = %q", c.url) + assert.Equal(t, http.StatusSeeOther, resp.Code) } req, _ = http.NewRequest("GET", "/", nil) @@ -47,3 +48,24 @@ func TestRedirect(t *testing.T) { assert.Equal(t, "/other", resp.Header().Get("HX-Redirect")) assert.Equal(t, http.StatusNoContent, resp.Code) } + +func TestRedirectOptionalStatus(t *testing.T) { + defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/")() + req, _ := http.NewRequest("GET", "/", nil) + + cases := []struct { + expected int + actual int + }{ + {expected: 303}, + {http.StatusTemporaryRedirect, 307}, + {http.StatusPermanentRedirect, 308}, + } + for _, c := range cases { + resp := httptest.NewRecorder() + b, cleanup := NewBaseContext(resp, req) + b.Redirect("/", c.actual) + cleanup() + assert.Equal(t, c.expected, resp.Code) + } +} diff --git a/tests/integration/api_packages_container_test.go b/tests/integration/api_packages_container_test.go index 0e977e9ae7..e3f7d010b3 100644 --- a/tests/integration/api_packages_container_test.go +++ b/tests/integration/api_packages_container_test.go @@ -430,14 +430,19 @@ func TestPackageContainer(t *testing.T) { assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest")) }) - t.Run("GetManifest", func(t *testing.T) { + t.Run("GetManifest unknown-tag", func(t *testing.T) { defer tests.PrintCurrentTest(t)() req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/unknown-tag", url)). AddTokenAuth(userToken) MakeRequest(t, req, http.StatusNotFound) + }) - req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)). + t.Run("GetManifest serv indirect", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Packages.Storage.MinioConfig.ServeDirect, false)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)). AddTokenAuth(userToken) resp := MakeRequest(t, req, http.StatusOK) @@ -446,6 +451,25 @@ func TestPackageContainer(t *testing.T) { assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest")) assert.Equal(t, manifestContent, resp.Body.String()) }) + + t.Run("GetManifest serv direct", func(t *testing.T) { + if setting.Packages.Storage.Type != setting.MinioStorageType { + t.Skip("Test skipped for non-Minio-storage.") + return + } + + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Packages.Storage.MinioConfig.ServeDirect, true)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusTemporaryRedirect) + + assert.Empty(t, resp.Header().Get("Content-Length")) + assert.NotEmpty(t, resp.Header().Get("Location")) + assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type")) + assert.Empty(t, resp.Header().Get("Docker-Content-Digest")) + }) }) } From b2b039b6e7811b49f744466c93c38e4d6422bd00 Mon Sep 17 00:00:00 2001 From: John Moon Date: Mon, 9 Jun 2025 10:01:59 +0200 Subject: [PATCH 3/7] fix: allow instance API URLs in release assets (#7644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, if you try to add an "external" link to a release in Forgejo, the validation code checks for basic URL soundness and then specifically checks that the URL is not an API URL. In some cases, it may make sense to link to instance API URLs (like when you want to create a release that links to several different repos' packages). Relax this check so it only validates basic URL soundness. Refs: https://codeberg.org/forgejo/forgejo/issues/7598 ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. See: https://codeberg.org/forgejo/docs/pulls/1161 ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. ## Release notes - Bug fixes - [PR](https://codeberg.org/forgejo/forgejo/pulls/7644): allow instance API URLs in release assets Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7644 Reviewed-by: Gusted Reviewed-by: Malte Jürgens Co-authored-by: John Moon Co-committed-by: John Moon --- models/repo/attachment.go | 4 ++-- modules/validation/helpers.go | 5 +++++ services/attachment/attachment.go | 2 +- tests/integration/api_releases_test.go | 24 ++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/models/repo/attachment.go b/models/repo/attachment.go index 4a09ef14f2..6d903be5f8 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -235,7 +235,7 @@ func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...str if attach.UUID == "" { return errors.New("attachment uuid should be not blank") } - if attach.ExternalURL != "" && !validation.IsValidExternalURL(attach.ExternalURL) { + if attach.ExternalURL != "" && !validation.IsValidReleaseAssetURL(attach.ExternalURL) { return ErrInvalidExternalURL{ExternalURL: attach.ExternalURL} } _, err := db.GetEngine(ctx).Where("uuid=?", attach.UUID).Cols(cols...).Update(attach) @@ -244,7 +244,7 @@ func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...str // UpdateAttachment updates the given attachment in database func UpdateAttachment(ctx context.Context, atta *Attachment) error { - if atta.ExternalURL != "" && !validation.IsValidExternalURL(atta.ExternalURL) { + if atta.ExternalURL != "" && !validation.IsValidReleaseAssetURL(atta.ExternalURL) { return ErrInvalidExternalURL{ExternalURL: atta.ExternalURL} } sess := db.GetEngine(ctx).Cols("name", "issue_id", "release_id", "comment_id", "download_count") diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go index 1f573564e6..4b28dead03 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -75,6 +75,11 @@ func IsValidExternalURL(uri string) bool { return true } +// IsValidReleaseAssetURL checks if the URL is valid for external release assets +func IsValidReleaseAssetURL(uri string) bool { + return IsValidURL(uri) +} + // IsValidExternalTrackerURLFormat checks if URL matches required syntax for external trackers func IsValidExternalTrackerURLFormat(uri string) bool { if !IsValidExternalURL(uri) { diff --git a/services/attachment/attachment.go b/services/attachment/attachment.go index 365bd7faf6..b6f763842b 100644 --- a/services/attachment/attachment.go +++ b/services/attachment/attachment.go @@ -51,7 +51,7 @@ func NewExternalAttachment(ctx context.Context, attach *repo_model.Attachment) ( if attach.ExternalURL == "" { return nil, fmt.Errorf("attachment %s should have a external url", attach.Name) } - if !validation.IsValidExternalURL(attach.ExternalURL) { + if !validation.IsValidReleaseAssetURL(attach.ExternalURL) { return nil, repo_model.ErrInvalidExternalURL{ExternalURL: attach.ExternalURL} } diff --git a/tests/integration/api_releases_test.go b/tests/integration/api_releases_test.go index d5f7960ab1..f25948989a 100644 --- a/tests/integration/api_releases_test.go +++ b/tests/integration/api_releases_test.go @@ -430,6 +430,30 @@ func TestAPIExternalAssetRelease(t *testing.T) { assert.Equal(t, "external", attachment.Type) } +func TestAPIAllowedAPIURLInRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") + internalURL := "https://localhost:3003/api/packages/owner/generic/test/1.0.0/test.txt" + + req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=%s", owner.Name, repo.Name, r.ID, url.QueryEscape(internalURL))). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var attachment *api.Attachment + DecodeJSON(t, resp, &attachment) + + assert.Equal(t, "test-asset", attachment.Name) + assert.EqualValues(t, 0, attachment.Size) + assert.Equal(t, internalURL, attachment.DownloadURL) + assert.Equal(t, "external", attachment.Type) +} + func TestAPIDuplicateAssetRelease(t *testing.T) { defer tests.PrepareTestEnv(t)() From bd6f3243ab890d1479dd8dae8d78a616fe05a520 Mon Sep 17 00:00:00 2001 From: pat-s Date: Mon, 9 Jun 2025 10:14:53 +0200 Subject: [PATCH 4/7] feat: support artifact uploads for OCI container packages (#8070) # Fix OCI artifact uploads with`oras` ## Problem ORAS (OCI Registry As Storage) artifact uploads were failing with several HTTP-related errors when pushing to Forgejo's container registry. This prevented users from storing OCI artifacts like `artifacthub-repo.yaml` in commands like `oras push [...] artifacthub-repo.yaml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml`. This has been discussed previously in https://github.com/go-gitea/gitea/issues/25846 ## Root Causes and Fixes ### 1. Missing Content-Length for Empty Blobs **Issue**: Empty blobs (size 0) were not getting the required `Content-Length: 0` header, causing ORAS to fail with "unknown response Content-Length". **Fix**: Changed the condition in `setResponseHeaders` from `if h.ContentLength != 0` to `if h.ContentLength >= 0` to ensure the Content-Length header is always set for valid blob sizes. ```go // Before if h.ContentLength != 0 { resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10)) } // After if h.ContentLength >= 0 { resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10)) } ``` ### 2. Content-Length Mismatch in JSON Error Responses **Issue**: The `jsonResponse` function was calling `WriteHeader()` before writing JSON content, causing "wrote more than the declared Content-Length" errors when the HTTP stack calculated a different Content-Length than what was actually written. **Fix**: Modified `jsonResponse` to buffer JSON content first, calculate the exact Content-Length, then write the complete response. ### 3. Incomplete HTTP Responses in Error Handling **Issue**: The `apiError` function was only setting response headers without writing any response body, causing EOF errors when clients expected a complete HTTP response. **Fix**: Updated `apiError` to write proper JSON error responses following the OCI Distribution Specification format with `code` and `message` fields. ### 4. Empty Config Blob Handling for OCI Artifacts **Issue**: OCI artifacts often have empty config blobs (required by spec but contain no data). The JSON decoder was failing with EOF when trying to parse these empty configs. **Fix**: Added EOF handling in `parseOCIImageConfig` to return a valid default metadata object for empty config blobs. ```go if err := json.NewDecoder(r).Decode(&image); err != nil { // Handle empty config blobs (common in OCI artifacts) if err == io.EOF { return &Metadata{ Type: TypeOCI, Platform: DefaultPlatform, }, nil } return nil, err } ``` ## Testing Verified that ORAS artifact uploads now work correctly: ```bash oras push registry/owner/package:artifacthub.io \ --config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \ artifacthub-repo.yaml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml ``` ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8070 Reviewed-by: Earl Warren Co-authored-by: pat-s Co-committed-by: pat-s --- .../dashboards/overview.libsonnet | 4 +- modules/packages/container/metadata.go | 7 + modules/packages/container/metadata_test.go | 47 +++++ routers/api/packages/container/container.go | 26 ++- .../api/packages/container/container_test.go | 124 ++++++++++++ .../api_packages_container_test.go | 186 +++++++++++++++--- 6 files changed, 355 insertions(+), 39 deletions(-) create mode 100644 routers/api/packages/container/container_test.go diff --git a/contrib/gitea-monitoring-mixin/dashboards/overview.libsonnet b/contrib/gitea-monitoring-mixin/dashboards/overview.libsonnet index 31b7d4f9b2..108cab0eb1 100644 --- a/contrib/gitea-monitoring-mixin/dashboards/overview.libsonnet +++ b/contrib/gitea-monitoring-mixin/dashboards/overview.libsonnet @@ -408,7 +408,7 @@ local addIssueLabelsOverrides(labels) = regex: '', type: 'query', multi: true, - allValue: '.+' + allValue: '.+', }, ) .addTemplate( @@ -423,7 +423,7 @@ local addIssueLabelsOverrides(labels) = regex: '', type: 'query', multi: true, - allValue: '.+' + allValue: '.+', }, ) .addTemplate( diff --git a/modules/packages/container/metadata.go b/modules/packages/container/metadata.go index ec9d834357..6cac77b7ff 100644 --- a/modules/packages/container/metadata.go +++ b/modules/packages/container/metadata.go @@ -84,6 +84,13 @@ func ParseImageConfig(mt string, r io.Reader) (*Metadata, error) { func parseOCIImageConfig(r io.Reader) (*Metadata, error) { var image oci.Image if err := json.NewDecoder(r).Decode(&image); err != nil { + // Handle empty config blobs (common in OCI artifacts) + if err == io.EOF { + return &Metadata{ + Type: TypeOCI, + Platform: DefaultPlatform, + }, nil + } return nil, err } diff --git a/modules/packages/container/metadata_test.go b/modules/packages/container/metadata_test.go index 6c8c6ea5b9..5596c95751 100644 --- a/modules/packages/container/metadata_test.go +++ b/modules/packages/container/metadata_test.go @@ -4,6 +4,7 @@ package container import ( + "io" "strings" "testing" @@ -60,3 +61,49 @@ func TestParseImageConfig(t *testing.T) { assert.Equal(t, projectURL, metadata.ProjectURL) assert.Equal(t, repositoryURL, metadata.RepositoryURL) } + +func TestParseImageConfigEmptyBlob(t *testing.T) { + t.Run("Empty config blob (EOF)", func(t *testing.T) { + // Test empty reader (simulates empty config blob common in OCI artifacts) + metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader("")) + require.NoError(t, err) + + assert.Equal(t, TypeOCI, metadata.Type) + assert.Equal(t, DefaultPlatform, metadata.Platform) + assert.Empty(t, metadata.Description) + assert.Empty(t, metadata.Authors) + assert.Empty(t, metadata.Labels) + assert.Empty(t, metadata.Manifests) + }) + + t.Run("Empty JSON object", func(t *testing.T) { + // Test minimal valid JSON config + metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader("{}")) + require.NoError(t, err) + + assert.Equal(t, TypeOCI, metadata.Type) + assert.Equal(t, DefaultPlatform, metadata.Platform) + assert.Empty(t, metadata.Description) + assert.Empty(t, metadata.Authors) + }) + + t.Run("Invalid JSON still returns error", func(t *testing.T) { + // Test that actual JSON errors (not EOF) are still returned + _, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader("{invalid json")) + require.Error(t, err) + assert.NotEqual(t, io.EOF, err) + }) + + t.Run("OCI artifact with empty config", func(t *testing.T) { + // Test OCI artifact scenario with minimal config + configOCI := `{"config": {}}` + metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader(configOCI)) + require.NoError(t, err) + + assert.Equal(t, TypeOCI, metadata.Type) + assert.Equal(t, DefaultPlatform, metadata.Platform) + assert.Empty(t, metadata.Description) + assert.Empty(t, metadata.Authors) + assert.Empty(t, metadata.ImageLayers) + }) +} diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index 4d59e391a5..191a4aa455 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -4,6 +4,7 @@ package container import ( + "bytes" "errors" "fmt" "io" @@ -62,9 +63,6 @@ func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) { if h.ContentType != "" { resp.Header().Set("Content-Type", h.ContentType) } - if h.ContentLength != 0 { - resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10)) - } if h.UploadUUID != "" { resp.Header().Set("Docker-Upload-Uuid", h.UploadUUID) } @@ -72,17 +70,29 @@ func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) { resp.Header().Set("Docker-Content-Digest", h.ContentDigest) resp.Header().Set("ETag", fmt.Sprintf(`"%s"`, h.ContentDigest)) } + if h.ContentLength >= 0 { + resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10)) + } resp.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") resp.WriteHeader(h.Status) } func jsonResponse(ctx *context.Context, status int, obj any) { - setResponseHeaders(ctx.Resp, &containerHeaders{ - Status: status, - ContentType: "application/json", - }) - if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil { + // Buffer the JSON content first to calculate correct Content-Length + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(obj); err != nil { log.Error("JSON encode: %v", err) + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Status: status, + ContentType: "application/json", + ContentLength: int64(buf.Len()), + }) + + if _, err := buf.WriteTo(ctx.Resp); err != nil { + log.Error("JSON write: %v", err) } } diff --git a/routers/api/packages/container/container_test.go b/routers/api/packages/container/container_test.go new file mode 100644 index 0000000000..2ed38d846d --- /dev/null +++ b/routers/api/packages/container/container_test.go @@ -0,0 +1,124 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetResponseHeaders(t *testing.T) { + t.Run("Content-Length for empty content", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusOK, + ContentLength: 0, // Empty blob + ContentDigest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }) + + assert.Equal(t, "0", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", recorder.Header().Get("Docker-Content-Digest")) + assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version")) + assert.Equal(t, http.StatusOK, recorder.Code) + }) + + t.Run("Content-Length for non-empty content", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusOK, + ContentLength: 1024, + ContentDigest: "sha256:abcd1234", + }) + + assert.Equal(t, "1024", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:abcd1234", recorder.Header().Get("Docker-Content-Digest")) + }) + + t.Run("All headers set correctly", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusAccepted, + ContentLength: 512, + ContentDigest: "sha256:test123", + ContentType: "application/vnd.oci.image.manifest.v1+json", + Location: "/v2/test/repo/blobs/uploads/uuid123", + Range: "0-511", + UploadUUID: "uuid123", + }) + + assert.Equal(t, "512", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:test123", recorder.Header().Get("Docker-Content-Digest")) + assert.Equal(t, "application/vnd.oci.image.manifest.v1+json", recorder.Header().Get("Content-Type")) + assert.Equal(t, "/v2/test/repo/blobs/uploads/uuid123", recorder.Header().Get("Location")) + assert.Equal(t, "0-511", recorder.Header().Get("Range")) + assert.Equal(t, "uuid123", recorder.Header().Get("Docker-Upload-Uuid")) + assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version")) + assert.Equal(t, `"sha256:test123"`, recorder.Header().Get("ETag")) + assert.Equal(t, http.StatusAccepted, recorder.Code) + }) +} + +// TestResponseHeadersForEmptyBlobs tests the core fix for ORAS empty blob support +func TestResponseHeadersForEmptyBlobs(t *testing.T) { + t.Run("Content-Length set for empty blob", func(t *testing.T) { + recorder := httptest.NewRecorder() + + // This tests the main fix: empty blobs should have Content-Length: 0 + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusOK, + ContentLength: 0, // Empty blob (like empty config in ORAS artifacts) + ContentDigest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }) + + // The key fix: Content-Length should be set even for 0-byte blobs + assert.Equal(t, "0", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", recorder.Header().Get("Docker-Content-Digest")) + assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version")) + assert.Equal(t, `"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`, recorder.Header().Get("ETag")) + assert.Equal(t, http.StatusOK, recorder.Code) + }) + + t.Run("Content-Length set for regular blob", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusOK, + ContentLength: 1024, + ContentDigest: "sha256:abcd1234", + }) + + assert.Equal(t, "1024", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:abcd1234", recorder.Header().Get("Docker-Content-Digest")) + }) + + t.Run("All headers set correctly", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusAccepted, + ContentLength: 512, + ContentDigest: "sha256:test123", + ContentType: "application/vnd.oci.image.manifest.v1+json", + Location: "/v2/test/repo/blobs/uploads/uuid123", + Range: "0-511", + UploadUUID: "uuid123", + }) + + assert.Equal(t, "512", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:test123", recorder.Header().Get("Docker-Content-Digest")) + assert.Equal(t, "application/vnd.oci.image.manifest.v1+json", recorder.Header().Get("Content-Type")) + assert.Equal(t, "/v2/test/repo/blobs/uploads/uuid123", recorder.Header().Get("Location")) + assert.Equal(t, "0-511", recorder.Header().Get("Range")) + assert.Equal(t, "uuid123", recorder.Header().Get("Docker-Upload-Uuid")) + assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version")) + assert.Equal(t, `"sha256:test123"`, recorder.Header().Get("ETag")) + assert.Equal(t, http.StatusAccepted, recorder.Code) + }) +} diff --git a/tests/integration/api_packages_container_test.go b/tests/integration/api_packages_container_test.go index e3f7d010b3..fb092c91c7 100644 --- a/tests/integration/api_packages_container_test.go +++ b/tests/integration/api_packages_container_test.go @@ -56,7 +56,7 @@ func TestPackageContainer(t *testing.T) { return values } - images := []string{"test", "te/st"} + images := []string{"test", "te/st", "oras-artifact"} tags := []string{"latest", "main"} multiTag := "multi" @@ -177,6 +177,90 @@ func TestPackageContainer(t *testing.T) { assert.Equal(t, "registry/2.0", resp.Header().Get("Docker-Distribution-Api-Version")) }) + t.Run("ORAS Artifact Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + image := "oras-artifact" + url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image) + + // Empty config blob (common in ORAS artifacts) + emptyConfigDigest := "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + emptyConfigContent := "" + + // Upload empty config blob + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, emptyConfigDigest), bytes.NewReader([]byte(emptyConfigContent))). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusCreated) + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, emptyConfigDigest), resp.Header().Get("Location")) + assert.Equal(t, emptyConfigDigest, resp.Header().Get("Docker-Content-Digest")) + + // Verify empty blob exists and has correct Content-Length + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, emptyConfigDigest)). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "0", resp.Header().Get("Content-Length")) // This was the main fix + assert.Equal(t, emptyConfigDigest, resp.Header().Get("Docker-Content-Digest")) + + // Upload a small data blob (e.g., artifacthub metadata) + artifactData := `{"name":"test-artifact","version":"1.0.0"}` + artifactDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(artifactData))) + + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, artifactDigest), bytes.NewReader([]byte(artifactData))). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusCreated) + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, artifactDigest), resp.Header().Get("Location")) + + // Create OCI artifact manifest + artifactManifest := fmt.Sprintf(`{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.cncf.artifacthub.config.v1+yaml", + "config": { + "mediaType": "application/vnd.cncf.artifacthub.config.v1+yaml", + "digest": "%s", + "size": %d + }, + "layers": [ + { + "mediaType": "application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml", + "digest": "%s", + "size": %d + } + ] + }`, emptyConfigDigest, len(emptyConfigContent), artifactDigest, len(artifactData)) + + artifactManifestDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(artifactManifest))) + + // Upload artifact manifest + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/artifact-v1", url), bytes.NewReader([]byte(artifactManifest))). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json") + resp = MakeRequest(t, req, http.StatusCreated) + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/manifests/artifact-v1", user.Name, image), resp.Header().Get("Location")) + assert.Equal(t, artifactManifestDigest, resp.Header().Get("Docker-Content-Digest")) + + // Verify manifest can be retrieved + req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/artifact-v1", url)). + AddTokenAuth(userToken). + SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json") + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "application/vnd.oci.image.manifest.v1+json", resp.Header().Get("Content-Type")) + assert.Equal(t, artifactManifestDigest, resp.Header().Get("Docker-Content-Digest")) + + // Verify package was created with correct metadata + pvs, err := packages_model.GetVersionsByPackageType(db.DefaultContext, user.ID, packages_model.TypeContainer) + require.NoError(t, err) + + found := false + for _, pv := range pvs { + if pv.LowerVersion == "artifact-v1" { + found = true + break + } + } + assert.True(t, found, "ORAS artifact package should be created") + }) + for _, image := range images { t.Run(fmt.Sprintf("[Image:%s]", image), func(t *testing.T) { url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image) @@ -604,36 +688,76 @@ func TestPackageContainer(t *testing.T) { t.Run("GetTagList", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - cases := []struct { + var cases []struct { URL string ExpectedTags []string ExpectedLink string - }{ - { - URL: fmt.Sprintf("%s/tags/list", url), - ExpectedTags: []string{"latest", "main", "multi"}, - ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), - }, - { - URL: fmt.Sprintf("%s/tags/list?n=0", url), - ExpectedTags: []string{}, - ExpectedLink: "", - }, - { - URL: fmt.Sprintf("%s/tags/list?n=2", url), - ExpectedTags: []string{"latest", "main"}, - ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), - }, - { - URL: fmt.Sprintf("%s/tags/list?last=main", url), - ExpectedTags: []string{"multi"}, - ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), - }, - { - URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url), - ExpectedTags: []string{"main"}, - ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), - }, + } + + if image == "oras-artifact" { + cases = []struct { + URL string + ExpectedTags []string + ExpectedLink string + }{ + { + URL: fmt.Sprintf("%s/tags/list", url), + ExpectedTags: []string{"artifact-v1", "latest", "main", "multi"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=0", url), + ExpectedTags: []string{}, + ExpectedLink: "", + }, + { + URL: fmt.Sprintf("%s/tags/list?n=2", url), + ExpectedTags: []string{"artifact-v1", "latest"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?last=main", url), + ExpectedTags: []string{"multi"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url), + ExpectedTags: []string{"main"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + } + } else { + cases = []struct { + URL string + ExpectedTags []string + ExpectedLink string + }{ + { + URL: fmt.Sprintf("%s/tags/list", url), + ExpectedTags: []string{"latest", "main", "multi"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=0", url), + ExpectedTags: []string{}, + ExpectedLink: "", + }, + { + URL: fmt.Sprintf("%s/tags/list?n=2", url), + ExpectedTags: []string{"latest", "main"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?last=main", url), + ExpectedTags: []string{"multi"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url), + ExpectedTags: []string{"main"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + } } for _, c := range cases { @@ -660,7 +784,11 @@ func TestPackageContainer(t *testing.T) { var apiPackages []*api.Package DecodeJSON(t, resp, &apiPackages) - assert.Len(t, apiPackages, 4) // "latest", "main", "multi", "sha256:..." + if image == "oras-artifact" { + assert.Len(t, apiPackages, 5) // "artifact-v1", "latest", "main", "multi", "sha256:..." + } else { + assert.Len(t, apiPackages, 4) // "latest", "main", "multi", "sha256:..." + } }) t.Run("Delete", func(t *testing.T) { From e70f48bd444a70c75b348f420c17b45be3d6c02b Mon Sep 17 00:00:00 2001 From: Julian Schlarb Date: Mon, 9 Jun 2025 10:37:31 +0200 Subject: [PATCH 5/7] fix: always render detailed team permissions table in sidebar (#8108) Remove the generic write/admin description block for AccessMode 2/3 and unconditionally display the unit-level permissions table. fixes #3517 ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [X] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [X] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [X] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8108 Reviewed-by: Otto Co-authored-by: Julian Schlarb Co-committed-by: Julian Schlarb --- models/fixtures/team.yml | 12 +++ models/fixtures/team_unit.yml | 7 ++ models/fixtures/user.yml | 2 +- options/locale/locale_en-US.ini | 2 - templates/org/team/sidebar.tmpl | 7 +- tests/e2e/org-teams-overview.test.e2e.ts | 116 +++++++++++++++++++++++ tests/e2e/utils_e2e_test.go | 2 + 7 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/org-teams-overview.test.e2e.ts diff --git a/models/fixtures/team.yml b/models/fixtures/team.yml index 149fe90888..a863f1203a 100644 --- a/models/fixtures/team.yml +++ b/models/fixtures/team.yml @@ -239,3 +239,15 @@ num_members: 2 includes_all_repositories: false can_create_org_repo: false + +- + id: 25 + org_id: 17 + lower_name: super-user + name: super-user + description: "" + authorize: 3 + num_repos: 0 + num_members: 0 + includes_all_repositories: 0 + can_create_org_repo: 0 diff --git a/models/fixtures/team_unit.yml b/models/fixtures/team_unit.yml index e8f8d0e422..4d282a7eb5 100644 --- a/models/fixtures/team_unit.yml +++ b/models/fixtures/team_unit.yml @@ -329,3 +329,10 @@ team_id: 22 type: 3 access_mode: 1 + +- + id: 84 + org_id: 17 + team_id: 25 + type: 3 + access_mode: 3 diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 1a03185cf1..00aa182540 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -642,7 +642,7 @@ num_following: 0 num_stars: 0 num_repos: 2 - num_teams: 3 + num_teams: 4 num_members: 4 visibility: 0 repo_admin_change_team_access: false diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index e43276a122..7479ab80af 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2985,8 +2985,6 @@ teams.invite_team_member.list = Pending invitations teams.delete_team_title = Delete team teams.delete_team_desc = Deleting a team revokes repository access from its members. Continue? teams.delete_team_success = The team has been deleted. -teams.read_permission_desc = This team grants Read access: members can view and clone team repositories. -teams.write_permission_desc = This team grants Write access: members can read from and push to team repositories. teams.admin_permission_desc = This team grants Administrator access: members can read from, push to and add collaborators to team repositories. teams.create_repo_permission_desc = Additionally, this team grants Create repository permission: members can create new repositories in organization. teams.repositories = Team repositories diff --git a/templates/org/team/sidebar.tmpl b/templates/org/team/sidebar.tmpl index c9f80259e2..faf0c336a0 100644 --- a/templates/org/team/sidebar.tmpl +++ b/templates/org/team/sidebar.tmpl @@ -42,11 +42,8 @@
  • {{ctx.Locale.Tr "org.teams.can_create_org_repo"}}
  • {{end}} - {{if (eq .Team.AccessMode 2)}} -

    {{ctx.Locale.Tr "org.settings.permission"}}

    - {{ctx.Locale.Tr "org.teams.write_permission_desc"}} - {{else if (eq .Team.AccessMode 3)}} -

    {{ctx.Locale.Tr "org.settings.permission"}}

    +

    {{ctx.Locale.Tr "org.settings.permission"}}

    + {{if (eq .Team.AccessMode 3)}} {{ctx.Locale.Tr "org.teams.admin_permission_desc"}} {{else}} diff --git a/tests/e2e/org-teams-overview.test.e2e.ts b/tests/e2e/org-teams-overview.test.e2e.ts new file mode 100644 index 0000000000..d968b1e6df --- /dev/null +++ b/tests/e2e/org-teams-overview.test.e2e.ts @@ -0,0 +1,116 @@ +// @watch start +// templates/org/team/sidebar.tmpl +// @watch end +/* eslint playwright/expect-expect: ["error", { "assertFunctionNames": ["assertPermissionsDetails", "assertRestrictedAccess", "assertOwnerPermissions"] }] */ +import {expect, type Page} from '@playwright/test'; +import {test} from './utils_e2e.ts'; + +type Permission = 'No access' | 'Write' | 'Read'; + +const UNIT_VALUES = [ + 'Code', + 'Issues', + 'Pull requests', + 'Releases', + 'Wiki', + 'External Wiki', + 'External issues', + 'Projects', + 'Packages', + 'Actions', +] as const; + +type Unit = typeof UNIT_VALUES[number]; + +const assertPermission = async (page: Page, name: Unit, permission: Permission) => { + await expect.soft(page.getByRole('row', {name}).getByRole('cell').nth(1)).toHaveText(permission); +}; + +const testTeamUrl = '/org/org17/teams/test_team'; +const reviewTeamUrl = '/org/org17/teams/review_team'; +const ownersUrl = '/org/org17/teams/owners'; +const adminUrl = '/org/org17/teams/super-user'; + +const cases: Record = { + [testTeamUrl]: {write: ['Issues']}, + [reviewTeamUrl]: {read: ['Code']}, +}; + +const assertOwnerPermissions = async (page: Page, code: number = 200) => { + const response = await page.goto(ownersUrl); + expect(response?.status()).toBe(code); + + await expect(page.getByText('Owners have full access to all repositories and have administrator access to the organization.')).toBeVisible(); +}; + +const assertAdminPermissions = async (page: Page, code: number = 200) => { + const response = await page.goto(adminUrl); + expect(response?.status()).toBe(code); + + await expect(page.getByText('This team grants Administrator access: members can read from, push to and add collaborators to team repositories.')).toBeVisible(); +}; + +const assertRestrictedAccess = async (page: Page, ...urls: string[]) => { + for (const url of urls) { + expect((await page.goto(url))?.status(), 'should not see any details').toBe(404); + } +}; + +const assertPermissionsDetails = async (page: Page, url: (keyof typeof cases)) => { + const response = await page.goto(url); + expect(response?.status()).toBe(200); + + const per = cases[url]; + + for (const unit of UNIT_VALUES) { + if (per.read?.includes(unit)) { + await assertPermission(page, unit, 'Read'); + } else if (per.write?.includes(unit)) { + await assertPermission(page, unit, 'Write'); + } else { + await assertPermission(page, unit, 'No access'); + } + } +}; + +test.describe('Orga team overview', () => { + test.describe('admin', () => { + test.use({user: 'user1'}); + + test('should see all', async ({page}) => { + await assertPermissionsDetails(page, testTeamUrl); + await assertPermissionsDetails(page, reviewTeamUrl); + await assertOwnerPermissions(page); + await assertAdminPermissions(page); + }); + }); + + test.describe('owner', () => { + test.use({user: 'user18'}); + + test('should see all', async ({page}) => { + await assertPermissionsDetails(page, testTeamUrl); + await assertPermissionsDetails(page, reviewTeamUrl); + await assertOwnerPermissions(page); + await assertAdminPermissions(page); + }); + }); + + test.describe('reviewer team', () => { + test.use({user: 'user29'}); + + test('should only see permissions for `reviewer team` and restricted access to other resources', async ({page}) => { + await assertPermissionsDetails(page, reviewTeamUrl); + await assertRestrictedAccess(page, ownersUrl, testTeamUrl, adminUrl); + }); + }); + + test.describe('test_team', () => { + test.use({user: 'user2'}); + + test('should only see permissions for test_team and restricted access to other resources', async ({page}) => { + await assertPermissionsDetails(page, testTeamUrl); + await assertRestrictedAccess(page, ownersUrl, reviewTeamUrl, adminUrl); + }); + }); +}); diff --git a/tests/e2e/utils_e2e_test.go b/tests/e2e/utils_e2e_test.go index f892d6c518..e121c604c3 100644 --- a/tests/e2e/utils_e2e_test.go +++ b/tests/e2e/utils_e2e_test.go @@ -94,6 +94,8 @@ func createSessions(t testing.TB) { "user1", "user2", "user12", + "user18", + "user29", "user40", } From 580efedad455b267e406d03c0c45556369020f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Malte=20J=C3=BCrgens?= Date: Mon, 9 Jun 2025 10:48:35 +0200 Subject: [PATCH 6/7] fix: remove download attribute from external assets (#8112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #6983. I suppose a change like this doesn't require any testing? ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] I do not want this change to show in the release notes. - [ ] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8112 Reviewed-by: Gusted Reviewed-by: Beowulf Co-authored-by: Malte Jürgens Co-committed-by: Malte Jürgens --- templates/repo/release/list.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index 54571f065a..b8f50b1f4d 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -98,7 +98,7 @@ {{range $release.Attachments}} {{if .ExternalURL}}
  • - + {{svg "octicon-link-external" 16 "tw-mr-1"}}{{.Name}}
  • From 81c960f0c2d9bf50dfe4631afcd13fed5e923f01 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 9 Jun 2025 10:50:43 +0200 Subject: [PATCH 7/7] Update renovate to v40.48.4 (forgejo) (#8117) Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .forgejo/workflows/renovate.yml | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/renovate.yml b/.forgejo/workflows/renovate.yml index cddda0fa09..b584f94cf8 100644 --- a/.forgejo/workflows/renovate.yml +++ b/.forgejo/workflows/renovate.yml @@ -28,7 +28,7 @@ jobs: runs-on: docker container: - image: data.forgejo.org/renovate/renovate:40.40.0 + image: data.forgejo.org/renovate/renovate:40.48.4 steps: - name: Load renovate repo cache diff --git a/Makefile b/Makefile index 759443456d..9774200b06 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 # renovate: datasour DEADCODE_PACKAGE ?= golang.org/x/tools/cmd/deadcode@v0.34.0 # renovate: datasource=go GOMOCK_PACKAGE ?= go.uber.org/mock/mockgen@v0.5.2 # renovate: datasource=go GOPLS_PACKAGE ?= golang.org/x/tools/gopls@v0.18.1 # renovate: datasource=go -RENOVATE_NPM_PACKAGE ?= renovate@40.40.0 # renovate: datasource=docker packageName=data.forgejo.org/renovate/renovate +RENOVATE_NPM_PACKAGE ?= renovate@40.48.4 # renovate: datasource=docker packageName=data.forgejo.org/renovate/renovate # https://github.com/disposable-email-domains/disposable-email-domains/commits/main/ DISPOSABLE_EMAILS_SHA ?= 0c27e671231d27cf66370034d7f6818037416989 # renovate: ...