From 9e0e9e45acaf5cb22d3fca3ec30983b48b9a13e4 Mon Sep 17 00:00:00 2001 From: Sumit Date: Thu, 28 May 2026 10:52:26 +0530 Subject: [PATCH 1/9] fix: support ##[command] log prefix in action run UI (#37882) The Actions log parser only recognized `[command]`, so runner command output emitted as `##[command] ...` was not shown in expanded step logs. Add `##[command]` support to `LogLinePrefixCommandMap` in `web_src/js/components/ActionRunView.ts` and cover it with a regression test in `web_src/js/components/ActionRunView.test.ts`. Changes - Fixes Actions UI log rendering for runner command output - Adds support for ##[command] in the Actions log parser - Ensures runner echo ... lines are rendered when expanding step logs - Includes a regression test covering ##[command] foo parsing Co-authored-by: Lunny Xiao --- web_src/js/components/ActionRunView.test.ts | 1 + web_src/js/components/ActionRunView.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/web_src/js/components/ActionRunView.test.ts b/web_src/js/components/ActionRunView.test.ts index 1f972b73c0..b625b15e71 100644 --- a/web_src/js/components/ActionRunView.test.ts +++ b/web_src/js/components/ActionRunView.test.ts @@ -16,6 +16,7 @@ test('LogLineMessage', () => { '::warning file=test.js,line=1::foo': 'Warning: foo', '::notice::foo': 'Notice: foo', '::debug::foo': 'Debug: foo', + '##[command] foo': ' foo', '[command] foo': ' foo', // hidden is special, it is actually skipped before creating diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts index 91fe8329bd..a9a6695241 100644 --- a/web_src/js/components/ActionRunView.ts +++ b/web_src/js/components/ActionRunView.ts @@ -20,6 +20,7 @@ const LogLinePrefixCommandMap: Record = { '##[warning]': 'warning', '##[notice]': 'notice', '##[debug]': 'debug', + '##[command]': 'command', '[command]': 'command', // https://github.com/actions/toolkit/blob/master/docs/commands.md From db04bcb31a3cbab9a7893e6c10d18eece59332b6 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 28 May 2026 07:51:45 +0200 Subject: [PATCH 2/9] enhance(actions): set descriptive browser tab title on run view (#37870) --- routers/web/repo/actions/view.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 0ed3849621..d95b8679b8 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" actions_model "gitea.dev/models/actions" @@ -206,6 +207,16 @@ func View(ctx *context_module.Context) { jobID := ctx.PathParamInt64("job") ctx.Data["JobID"] = jobID // it can be 0 when no job (e.g.: run summary view) + // Browser tab title, ordered most-specific → least-specific so narrow tabs keep the useful part. + // Separator matches the " - " used by head.tmpl when joining to PageTitleCommon. + titleParts := []string{run.Title, run.WorkflowID} + if jobID > 0 { + if job, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID); err == nil && job.Name != "" { + titleParts = append([]string{job.Name}, titleParts...) + } + } + ctx.Data["Title"] = strings.Join(titleParts, " - ") + attemptNum := ctx.PathParamInt64("attempt") // ActionsViewURL is the endpoint for viewing a run (job summary), a job, or a job attempt. From 52fef742918671cb53fbcb6ced7c1ca2043fbccb Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 28 May 2026 08:14:52 +0200 Subject: [PATCH 3/9] fix(frontend): resolve Vite assets by manifest source path (#37836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In dev mode `/api/swagger` returned HTTP 500 (`Failed to locate local path for managed asset URI: css/swagger.css`): the backend synthesised asset keys from the Vite entry name instead of reading the manifest, which only worked by coincidence and broke once a source file name diverged from its entry name. This keys the manifest by its source path (e.g. `web_src/js/index.ts`) and resolves entries directly — hashed `file` in prod, dev-server source in dev. A new `AssetCSSLinks` helper renders a JS entry's stylesheet `` tags from the manifest (the entry's CSS plus the CSS of its statically-imported chunks). Fixes: https://github.com/go-gitea/gitea/issues/37830 Fixes: https://github.com/go-gitea/gitea/pull/37832 Fixes: https://github.com/go-gitea/gitea/pull/37876 Signed-off-by: silverwind Co-authored-by: prakhar0x01 Co-authored-by: Nicolas Co-authored-by: Claude (Opus 4.7) Co-authored-by: Giteabot --- modules/markup/external/frontend.go | 2 +- modules/markup/render.go | 2 +- modules/public/manifest.go | 120 +++++++++++++--------- modules/public/manifest_test.go | 64 ++++++------ modules/public/vitedev.go | 30 ++---- modules/templates/helper.go | 3 +- services/webtheme/webtheme.go | 2 +- templates/base/footer.tmpl | 2 +- templates/base/head_script.tmpl | 4 +- templates/base/head_style.tmpl | 2 +- templates/devtest/devtest-header.tmpl | 2 +- templates/swagger/openapi-viewer.tmpl | 6 +- tests/integration/markup_external_test.go | 8 +- vite.config.ts | 1 - web_src/js/swagger.ts | 2 - 15 files changed, 122 insertions(+), 128 deletions(-) diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go index 26e83ec99a..34fa2715f7 100644 --- a/modules/markup/external/frontend.go +++ b/modules/markup/external/frontend.go @@ -90,6 +90,6 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou `, p.name, ctx.RenderOptions.RelativePath, contentEncoding, contentString, - public.AssetURI("js/external-render-frontend.js")) + public.AssetURI("web_src/js/external-render-frontend.ts")) return err } diff --git a/modules/markup/render.go b/modules/markup/render.go index 44bdcd0791..6f43434485 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -243,7 +243,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, return RenderIFrame(ctx, &extOpts, output) } // else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS - extraScriptSrc := public.AssetURI("js/external-render-helper.js") + extraScriptSrc := public.AssetURI("web_src/js/external-render-helper.ts") extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI() // " -{{ctx.ScriptImport "js/iife.js"}} +{{ctx.ScriptImport "web_src/js/iife.ts"}} diff --git a/templates/base/head_style.tmpl b/templates/base/head_style.tmpl index 4a4fb9d96f..dba782f9bb 100644 --- a/templates/base/head_style.tmpl +++ b/templates/base/head_style.tmpl @@ -1,2 +1,2 @@ - +{{AssetCSSLinks "web_src/js/index.ts" "web_src/css/index.css"}} diff --git a/templates/devtest/devtest-header.tmpl b/templates/devtest/devtest-header.tmpl index c9d7b3047f..ad153364e0 100644 --- a/templates/devtest/devtest-header.tmpl +++ b/templates/devtest/devtest-header.tmpl @@ -1,4 +1,4 @@ {{template "base/head" ctx.RootData}} - +
{{template "base/alert" ctx.RootData}}
diff --git a/templates/swagger/openapi-viewer.tmpl b/templates/swagger/openapi-viewer.tmpl index 792364157d..2e3e9bf149 100644 --- a/templates/swagger/openapi-viewer.tmpl +++ b/templates/swagger/openapi-viewer.tmpl @@ -4,14 +4,14 @@ {{ctx.HeadMetaContentSecurityPolicy}} Gitea API - {{/* HINT: SWAGGER-CSS-IMPORT: import swagger styles ahead to avoid UI flicker (e.g.: the swagger-back-link element) */}} - + {{/* HINT: SWAGGER-CSS-IMPORT: load swagger styles ahead to avoid flicker (e.g. the swagger-back-link) */}} + {{AssetCSSLinks "web_src/js/swagger.ts" "web_src/css/swagger-standalone.css"}} {{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}} {{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}
- {{ctx.ScriptImport "js/swagger.js" "module"}} + {{ctx.ScriptImport "web_src/js/swagger.ts" "module"}} diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index 458fa23948..9217fd7e5a 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -109,8 +109,8 @@ func TestExternalMarkupRenderer(t *testing.T) { assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy")) // FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "`+ - ``+ + ``+ + ``+ `
<script></script>
`, respSub.Body.String(), ) @@ -137,8 +137,8 @@ func TestExternalMarkupRenderer(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer?a=1%2f2") respSub := MakeRequest(t, req, http.StatusOK) assert.Equal(t, - ``+ - ``+ + ``+ + ``+ ``, respSub.Body.String(), ) diff --git a/vite.config.ts b/vite.config.ts index 53717813a1..e077fed6ae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -267,7 +267,6 @@ export default defineConfig(commonViteOpts({ manifest: true, rolldownOptions: { input: { - // FIXME: INCORRECT-VITE-MANIFEST-PARSER: the "css importing" logic in backend is wrong index: join(import.meta.dirname, 'web_src/js/index.ts'), swagger: join(import.meta.dirname, 'web_src/js/swagger.ts'), 'external-render-frontend': join(import.meta.dirname, 'web_src/js/external-render-frontend.ts'), diff --git a/web_src/js/swagger.ts b/web_src/js/swagger.ts index ee0fd28936..c73002a6f1 100644 --- a/web_src/js/swagger.ts +++ b/web_src/js/swagger.ts @@ -1,5 +1,3 @@ -// FIXME: INCORRECT-VITE-MANIFEST-PARSER: it just happens to work for current dependencies -// If this module depends on another one and that one imports "swagger.css", then {{AssetURI "css/swagger.css"}} won't work import '../css/swagger-standalone.css'; import {initSwaggerUI} from './render/swagger.ts'; From 49f88a4b9eec7db7ed94e103dda152e56d9d9554 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Thu, 28 May 2026 11:29:32 -0600 Subject: [PATCH 4/9] feat(repo): split repository creation limit into user and org scopes (#37872) ## Background `MAX_CREATION_LIMIT` applies to whoever owns a new repository, with no distinction between individual users and organizations. Admins who want different limits for the two - most commonly "block personal repos but let orgs create freely" - currently have to set per-user / per-org overrides on every entity. ## Changes Adds two new `[repository]` settings: - `USER_MAX_CREATION_LIMIT`: global limit for individual users - `ORG_MAX_CREATION_LIMIT`: global limit for organizations `MAX_CREATION_LIMIT` is kept as a shortcut: when set, it becomes the default value for both new keys. When the new keys are explicitly configured, they take precedence. Deployments that only set `MAX_CREATION_LIMIT` see behavior identical to now. Co-authored-by: Lunny Xiao --- custom/conf/app.example.ini | 11 ++++- models/user/user.go | 21 ++++----- models/user/user_test.go | 40 ++++++++++++++++- modules/setting/repository.go | 8 ++++ modules/setting/repository_test.go | 66 ++++++++++++++++++++++++++++ services/repository/transfer.go | 7 +-- services/repository/transfer_test.go | 2 + 7 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 modules/setting/repository_test.go diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 7619f46529..2793dd1ca0 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1024,9 +1024,18 @@ LEVEL = Info ;; Default private when using push-to-create ;DEFAULT_PUSH_CREATE_PRIVATE = true ;; -;; Global limit of repositories per user, applied at creation time. -1 means no limit +;; Global limit of repositories per user or org, applied at creation time. -1 means no limit +;; To configure independent limits for users and orgs, use USER_MAX_CREATION_LIMIT and ORG_MAX_CREATION_LIMIT ;MAX_CREATION_LIMIT = -1 ;; +;; Global limit of repositories per user, applied at creation time. -1 means no limit +;; Takes precedence over MAX_CREATION_LIMIT when set +;USER_MAX_CREATION_LIMIT = -1 +;; +;; Global limit of repositories per organization, applied at creation time. -1 means no limit +;; Takes precedence over MAX_CREATION_LIMIT when set +;ORG_MAX_CREATION_LIMIT = -1 +;; ;; Preferred Licenses to place at the top of the List ;; The name here must match the filename in options/license or custom/options/license ;PREFERRED_LICENSES = Apache License 2.0,MIT License diff --git a/models/user/user.go b/models/user/user.go index 5c7a7148e5..66e8d49b42 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -244,12 +244,15 @@ func (u *User) IsOAuth2() bool { return u.LoginType == auth.OAuth2 } -// MaxCreationLimit returns the number of repositories a user is allowed to create +// MaxCreationLimit returns the number of repositories a user or an organization is allowed to create func (u *User) MaxCreationLimit() int { - if u.MaxRepoCreation <= -1 { - return setting.Repository.MaxCreationLimit + if u.MaxRepoCreation > -1 { + return u.MaxRepoCreation } - return u.MaxRepoCreation + if u.IsOrganization() { + return setting.Repository.OrgMaxCreationLimit + } + return setting.Repository.UserMaxCreationLimit } // CanCreateRepoIn checks whether the doer(u) can create a repository in the owner @@ -264,13 +267,11 @@ func (u *User) CanCreateRepoIn(owner *User) bool { return true } const noLimit = -1 - if owner.MaxRepoCreation == noLimit { - if setting.Repository.MaxCreationLimit == noLimit { - return true - } - return owner.NumRepos < setting.Repository.MaxCreationLimit + limit := owner.MaxCreationLimit() + if limit == noLimit { + return true } - return owner.NumRepos < owner.MaxRepoCreation + return owner.NumRepos < limit } // CanCreateOrganization returns true if user can create organisation. diff --git a/models/user/user_test.go b/models/user/user_test.go index 47eb00881f..2bf32a5038 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -674,12 +674,18 @@ func TestGetInactiveUsers(t *testing.T) { func TestCanCreateRepo(t *testing.T) { defer test.MockVariableValue(&setting.Repository.MaxCreationLimit)() + defer test.MockVariableValue(&setting.Repository.UserMaxCreationLimit)() + defer test.MockVariableValue(&setting.Repository.OrgMaxCreationLimit)() const noLimit = -1 doerActions := user_model.NewActionsUser() doerNormal := &user_model.User{ID: 2} doerAdmin := &user_model.User{ID: 1, IsAdmin: true} + orgOwner := func(numRepos, maxRepoCreation int) *user_model.User { + return &user_model.User{ID: 3, Type: user_model.UserTypeOrganization, NumRepos: numRepos, MaxRepoCreation: maxRepoCreation} + } t.Run("NoGlobalLimit", func(t *testing.T) { - setting.Repository.MaxCreationLimit = noLimit + setting.Repository.UserMaxCreationLimit = noLimit + setting.Repository.OrgMaxCreationLimit = noLimit assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) @@ -693,7 +699,8 @@ func TestCanCreateRepo(t *testing.T) { }) t.Run("GlobalLimit50", func(t *testing.T) { - setting.Repository.MaxCreationLimit = 50 + setting.Repository.UserMaxCreationLimit = 50 + setting.Repository.OrgMaxCreationLimit = 50 assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: noLimit})) // limited by global limit @@ -707,4 +714,33 @@ func TestCanCreateRepo(t *testing.T) { assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: 100})) }) + + t.Run("UserBlockedOrgsUnlimited", func(t *testing.T) { + // User and org limits are independent: a deployment can block personal repos while leaving orgs unrestricted. + setting.Repository.UserMaxCreationLimit = 0 + setting.Repository.OrgMaxCreationLimit = noLimit + + // regular user is blocked + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 0, MaxRepoCreation: noLimit})) + // per-user override grants individual exceptions even when the global user limit is 0 + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 3, MaxRepoCreation: 5})) + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 5, MaxRepoCreation: 5})) + + // organization can create unlimited repos + assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(10, noLimit))) + assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(999, noLimit))) + // per-org override still wins over the global org limit + assert.False(t, doerNormal.CanCreateRepoIn(orgOwner(5, 5))) + }) + + t.Run("OrgGlobalLimitWithPerOrgOverride", func(t *testing.T) { + setting.Repository.UserMaxCreationLimit = noLimit + setting.Repository.OrgMaxCreationLimit = 10 + + assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(5, noLimit))) + assert.False(t, doerNormal.CanCreateRepoIn(orgOwner(10, noLimit))) + + // per-org override bypasses the global org limit + assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(10, 100))) + }) } diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 94da96a233..d1000c5280 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -37,6 +37,8 @@ var ( DefaultPrivate string DefaultPushCreatePrivate bool MaxCreationLimit int + UserMaxCreationLimit int + OrgMaxCreationLimit int PreferredLicenses []string DisableHTTPGit bool AccessControlAllowOrigin string @@ -165,6 +167,8 @@ var ( DefaultPrivate: RepoCreatingLastUserVisibility, DefaultPushCreatePrivate: true, MaxCreationLimit: -1, + UserMaxCreationLimit: -1, + OrgMaxCreationLimit: -1, PreferredLicenses: []string{"Apache License 2.0", "MIT License"}, DisableHTTPGit: false, AccessControlAllowOrigin: "", @@ -297,7 +301,11 @@ func loadRepositoryFrom(rootCfg ConfigProvider) { Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool() Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool() Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").MustString("https") + // MAX_CREATION_LIMIT is a shortcut that sets the default for the two per-type limits below. + // USER_/ORG_MAX_CREATION_LIMIT take precedence when explicitly set. Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1) + Repository.UserMaxCreationLimit = sec.Key("USER_MAX_CREATION_LIMIT").MustInt(Repository.MaxCreationLimit) + Repository.OrgMaxCreationLimit = sec.Key("ORG_MAX_CREATION_LIMIT").MustInt(Repository.MaxCreationLimit) Repository.DefaultBranch = sec.Key("DEFAULT_BRANCH").MustString(Repository.DefaultBranch) RepoRootPath = sec.Key("ROOT").MustString(filepath.Join(AppDataPath, "gitea-repositories")) if !filepath.IsAbs(RepoRootPath) { diff --git a/modules/setting/repository_test.go b/modules/setting/repository_test.go new file mode 100644 index 0000000000..bd2779019c --- /dev/null +++ b/modules/setting/repository_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestLoadRepositoryCreationLimits(t *testing.T) { + defer test.MockVariableValue(&Repository.MaxCreationLimit)() + defer test.MockVariableValue(&Repository.UserMaxCreationLimit)() + defer test.MockVariableValue(&Repository.OrgMaxCreationLimit)() + + t.Run("ShortcutPropagatesToBoth", func(t *testing.T) { + cfg, err := NewConfigProviderFromData(` +[repository] +MAX_CREATION_LIMIT = 5 +`) + assert.NoError(t, err) + loadRepositoryFrom(cfg) + assert.Equal(t, 5, Repository.MaxCreationLimit) + assert.Equal(t, 5, Repository.UserMaxCreationLimit) + assert.Equal(t, 5, Repository.OrgMaxCreationLimit) + }) + + t.Run("PerTypeKeysOverrideShortcut", func(t *testing.T) { + cfg, err := NewConfigProviderFromData(` +[repository] +MAX_CREATION_LIMIT = 5 +USER_MAX_CREATION_LIMIT = 0 +ORG_MAX_CREATION_LIMIT = -1 +`) + assert.NoError(t, err) + loadRepositoryFrom(cfg) + assert.Equal(t, 0, Repository.UserMaxCreationLimit) + assert.Equal(t, -1, Repository.OrgMaxCreationLimit) + }) + + t.Run("PartialOverrideOtherInheritsShortcut", func(t *testing.T) { + cfg, err := NewConfigProviderFromData(` +[repository] +MAX_CREATION_LIMIT = 7 +ORG_MAX_CREATION_LIMIT = -1 +`) + assert.NoError(t, err) + loadRepositoryFrom(cfg) + assert.Equal(t, 7, Repository.UserMaxCreationLimit) + assert.Equal(t, -1, Repository.OrgMaxCreationLimit) + }) + + t.Run("NoKeyDefaultsToNoLimit", func(t *testing.T) { + cfg, err := NewConfigProviderFromData(` +[repository] +`) + assert.NoError(t, err) + loadRepositoryFrom(cfg) + assert.Equal(t, -1, Repository.MaxCreationLimit) + assert.Equal(t, -1, Repository.UserMaxCreationLimit) + assert.Equal(t, -1, Repository.OrgMaxCreationLimit) + }) +} diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 5d4c4c9291..76051ac4f7 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -20,7 +20,6 @@ import ( "gitea.dev/modules/gitrepo" "gitea.dev/modules/globallock" "gitea.dev/modules/log" - "gitea.dev/modules/setting" "gitea.dev/modules/util" notify_service "gitea.dev/services/notify" ) @@ -62,8 +61,7 @@ func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, d } if !doer.CanCreateRepoIn(repoTransfer.Recipient) { - limit := util.Iif(repoTransfer.Recipient.MaxRepoCreation >= 0, repoTransfer.Recipient.MaxRepoCreation, setting.Repository.MaxCreationLimit) - return LimitReachedError{Limit: limit} + return LimitReachedError{Limit: repoTransfer.Recipient.MaxCreationLimit()} } if !repoTransfer.CanUserAcceptOrRejectTransfer(ctx, doer) { @@ -434,8 +432,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use } if !doer.CanForkRepoIn(newOwner) { - limit := util.Iif(newOwner.MaxRepoCreation >= 0, newOwner.MaxRepoCreation, setting.Repository.MaxCreationLimit) - return LimitReachedError{Limit: limit} + return LimitReachedError{Limit: newOwner.MaxCreationLimit()} } var isDirectTransfer bool diff --git a/services/repository/transfer_test.go b/services/repository/transfer_test.go index 99d2af109f..85282a1135 100644 --- a/services/repository/transfer_test.go +++ b/services/repository/transfer_test.go @@ -133,6 +133,8 @@ func TestRepositoryTransferRejection(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) // Set limit to 0 repositories so no repositories can be transferred defer test.MockVariableValue(&setting.Repository.MaxCreationLimit, 0)() + defer test.MockVariableValue(&setting.Repository.UserMaxCreationLimit, 0)() + defer test.MockVariableValue(&setting.Repository.OrgMaxCreationLimit, 0)() // Admin case doerAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) From 428ee9fcce7928bf5405900345d43e9ba1b01564 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 28 May 2026 10:53:38 -0700 Subject: [PATCH 5/9] fix(testing): Fix random failure test (#37887) Fix the flaky npm package web view test that compared rendered HTML as a raw string. Fix https://github.com/go-gitea/gitea/actions/runs/26524574688/job/78124662707?pr=36564 --------- Co-authored-by: wxiaoguang Co-authored-by: Nicolas --- tests/integration/api_packages_npm_test.go | 2 +- tests/integration/html_helper.go | 39 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/integration/api_packages_npm_test.go b/tests/integration/api_packages_npm_test.go index 54d972d47d..1159b64da8 100644 --- a/tests/integration/api_packages_npm_test.go +++ b/tests/integration/api_packages_npm_test.go @@ -304,7 +304,7 @@ func TestPackageNpm(t *testing.T) { resp := MakeRequest(t, req, http.StatusOK) doc := NewHTMLParser(t, resp.Body) rendered, _ := doc.Find(".markup.markdown").Html() - assert.Equal(t, `

docs + assertHTMLEq(t, `

docs logo

`, rendered) }) diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go index c2045453e6..cefe5592c4 100644 --- a/tests/integration/html_helper.go +++ b/tests/integration/html_helper.go @@ -5,10 +5,13 @@ package integration import ( "io" + "slices" + "strings" "testing" "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" + "golang.org/x/net/html" ) // HTMLDoc struct @@ -47,3 +50,39 @@ func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string assert.Equal(t, v, sel.Length()) } } + +func assertHTMLEq(t testing.TB, expected, actual string) { + t.Helper() + if expected == actual { + return + } + exp, err := html.Parse(strings.NewReader(expected)) + if !assert.NoError(t, err) { + return + } + act, err := html.Parse(strings.NewReader(actual)) + if !assert.NoError(t, err) { + return + } + var normalize func(n *html.Node) + normalize = func(n *html.Node) { + slices.SortFunc(n.Attr, func(a, b html.Attribute) int { + if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 { + return cmp + } + if cmp := strings.Compare(a.Key, b.Key); cmp != 0 { + return cmp + } + return strings.Compare(a.Val, b.Val) + }) + for c := n.FirstChild; c != nil; c = c.NextSibling { + normalize(c) + } + } + normalize(exp) + normalize(act) + var expNormalized, actNormalized strings.Builder + assert.NoError(t, html.Render(&expNormalized, exp)) + assert.NoError(t, html.Render(&actNormalized, act)) + assert.Equal(t, expNormalized.String(), actNormalized.String()) +} From 90d443b46c6163fd15608bbbcb14593cc95d4546 Mon Sep 17 00:00:00 2001 From: Jorge Ortiz Date: Thu, 28 May 2026 16:40:43 -0700 Subject: [PATCH 6/9] fix(actions): reject workflow_dispatch for workflows without that trigger (#37660) ## Summary Fixes #37528 This PR makes the workflow dispatch API reject workflows that do not declare `workflow_dispatch`. Previously, `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches` could create an `ActionRun` for a workflow that only declared another event such as `push`. The service now validates that the target workflow has a `workflow_dispatch` trigger before inserting the run. The API maps that validation failure to `422 Unprocessable Entity`, matching existing validation failures in this handler. The regression test creates a push-only workflow, dispatches it through the public API, asserts the `workflow_dispatch` validation message, and verifies that no run was inserted. ## Disclosure Developed with assistance from OpenAI Codex. --------- Co-authored-by: Nicolas --- routers/api/v1/repo/action.go | 2 + services/actions/workflow.go | 14 +++-- tests/integration/actions_trigger_test.go | 70 +++++++++++++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index ca72b52a22..169ed4b68e 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1091,6 +1091,8 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { ctx.APIError(http.StatusNotFound, err) } else if errors.Is(err, util.ErrPermissionDenied) { ctx.APIError(http.StatusForbidden, err) + } else if errors.Is(err, util.ErrInvalidArgument) { + ctx.APIError(http.StatusUnprocessableEntity, err) } else { ctx.APIErrorInternal(err) } diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 45f15feb59..a748b0859c 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -140,11 +140,17 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re workflow := &model.Workflow{ RawOn: singleWorkflow.RawOn, } + workflowDispatch := workflow.WorkflowDispatchConfig() + if workflowDispatch == nil { + return 0, util.ErrorWrapTranslatable( + util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID), + "actions.workflow.has_no_workflow_dispatch", workflowID, + ) + } + inputsWithDefaults := make(map[string]any) - if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil { - if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil { - return 0, err - } + if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil { + return 0, err } // ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index 197abe54d0..cd87bc8b1a 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -931,6 +931,76 @@ jobs: }) } +func TestWorkflowDispatchPublicApiRequiresWorkflowDispatchTrigger(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + repo, err := repo_service.CreateRepository(t.Context(), user2, user2, repo_service.CreateRepoOptions{ + Name: "workflow-dispatch-requires-trigger", + Description: "test workflow dispatch requires workflow_dispatch", + AutoInit: true, + Gitignores: "Go", + License: "MIT", + Readme: "Default", + DefaultBranch: "main", + IsPrivate: false, + }) + require.NoError(t, err) + require.NotNil(t, repo) + + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(t.Context(), repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/push-only.yml", + ContentReader: strings.NewReader(` +on: + push: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + require.NotNil(t, addWorkflowToBaseResp) + + values := url.Values{} + values.Set("ref", "main") + req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/push-only.yml/dispatches", repo.FullName()), values). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusUnprocessableEntity) + apiError := DecodeJSON(t, resp, &api.APIError{}) + assert.Contains(t, apiError.Message, "has no workflow_dispatch event trigger") + + unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ + RepoID: repo.ID, + Event: "workflow_dispatch", + WorkflowID: "push-only.yml", + }) + }) +} + func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) From ea723fe482d60727d727a716a8cdea5f3a798db0 Mon Sep 17 00:00:00 2001 From: Pascal Zimmermann Date: Fri, 29 May 2026 03:12:11 +0200 Subject: [PATCH 7/9] enhance: Migrate remaining gopkg.in/yaml.v3 usages to go.yaml.in/yaml/v4 (#37866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description Replaces all remaining direct `gopkg.in/yaml.v3` imports with `go.yaml.in/yaml/v4` across models, modules, routers, services, and integration tests. `gopkg.in/yaml.v3` moves from a direct to an indirect dependency in `go.mod`. #### API compatibility The yaml.Node type, node.Kind/node.Content traversal style (modules/markup/markdown/convertyaml.go), and the UnmarshalYAML(*yaml.Node) interface signature (modules/optional/serialization.go) are all preserved in v4 — no call-site changes were required beyond the import path. **Related:** - https://github.com/go-gitea/gitea/pull/36564#issuecomment-4526536805 --------- Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.8) --- go.mod | 2 +- models/unittest/fixtures_loader.go | 2 +- modules/issue/template/unmarshal.go | 2 +- modules/label/parser.go | 2 +- modules/markup/markdown/convertyaml.go | 2 +- modules/markup/markdown/meta.go | 2 +- modules/markup/markdown/renderconfig.go | 2 +- modules/markup/markdown/renderconfig_test.go | 2 +- modules/migration/file_format.go | 2 +- modules/optional/serialization.go | 2 +- modules/optional/serialization_test.go | 2 +- modules/packages/helm/metadata.go | 2 +- modules/packages/pub/metadata.go | 2 +- modules/packages/rubygems/metadata.go | 2 +- modules/structs/issue.go | 4 ++-- modules/structs/issue_test.go | 4 ++-- routers/api/packages/helm/helm.go | 2 +- routers/web/devtest/mail_preview.go | 2 +- services/issue/template.go | 2 +- services/migrations/dump.go | 2 +- services/migrations/restore.go | 2 +- tests/integration/api_issue_config_test.go | 2 +- tests/integration/api_packages_helm_test.go | 2 +- tests/integration/dump_restore_test.go | 2 +- 24 files changed, 26 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index 7b88ba01f6..0b8d4f977a 100644 --- a/go.mod +++ b/go.mod @@ -113,7 +113,6 @@ require ( google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 gopkg.in/ini.v1 v1.67.2 - gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.1 mvdan.cc/xurls/v2 v2.6.0 strk.kbt.io/projects/go/libravatar v0.0.0-20260301104140-add494e31dab @@ -282,6 +281,7 @@ require ( golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401020348-3a24fdc17823 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/models/unittest/fixtures_loader.go b/models/unittest/fixtures_loader.go index 8f4b9691bb..7ad3bc9058 100644 --- a/models/unittest/fixtures_loader.go +++ b/models/unittest/fixtures_loader.go @@ -16,7 +16,7 @@ import ( "gitea.dev/models/db" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" "xorm.io/xorm" "xorm.io/xorm/schemas" ) diff --git a/modules/issue/template/unmarshal.go b/modules/issue/template/unmarshal.go index e313360b1b..e0d6881cf5 100644 --- a/modules/issue/template/unmarshal.go +++ b/modules/issue/template/unmarshal.go @@ -14,7 +14,7 @@ import ( api "gitea.dev/modules/structs" "gitea.dev/modules/util" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) // CouldBe indicates a file with the filename could be a template, diff --git a/modules/label/parser.go b/modules/label/parser.go index f3c59f45f3..f56ef0e255 100644 --- a/modules/label/parser.go +++ b/modules/label/parser.go @@ -10,7 +10,7 @@ import ( "gitea.dev/modules/options" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) type labelFile struct { diff --git a/modules/markup/markdown/convertyaml.go b/modules/markup/markdown/convertyaml.go index 61dfbf20df..0c69901929 100644 --- a/modules/markup/markdown/convertyaml.go +++ b/modules/markup/markdown/convertyaml.go @@ -11,7 +11,7 @@ import ( "github.com/yuin/goldmark/ast" east "github.com/yuin/goldmark/extension/ast" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func nodeToTable(meta *yaml.Node) ast.Node { diff --git a/modules/markup/markdown/meta.go b/modules/markup/markdown/meta.go index 6ddd892110..ecc0b81098 100644 --- a/modules/markup/markdown/meta.go +++ b/modules/markup/markdown/meta.go @@ -9,7 +9,7 @@ import ( "unicode" "unicode/utf8" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func isYAMLSeparator(line []byte) bool { diff --git a/modules/markup/markdown/renderconfig.go b/modules/markup/markdown/renderconfig.go index 36a7b1a9a1..59f3e873ec 100644 --- a/modules/markup/markdown/renderconfig.go +++ b/modules/markup/markdown/renderconfig.go @@ -10,7 +10,7 @@ import ( "gitea.dev/modules/markup" "github.com/yuin/goldmark/ast" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) // RenderConfig represents rendering configuration for this file diff --git a/modules/markup/markdown/renderconfig_test.go b/modules/markup/markdown/renderconfig_test.go index 53c52177a7..84e4ac33f6 100644 --- a/modules/markup/markdown/renderconfig_test.go +++ b/modules/markup/markdown/renderconfig_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func TestRenderConfig_UnmarshalYAML(t *testing.T) { diff --git a/modules/migration/file_format.go b/modules/migration/file_format.go index ca758611bc..e925494cd2 100644 --- a/modules/migration/file_format.go +++ b/modules/migration/file_format.go @@ -13,7 +13,7 @@ import ( "gitea.dev/modules/log" "github.com/santhosh-tekuri/jsonschema/v6" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) // schemaLoader implements jsonschema.URLLoader diff --git a/modules/optional/serialization.go b/modules/optional/serialization.go index 9c6561628c..a3db68e76d 100644 --- a/modules/optional/serialization.go +++ b/modules/optional/serialization.go @@ -6,7 +6,7 @@ package optional import ( "gitea.dev/modules/json" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func (o *Option[T]) UnmarshalJSON(data []byte) error { diff --git a/modules/optional/serialization_test.go b/modules/optional/serialization_test.go index 8f7d22c206..966e284e8b 100644 --- a/modules/optional/serialization_test.go +++ b/modules/optional/serialization_test.go @@ -11,7 +11,7 @@ import ( "gitea.dev/modules/optional" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) type testSerializationStruct struct { diff --git a/modules/packages/helm/metadata.go b/modules/packages/helm/metadata.go index 628f477749..723c3583ff 100644 --- a/modules/packages/helm/metadata.go +++ b/modules/packages/helm/metadata.go @@ -13,7 +13,7 @@ import ( "gitea.dev/modules/validation" "github.com/hashicorp/go-version" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) var ( diff --git a/modules/packages/pub/metadata.go b/modules/packages/pub/metadata.go index 7b78b39f47..7291836e1f 100644 --- a/modules/packages/pub/metadata.go +++ b/modules/packages/pub/metadata.go @@ -14,7 +14,7 @@ import ( "gitea.dev/modules/validation" "github.com/hashicorp/go-version" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) var ( diff --git a/modules/packages/rubygems/metadata.go b/modules/packages/rubygems/metadata.go index ed9de3562a..8a989bbe7f 100644 --- a/modules/packages/rubygems/metadata.go +++ b/modules/packages/rubygems/metadata.go @@ -14,7 +14,7 @@ import ( "gitea.dev/modules/util" "gitea.dev/modules/validation" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) var ( diff --git a/modules/structs/issue.go b/modules/structs/issue.go index f108cf3d0a..68618a3191 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) // StateType issue state type @@ -231,7 +231,7 @@ func (l *IssueTemplateStringSlice) UnmarshalYAML(value *yaml.Node) error { *l = labels return nil } - return fmt.Errorf("line %d: cannot unmarshal %s into IssueTemplateStringSlice", value.Line, value.ShortTag()) + return fmt.Errorf("cannot unmarshal %s into IssueTemplateStringSlice", value.ShortTag()) } type IssueConfigContactLink struct { diff --git a/modules/structs/issue_test.go b/modules/structs/issue_test.go index 55bd01df49..3638713caa 100644 --- a/modules/structs/issue_test.go +++ b/modules/structs/issue_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func TestIssueTemplate_Type(t *testing.T) { @@ -88,7 +88,7 @@ labels: b: bb `, tmpl: &IssueTemplate{}, - wantErr: "line 3: cannot unmarshal !!map into IssueTemplateStringSlice", + wantErr: "yaml: unmarshal errors:\n line 3: cannot unmarshal !!map into IssueTemplateStringSlice", }, } for _, tt := range tests { diff --git a/routers/api/packages/helm/helm.go b/routers/api/packages/helm/helm.go index 4489dbfedf..c0fc032e15 100644 --- a/routers/api/packages/helm/helm.go +++ b/routers/api/packages/helm/helm.go @@ -23,7 +23,7 @@ import ( "gitea.dev/services/context" packages_service "gitea.dev/services/packages" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func apiError(ctx *context.Context, status int, obj any) { diff --git a/routers/web/devtest/mail_preview.go b/routers/web/devtest/mail_preview.go index 162ac3a314..82a84ec03c 100644 --- a/routers/web/devtest/mail_preview.go +++ b/routers/web/devtest/mail_preview.go @@ -12,7 +12,7 @@ import ( "gitea.dev/services/context" "gitea.dev/services/mailer" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func MailPreviewRender(ctx *context.Context) { diff --git a/services/issue/template.go b/services/issue/template.go index 87153913a5..c1a0ff2fdd 100644 --- a/services/issue/template.go +++ b/services/issue/template.go @@ -16,7 +16,7 @@ import ( api "gitea.dev/modules/structs" "gitea.dev/modules/util" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) // templateDirCandidates issue templates directory diff --git a/services/migrations/dump.go b/services/migrations/dump.go index 6d81220b4f..949f2e79b8 100644 --- a/services/migrations/dump.go +++ b/services/migrations/dump.go @@ -26,7 +26,7 @@ import ( "gitea.dev/modules/structs" "github.com/google/uuid" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) var _ base.Uploader = &RepositoryDumper{} diff --git a/services/migrations/restore.go b/services/migrations/restore.go index a94a1e1c93..cb34f68186 100644 --- a/services/migrations/restore.go +++ b/services/migrations/restore.go @@ -12,7 +12,7 @@ import ( base "gitea.dev/modules/migration" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) // RepositoryRestorer implements an Downloader from the local directory diff --git a/tests/integration/api_issue_config_test.go b/tests/integration/api_issue_config_test.go index 95ea4af783..4227f33237 100644 --- a/tests/integration/api_issue_config_test.go +++ b/tests/integration/api_issue_config_test.go @@ -16,7 +16,7 @@ import ( "gitea.dev/tests" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func createIssueConfig(t *testing.T, user *user_model.User, repo *repo_model.Repository, issueConfig map[string]any) { diff --git a/tests/integration/api_packages_helm_test.go b/tests/integration/api_packages_helm_test.go index 003be3ff14..f66676b81d 100644 --- a/tests/integration/api_packages_helm_test.go +++ b/tests/integration/api_packages_helm_test.go @@ -20,7 +20,7 @@ import ( "gitea.dev/tests" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func TestPackageHelm(t *testing.T) { diff --git a/tests/integration/dump_restore_test.go b/tests/integration/dump_restore_test.go index 0cb9001d8a..92e0ed4664 100644 --- a/tests/integration/dump_restore_test.go +++ b/tests/integration/dump_restore_test.go @@ -24,7 +24,7 @@ import ( "gitea.dev/services/migrations" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v4" ) func TestDumpRestore(t *testing.T) { From da3e192eafc04b17f8fdea55f310ff4760b805ed Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 29 May 2026 06:34:37 +0200 Subject: [PATCH 8/9] fix(actions): keep action run title clickable when commit subject is a URL (#37867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When a commit subject is a bare URL, `linkProcessor` wrapped it in its own `` to that URL. Because HTML cannot nest anchors, the wrapping default link (the action run / commit link) was lost and the action title became unclickable — clicking it sent the user to the URL from the commit message instead of the action log. - Drop `linkProcessor` from `PostProcessCommitMessageSubject` so the whole subject stays wrapped in the default link. URLs in subjects now render as text inside that link; URLs in commit bodies are unaffected. Fixes #37865 --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: Lunny Xiao Co-authored-by: Giteabot --- modules/markup/html.go | 24 +++++++++++++++++++++--- modules/templates/util_render_test.go | 12 ++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/modules/markup/html.go b/modules/markup/html.go index a635ce219b..4943bdf4a5 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -175,16 +175,25 @@ var emojiProcessors = []processor{ emojiProcessor, } +// isBareURLSubject reports whether the (HTML-escaped) commit subject content +// is entirely a single URL, ignoring leading/trailing whitespace. +func isBareURLSubject(content string) bool { + s := strings.TrimSpace(html.UnescapeString(content)) + if s == "" { + return false + } + m := common.GlobalVars().LinkRegex.FindStringIndex(s) + return m != nil && m[0] == 0 && m[1] == len(s) +} + // PostProcessCommitMessageSubject will use the same logic as PostProcess and // PostProcessCommitMessage, but will disable the shortLinkProcessor and -// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set, -// which changes every text node into a link to the passed default link. +// emailAddressProcessor, and wraps the whole subject in defaultLink. func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) { procs := []processor{ fullIssuePatternProcessor, comparePatternProcessor, fullHashPatternProcessor, - linkProcessor, mentionProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, @@ -192,6 +201,15 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st emojiShortCodeProcessor, emojiProcessor, } + // When the whole subject is a bare URL, linkProcessor would turn it into + // a competing anchor and hijack the surrounding defaultLink wrapper, leaving + // the subject visually unclickable. Match GitHub: render such subjects as + // plain text inside defaultLink. Partial URLs inside larger text still become + // their own links (nested anchors aren't legal HTML, so the outer defaultLink + // naturally breaks on that span, same as on GitHub). + if !isBareURLSubject(content) { + procs = append(procs, linkProcessor) + } procs = append(procs, func(ctx *RenderContext, node *html.Node) { ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data} node.Type = html.ElementNode diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index f732be014a..be1190cc49 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -140,6 +140,18 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo)) }) + t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) { + // a bare URL in the subject must not hijack the default link + expected := `https://example.com/file.bin` + assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo)) + }) + + t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) { + // a URL embedded in larger subject text still becomes its own link + expected := `see https://example.com/x here` + assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo)) + }) + t.Run("RenderIssueTitle", func(t *testing.T) { defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() expected := ` space @mention-user From 949119c1dd8108fe72f1fd22ed1199200fa73ab1 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Thu, 28 May 2026 22:53:14 -0600 Subject: [PATCH 9/9] fix(actions): exclude `workflow_call` from workflow trigger detection (#37894) Gitea now only allows `workflow_dispatch.inputs`. If a workflow contains `workflow_call.inputs`, the workflow cannot be triggered, even though the `on:` section contains other trigger events. https://github.com/go-gitea/gitea/blob/428ee9fcce7928bf5405900345d43e9ba1b01564/modules/actions/jobparser/model.go#L402-L405 For example, this workflow cannot be triggered due to `workflow_call.inputs`: ```yaml on: push: pull_request: workflow_call: inputs: name: type: string ``` --- This PR is extracted from #37478 for backport --------- Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.8) Co-authored-by: Giteabot --- modules/actions/jobparser/model.go | 12 +++++++ modules/actions/jobparser/model_test.go | 47 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go index 2c4bd1f93a..96450849ab 100644 --- a/modules/actions/jobparser/model.go +++ b/modules/actions/jobparser/model.go @@ -298,6 +298,9 @@ func toGitContext(input map[string]any) *model.GithubContext { return gitContext } +// workflowCallEvent is only fired by another workflow's `uses:`, so it is excluded from trigger detection. +const workflowCallEvent = "workflow_call" + func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { switch rawOn.Kind { case yaml.ScalarNode: @@ -306,6 +309,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { if err != nil { return nil, err } + if val == workflowCallEvent { + return []*Event{}, nil + } return []*Event{ {Name: val}, }, nil @@ -319,6 +325,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { for _, v := range val { switch t := v.(type) { case string: + if t == workflowCallEvent { + continue + } res = append(res, &Event{Name: t}) default: return nil, fmt.Errorf("invalid type %T", t) @@ -332,6 +341,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { } res := make([]*Event, 0, len(events)) for i, k := range events { + if k == workflowCallEvent { + continue + } v := triggers[i] switch v.Kind { case yaml.ScalarNode: diff --git a/modules/actions/jobparser/model_test.go b/modules/actions/jobparser/model_test.go index bd29cb9425..567c160f92 100644 --- a/modules/actions/jobparser/model_test.go +++ b/modules/actions/jobparser/model_test.go @@ -254,6 +254,53 @@ func TestParseRawOn(t *testing.T) { }, }, }, + { + // `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection. + input: `on: + workflow_call: + inputs: + env: + type: string + required: true + outputs: + sha: + value: ${{ jobs.build.outputs.commit }} + secrets: + DEPLOY_KEY: + required: true +`, + result: []*Event{}, + }, + { + // Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces. + input: `on: + workflow_call: + inputs: + env: + type: string + push: + branches: [main] +`, + result: []*Event{ + { + Name: "push", + acts: map[string][]string{"branches": {"main"}}, + }, + }, + }, + { + // Scalar form: a purely reusable workflow has no event triggers. + input: "on: workflow_call", + result: []*Event{}, + }, + { + // Sequence form: `workflow_call` is excluded while sibling events are kept. + input: "on:\n - push\n - workflow_call\n - pull_request", + result: []*Event{ + {Name: "push"}, + {Name: "pull_request"}, + }, + }, } for _, kase := range kases { t.Run(kase.input, func(t *testing.T) {