From ad06fa7bec5665bc265457b63347e1d5aea7f3aa Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 19 May 2026 20:45:18 +0200 Subject: [PATCH 1/8] fix(pull): handle empty pull request files view to allow reviews (#37783) (#37785) Backport #37783 Co-authored-by: wxiaoguang --- routers/web/repo/pull.go | 53 +++++++++++++++------------ tests/integration/pull_status_test.go | 31 ++++++++++------ 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index c2c026e9cc..3708a1052c 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -681,6 +681,8 @@ func indexCommit(commits []*git.Commit, commitID string) *git.Commit { // ViewPullFiles render pull request changed files list page func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { + var err error + ctx.Data["PageIsPullList"] = true ctx.Data["PageIsPullFiles"] = true @@ -705,44 +707,47 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { headCommitID := prCompareInfo.HeadCommitID isSingleCommit := beforeCommitID == "" && afterCommitID != "" - ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase) && (afterCommitID == "" || afterCommitID == headCommitID) + + ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit ctx.Data["IsShowingAllCommits"] = isShowAllCommits - if afterCommitID == "" || afterCommitID == headCommitID { - afterCommitID = headCommitID - } + afterCommitID = util.IfZero(afterCommitID, headCommitID) afterCommit := indexCommit(prCompareInfo.Commits, afterCommitID) + if afterCommit == nil && afterCommitID == headCommitID { + afterCommit, err = gitRepo.GetCommit(afterCommitID) + if err != nil { + ctx.ServerError("GetCommit(afterCommitID)", err) + return + } + } if afterCommit == nil { - ctx.HTTPError(http.StatusBadRequest, "after commit not found in PR commits") + ctx.NotFound(nil) return } var beforeCommit *git.Commit - var err error - if !isSingleCommit { - if beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase { - beforeCommitID = prCompareInfo.CompareBase - // merge base commit is not in the list of the pull request commits - beforeCommit, err = gitRepo.GetCommit(beforeCommitID) - if err != nil { - ctx.ServerError("GetCommit", err) - return - } - } else { - beforeCommit = indexCommit(prCompareInfo.Commits, beforeCommitID) - if beforeCommit == nil { - ctx.HTTPError(http.StatusBadRequest, "before commit not found in PR commits") - return - } - } - } else { + if isSingleCommit { beforeCommit, err = afterCommit.Parent(0) if err != nil { - ctx.ServerError("Parent", err) + ctx.ServerError("afterCommit.Parent", err) return } beforeCommitID = beforeCommit.ID.String() + } else { + beforeCommitID = util.IfZero(beforeCommitID, prCompareInfo.CompareBase) + beforeCommit = indexCommit(prCompareInfo.Commits, beforeCommitID) + if beforeCommit == nil && beforeCommitID == prCompareInfo.CompareBase { + beforeCommit, err = gitRepo.GetCommit(beforeCommitID) + if err != nil { + ctx.ServerError("GetCommit(beforeCommitID)", err) + return + } + } + } + if beforeCommit == nil { + ctx.NotFound(nil) + return } ctx.Data["CompareInfo"] = prCompareInfo diff --git a/tests/integration/pull_status_test.go b/tests/integration/pull_status_test.go index 65a28e79c1..d346182498 100644 --- a/tests/integration/pull_status_test.go +++ b/tests/integration/pull_status_test.go @@ -140,20 +140,29 @@ func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) { func TestPullCreate_EmptyChangesWithSameCommits(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user1") - testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testCreateBranch(t, session, "user1", "repo1", "branch/master", "status1", http.StatusSeeOther) - req := NewRequestWithValues(t, "POST", "/user1/repo1/compare/master...status1", - map[string]string{ - "title": "pull request from status1", - }, - ) - session.MakeRequest(t, req, http.StatusOK) - req = NewRequest(t, "GET", "/user1/repo1/pulls/1") - resp := session.MakeRequest(t, req, http.StatusOK) - doc := NewHTMLParser(t, resp.Body) + testCreateBranch(t, session, "user2", "repo1", "branch/master", "empty-pr-branch", http.StatusSeeOther) + resp := testPullCreateDirectly(t, session, createPullRequestOptions{ + BaseRepoOwner: "user2", + BaseRepoName: "repo1", + BaseBranch: "master", + HeadBranch: "empty-pr-branch", + Title: "empty pr test", + }) + prURL := test.RedirectURL(resp) + + // check the "merge box" text + req := NewRequest(t, "GET", prURL) + resp = session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) text := strings.TrimSpace(doc.doc.Find(".merge-section").Text()) assert.Contains(t, text, "This branch is already included in the target branch. There is nothing to merge.") + + // check the "files" tab content + req = NewRequest(t, "GET", prURL+"/files") + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + assert.Equal(t, "Diff Content Not Available", strings.TrimSpace(doc.Find("#diff-container").Text())) }) } -- 2.52.0 From ac48c1d9587fedce03fa85f4b34c263d2b607639 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Sat, 9 May 2026 10:02:21 -0700 Subject: [PATCH 2/8] fix: "run as root" check (#37622) (#37625) Backport #37622 Remove the hacky and fragile `sed os.Getuid()` patch. Co-authored-by: wxiaoguang --- modules/setting/setting.go | 47 ++++++++++++++++++++++---------------- snap/snapcraft.yaml | 1 - 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 11d8d37ce4..6ed0e09a25 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -14,6 +14,7 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/optional" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/user" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util" ) // settings @@ -197,32 +198,38 @@ func loadLoginNotificationFrom(cfg ConfigProvider) { func loadRunModeFrom(rootCfg ConfigProvider) { rootSec := rootCfg.Section("") + mustNotRunAsRoot(rootSec) + + runModeValue := os.Getenv("GITEA_RUN_MODE") + runModeValue = util.IfZero(runModeValue, rootSec.Key("RUN_MODE").String()) + // non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value. + IsProd = !strings.EqualFold(runModeValue, "dev") // TODO: can use case-sensitive comparing in the future + RunMode = util.Iif(IsProd, "prod", "dev") + + // there is a separate check: mustCurrentRunUserMatch (IsRunUserMatchCurrentUser) RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername()) +} + +func mustNotRunAsRoot(rootSec ConfigSection) { + if os.Getuid() != 0 { + return + } + + mustRunAsRoot := os.Getenv("SNAP") != "" && os.Getenv("SNAP_NAME") != "" // snap container runs the app as uid=0 + if mustRunAsRoot { + return + } // The following is a purposefully undocumented option. Please do not run Gitea as root. It will only cause future headaches. // Please don't use root as a bandaid to "fix" something that is broken, instead the broken thing should instead be fixed properly. - unsafeAllowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") - unsafeAllowRunAsRoot = unsafeAllowRunAsRoot || optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() - RunMode = os.Getenv("GITEA_RUN_MODE") - if RunMode == "" { - RunMode = rootSec.Key("RUN_MODE").MustString("prod") - } + allowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") || // check gitea config + optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() // check gitea env var - // non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value. - RunMode = strings.ToLower(RunMode) - if RunMode != "dev" { - RunMode = "prod" - } - IsProd = RunMode != "dev" - - // check if we run as root - if os.Getuid() == 0 { - if !unsafeAllowRunAsRoot { - // Special thanks to VLC which inspired the wording of this messaging. - log.Fatal("Gitea is not supposed to be run as root. Sorry. If you need to use privileged TCP ports please instead use setcap and the `cap_net_bind_service` permission") - } - log.Critical("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.") + if !allowRunAsRoot { + // Special thanks to VLC which inspired the wording of this messaging. + log.Fatal("Gitea is not supposed to be run as root. If you need to use privileged TCP ports please instead use `setcap` and the `cap_net_bind_service` permission.") } + log.Warn("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.") } // HasInstallLock checks the install-lock in ConfigProvider directly, because sometimes the config file is not loaded into setting variables yet. diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index f78fc3e5f1..6996a91d9e 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -73,7 +73,6 @@ parts: override-build: | set -x - sed -i 's/os.Getuid()/1/g' modules/setting/setting.go npm install -g pnpm TAGS="bindata pam cert" make build install -D gitea "${SNAPCRAFT_PART_INSTALL}/gitea" -- 2.52.0 From a063c3b2e441759a47c39a20b7dcef1614d06368 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Wed, 27 May 2026 20:52:50 -0700 Subject: [PATCH 3/8] fix(actions): ack re-sent `UpdateLog` finalize idempotently (#37885) (#37892) Backport #37885 by @silverwind Fixes https://github.com/go-gitea/gitea/issues/37871, full backwards and forwards compatible with runners. Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.7) Co-authored-by: Lunny Xiao --- routers/api/actions/runner/runner.go | 24 ++++- .../integration/actions_log_finalize_test.go | 91 +++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 tests/integration/actions_log_finalize_test.go diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index d37c6d72a9..9412dc944f 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -261,16 +261,32 @@ func (s *Service) UpdateLog( } ack := task.LogLength - if len(req.Msg.Rows) == 0 || req.Msg.Index > ack || int64(len(req.Msg.Rows))+req.Msg.Index <= ack { + // Trim rows the runner already had acked. + var rows []*runnerv1.LogRow + if req.Msg.Index <= ack && int64(len(req.Msg.Rows))+req.Msg.Index > ack { + rows = req.Msg.Rows[ack-req.Msg.Index:] + } + + // Ack a re-sent finalize idempotently. Appending new rows past the seal errors. + if task.LogInStorage { + if len(rows) > 0 { + return nil, status.Errorf(codes.AlreadyExists, "log file has been archived") + } res.Msg.AckIndex = ack return res, nil } - if task.LogInStorage { - return nil, status.Errorf(codes.AlreadyExists, "log file has been archived") + // Bail unless we have new rows or a NoMore to finalize. Even with + // NoMore, bail when the runner has outrun the server — archiving a + // log with a gap is worse than asking it to retry. + if len(rows) == 0 && (!req.Msg.NoMore || req.Msg.Index > ack) { + res.Msg.AckIndex = ack + return res, nil } - rows := req.Msg.Rows[ack-req.Msg.Index:] + // WriteLogs is called even with no rows: with offset==0 it bootstraps + // an empty DBFS file so TransferLogs below has something to read when + // the runner finalizes a task that produced no log output. ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows) if err != nil { return nil, status.Errorf(codes.Internal, "unable to append logs to dbfs file: %v", err) diff --git a/tests/integration/actions_log_finalize_test.go b/tests/integration/actions_log_finalize_test.go new file mode 100644 index 0000000000..103d59c289 --- /dev/null +++ b/tests/integration/actions_log_finalize_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/url" + "os" + "testing" + + actions_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/actions" + auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/dbfs" + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest" + user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user" + actions_module "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/actions" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage" + + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Regression for https://gitea.com/gitea/runner/issues/950: a runner that +// finalizes a task with no log output sends UpdateLog{Rows:[], NoMore:true}. +// The previous short-circuit on len(Rows)==0 skipped TransferLogs, leaving +// an orphan dbfs_data row. Verify the row is now archived and removed. +func TestActionsLogFinalizeWithoutRows(t *testing.T) { + onGiteaRun(t, func(t *testing.T, _ *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + apiRepo := createActionsTestRepo(t, token, "actions-finalize-no-rows", false) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false) + + const wfTreePath = ".gitea/workflows/finalize-no-rows.yml" + wfFileContent := fmt.Sprintf(`name: finalize-no-rows +on: + push: + paths: + - '%s' +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: noop +`, wfTreePath) + createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "trigger", wfFileContent)) + + task := runner.fetchTask(t) + + resp, err := runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(&runnerv1.UpdateLogRequest{ + TaskId: task.Id, + Index: 0, + Rows: nil, + NoMore: true, + })) + require.NoError(t, err) + assert.EqualValues(t, 0, resp.Msg.AckIndex) + + freshTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id}) + require.True(t, freshTask.LogInStorage, "log_in_storage must flip after empty NoMore=true") + + _, err = storage.Actions.Stat(freshTask.LogFilename) + assert.NoError(t, err, "archived log must exist in storage") + + _, err = dbfs.Open(t.Context(), actions_module.DBFSPrefix+freshTask.LogFilename) + assert.ErrorIs(t, err, os.ErrNotExist, "DBFS row must be cleaned up after TransferLogs") + + // The runner re-sends its final UpdateLog when the response was lost. + // A sealed log must ack the re-send and still reject new appended rows. + t.Run("re-sent finalize is idempotent", func(t *testing.T) { + finalize := &runnerv1.UpdateLogRequest{TaskId: task.Id, Index: 0, Rows: nil, NoMore: true} + resp, err := runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(finalize)) + require.NoError(t, err) + assert.EqualValues(t, 0, resp.Msg.AckIndex) + + _, err = runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(&runnerv1.UpdateLogRequest{ + TaskId: task.Id, Index: 0, Rows: []*runnerv1.LogRow{{Content: "late"}}, NoMore: true, + })) + require.Error(t, err, "appending rows past the seal must be rejected") + }) + }) +} -- 2.52.0 From 9db67cd5546d1e29ade96fa48c8827f5c3121274 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Thu, 28 May 2026 20:09:08 -0700 Subject: [PATCH 4/8] fix(actions): reject workflow_dispatch for workflows without that trigger (#37660) (#37895) Backport #37660 by @jorgeortiz85 ## 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. ## Testing - `go test ./services/actions` - `TAGS="sqlite sqlite_unlock_notify" make test-integration#TestWorkflowDispatchPublicApiRequiresWorkflowDispatchTrigger` - `TAGS="sqlite sqlite_unlock_notify" make test-integration#TestWorkflowDispatchPublicApi` ## Disclosure Developed with assistance from OpenAI Codex. Co-authored-by: Jorge Ortiz 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 7fb4eb6c4c..d1a161d818 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1081,6 +1081,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 df58e548bf..6b6308be67 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 0f001f2bcd..85cde114ac 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}) -- 2.52.0 From 58074ac860d8563116da6b8c08d4627b2974d94d Mon Sep 17 00:00:00 2001 From: Giteabot Date: Thu, 28 May 2026 22:59:47 -0700 Subject: [PATCH 5/8] fix(actions): keep action run title clickable when commit subject is a URL (#37867) (#37898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport #37867 by @bircni - 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: Nicolas Co-authored-by: Claude Opus 4.7 Co-authored-by: Lunny Xiao --- 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 0097833a71..dd41b4e2e9 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 5518858c53..322fee4d66 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 -- 2.52.0 From f962ae575ad9c92325f1140d16168c76a05f7c40 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Thu, 28 May 2026 23:40:50 -0700 Subject: [PATCH 6/8] fix(actions): exclude `workflow_call` from workflow trigger detection (#37894) (#37899) Backport #37894 by @Zettat123 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: Zettat123 Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.8) --- 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 7132c278e9..eb17a43b01 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 d0e8204161..7b0be62c26 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) { -- 2.52.0 From 882eb2cce722a94b4400d5f03efaaf6f99de7dee Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Fri, 26 Jun 2026 21:41:36 -0500 Subject: [PATCH 7/8] docs: add cherry-pick entries to changelog Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59578248c4..13a721d53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ - Wiki page rename with automatic redirects via YAML frontmatter (#672) ### Fixed +- Cherry-pick upstream v1.26.2: handle empty pull request files view to allow reviews (#37783) +- Cherry-pick upstream v1.26.2: fix "run as root" check with snap container detection (#37622) +- Cherry-pick upstream: ack re-sent UpdateLog finalize idempotently (#37885) +- Cherry-pick upstream: reject workflow_dispatch for workflows without that trigger (#37660) +- Cherry-pick upstream: keep action run title clickable when commit subject is a URL (#37867) +- Cherry-pick upstream: exclude workflow_call from workflow trigger detection (#37894) - API token edit: reject empty scope update requests with 400 instead of silently succeeding - Workflow token auth: pr-check.yml pre-release dispatch was silently failing due to env var / curl reference mismatch - Workflow tokens: standardize all GA_TOKEN/GITEA_TOKEN/GITEA_URL env vars to MOKOGITEA_TOKEN/MOKOGITEA_URL across all workflow files in 5 template repos + MokoCLI (65+ files) -- 2.52.0 From a48f44c90148684ae05bdeadd0fb0cd1d7f230ed Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 00:19:57 -0500 Subject: [PATCH 8/8] fix(ci): allow fix/* and patch/* branches to target main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch policy check was rejecting fix/* → main PRs, but our actual policy allows fix/patch branches to target main directly for hotfixes that don't need the dev → rc → main cycle. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- .mokogitea/workflows/pr-check.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index c834bf5f8b..27231b2aba 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -47,15 +47,15 @@ jobs: fi ;; fix/*|bugfix/*) - if [ "$BASE" != "dev" ]; then + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then ALLOWED=false - REASON="Fix branches must target 'dev', not '${BASE}'" + REASON="Fix branches must target 'dev' or 'main', not '${BASE}'" fi ;; patch/*) - if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ] && [ "$BASE" != "rc" ]; then ALLOWED=false - REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'" + REASON="Patch branches must target 'dev', 'main', or 'rc', not '${BASE}'" fi ;; hotfix/*) @@ -86,10 +86,11 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`patch/*\` → \`dev\`, \`main\`, or \`rc\`" >> $GITHUB_STEP_SUMMARY echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY - echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`rc\` → \`main\`" >> $GITHUB_STEP_SUMMARY exit 1 fi -- 2.52.0