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 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) 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) { 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/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/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 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/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/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/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/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" 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") + }) + }) +} 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}) 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())) }) }