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()))
})
}