From 147bdfce0df1acebeca446c3ee2bf402c2e0ed6f Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 19 Feb 2026 01:31:01 +0100 Subject: [PATCH] Add `actions.WORKFLOW_DIRS` setting (#36619) Fixes: https://github.com/go-gitea/gitea/issues/36612 This new setting controls which workflow directories are searched. The default value matches the previous hardcoded behaviour. This allows users for example to exclude `.github/workflows` from being picked up by Actions in mirrored repositories by setting `WORKFLOW_DIRS = .gitea/workflows`. Signed-off-by: silverwind Co-authored-by: Claude Opus 4.6 --- custom/conf/app.example.ini | 3 ++ modules/actions/workflows.go | 31 +++++++++----- modules/actions/workflows_test.go | 71 +++++++++++++++++++++++++++++++ modules/setting/actions.go | 18 ++++++++ modules/setting/actions_test.go | 59 +++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 11 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 084c66aab0..c7f8401cd9 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2858,6 +2858,9 @@ LEVEL = Info ;ABANDONED_JOB_TIMEOUT = 24h ;; Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow ;SKIP_WORKFLOW_STRINGS = [skip ci],[ci skip],[no ci],[skip actions],[actions skip] +;; Comma-separated list of workflow directories, the first one to exist +;; in a repo is used to find Actions workflow files +;WORKFLOW_DIRS = .gitea/workflows,.github/workflows ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 26a6ebc370..72892f4124 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/glob" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -41,22 +42,30 @@ func IsWorkflow(path string) bool { return false } - return strings.HasPrefix(path, ".gitea/workflows") || strings.HasPrefix(path, ".github/workflows") + for _, workflowDir := range setting.Actions.WorkflowDirs { + if strings.HasPrefix(path, workflowDir+"/") { + return true + } + } + return false } func ListWorkflows(commit *git.Commit) (string, git.Entries, error) { - rpath := ".gitea/workflows" - tree, err := commit.SubTree(rpath) - if _, ok := err.(git.ErrNotExist); ok { - rpath = ".github/workflows" - tree, err = commit.SubTree(rpath) + var tree *git.Tree + var err error + var workflowDir string + for _, workflowDir = range setting.Actions.WorkflowDirs { + tree, err = commit.SubTree(workflowDir) + if err == nil { + break + } + if !git.IsErrNotExist(err) { + return "", nil, err + } } - if _, ok := err.(git.ErrNotExist); ok { + if tree == nil { return "", nil, nil } - if err != nil { - return "", nil, err - } entries, err := tree.ListEntriesRecursiveFast() if err != nil { @@ -69,7 +78,7 @@ func ListWorkflows(commit *git.Commit) (string, git.Entries, error) { ret = append(ret, entry) } } - return rpath, ret, nil + return workflowDir, ret, nil } func GetContentFromEntry(entry *git.TreeEntry) ([]byte, error) { diff --git a/modules/actions/workflows_test.go b/modules/actions/workflows_test.go index 89620fb698..77a65aae49 100644 --- a/modules/actions/workflows_test.go +++ b/modules/actions/workflows_test.go @@ -7,12 +7,83 @@ import ( "testing" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" "github.com/stretchr/testify/assert" ) +func TestIsWorkflow(t *testing.T) { + oldDirs := setting.Actions.WorkflowDirs + defer func() { + setting.Actions.WorkflowDirs = oldDirs + }() + + tests := []struct { + name string + dirs []string + path string + expected bool + }{ + { + name: "default with yml extension", + dirs: []string{".gitea/workflows", ".github/workflows"}, + path: ".gitea/workflows/test.yml", + expected: true, + }, + { + name: "default with yaml extension", + dirs: []string{".gitea/workflows", ".github/workflows"}, + path: ".github/workflows/test.yaml", + expected: true, + }, + { + name: "only gitea configured, github path rejected", + dirs: []string{".gitea/workflows"}, + path: ".github/workflows/test.yml", + expected: false, + }, + { + name: "only github configured, gitea path rejected", + dirs: []string{".github/workflows"}, + path: ".gitea/workflows/test.yml", + expected: false, + }, + { + name: "custom workflow dir", + dirs: []string{".custom/workflows"}, + path: ".custom/workflows/deploy.yml", + expected: true, + }, + { + name: "non-workflow file", + dirs: []string{".gitea/workflows", ".github/workflows"}, + path: ".gitea/workflows/readme.md", + expected: false, + }, + { + name: "directory boundary", + dirs: []string{".gitea/workflows"}, + path: ".gitea/workflows2/test.yml", + expected: false, + }, + { + name: "unrelated path", + dirs: []string{".gitea/workflows", ".github/workflows"}, + path: "src/main.go", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setting.Actions.WorkflowDirs = tt.dirs + assert.Equal(t, tt.expected, IsWorkflow(tt.path)) + }) + } +} + func TestDetectMatched(t *testing.T) { testCases := []struct { desc string diff --git a/modules/setting/actions.go b/modules/setting/actions.go index 34346b62cf..7a91ecb593 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -4,6 +4,7 @@ package setting import ( + "errors" "fmt" "strings" "time" @@ -25,10 +26,12 @@ var ( EndlessTaskTimeout time.Duration `ini:"ENDLESS_TASK_TIMEOUT"` AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"` SkipWorkflowStrings []string `ini:"SKIP_WORKFLOW_STRINGS"` + WorkflowDirs []string `ini:"WORKFLOW_DIRS"` }{ Enabled: true, DefaultActionsURL: defaultActionsURLGitHub, SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}, + WorkflowDirs: []string{".gitea/workflows", ".github/workflows"}, } ) @@ -119,5 +122,20 @@ func loadActionsFrom(rootCfg ConfigProvider) error { return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression) } + workflowDirs := make([]string, 0, len(Actions.WorkflowDirs)) + for _, dir := range Actions.WorkflowDirs { + dir = strings.TrimSpace(dir) + if dir == "" { + continue + } + dir = strings.ReplaceAll(dir, `\`, `/`) + dir = strings.TrimRight(dir, "/") + workflowDirs = append(workflowDirs, dir) + } + if len(workflowDirs) == 0 { + return errors.New("[actions] WORKFLOW_DIRS must contain at least one entry") + } + Actions.WorkflowDirs = workflowDirs + return nil } diff --git a/modules/setting/actions_test.go b/modules/setting/actions_test.go index 353cc657fa..5c7ab268c1 100644 --- a/modules/setting/actions_test.go +++ b/modules/setting/actions_test.go @@ -97,6 +97,65 @@ STORAGE_TYPE = minio assert.Equal(t, "actions_artifacts", filepath.Base(Actions.ArtifactStorage.Path)) } +func Test_WorkflowDirs(t *testing.T) { + oldActions := Actions + defer func() { + Actions = oldActions + }() + + tests := []struct { + name string + iniStr string + wantDirs []string + wantErr bool + }{ + { + name: "default", + iniStr: `[actions]`, + wantDirs: []string{".gitea/workflows", ".github/workflows"}, + }, + { + name: "single dir", + iniStr: "[actions]\nWORKFLOW_DIRS = .github/workflows", + wantDirs: []string{".github/workflows"}, + }, + { + name: "custom order", + iniStr: "[actions]\nWORKFLOW_DIRS = .github/workflows,.gitea/workflows", + wantDirs: []string{".github/workflows", ".gitea/workflows"}, + }, + { + name: "whitespace trimming", + iniStr: "[actions]\nWORKFLOW_DIRS = .gitea/workflows , .github/workflows ", + wantDirs: []string{".gitea/workflows", ".github/workflows"}, + }, + { + name: "trailing slash normalization", + iniStr: "[actions]\nWORKFLOW_DIRS = .gitea/workflows/,.github/workflows/", + wantDirs: []string{".gitea/workflows", ".github/workflows"}, + }, + { + name: "only commas and whitespace", + iniStr: "[actions]\nWORKFLOW_DIRS = , , ,", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := NewConfigProviderFromData(tt.iniStr) + require.NoError(t, err) + err = loadActionsFrom(cfg) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantDirs, Actions.WorkflowDirs) + }) + } +} + func Test_getDefaultActionsURLForActions(t *testing.T) { oldActions := Actions oldAppURL := AppURL