Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 882eb2cce7 | |||
| f962ae575a | |||
| 58074ac860 | |||
| 9db67cd554 | |||
| a063c3b2e4 | |||
| ac48c1d958 | |||
| ad06fa7bec | |||
| bd5c435f27 | |||
| a25a673d0c |
@@ -0,0 +1,68 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
|
||||
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
|
||||
|
||||
name: "Universal: CI Issue Reporter"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
gate:
|
||||
description: "CI gate name (e.g. PR Validation, Repository Health)"
|
||||
required: true
|
||||
type: string
|
||||
details:
|
||||
description: "Human-readable failure description"
|
||||
required: true
|
||||
type: string
|
||||
severity:
|
||||
description: "error or warning"
|
||||
required: false
|
||||
type: string
|
||||
default: "error"
|
||||
workflow:
|
||||
description: "Workflow name for the issue title"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
secrets:
|
||||
MOKOGITEA_TOKEN:
|
||||
required: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: "Report: ${{ inputs.gate }}"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone MokoCLI
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
run: |
|
||||
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
|
||||
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
|
||||
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
|
||||
|
||||
- name: Report CI failure
|
||||
env:
|
||||
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
|
||||
/tmp/mokocli/cli/ci_issue_reporter.sh \
|
||||
--gate "${{ inputs.gate }}" \
|
||||
--details "${{ inputs.details }}" \
|
||||
--severity "${{ inputs.severity }}" \
|
||||
--workflow "${{ inputs.workflow }}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+21
-3
@@ -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
|
||||
|
||||
+27
-20
@@ -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.
|
||||
|
||||
@@ -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 := `<a href="https://example.com/link" class="muted">https://example.com/file.bin</a>`
|
||||
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 := `<a href="https://example.com/link" class="muted">see </a><a href="https://example.com/x" data-markdown-generated-content="">https://example.com/x</a><a href="https://example.com/link" class="muted"> here</a>`
|
||||
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<SPACE><SPACE>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+29
-24
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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()))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user