diff --git a/modules/actions/workflowpattern/workflow_pattern.go b/modules/actions/workflowpattern/workflow_pattern.go new file mode 100644 index 0000000000..7e9a5d8597 --- /dev/null +++ b/modules/actions/workflowpattern/workflow_pattern.go @@ -0,0 +1,65 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflowpattern + +import ( + "strings" + + "code.gitea.io/gitea/modules/glob" +) + +type WorkflowPattern struct { + negative bool + glob glob.Glob +} + +func CompilePatterns(patterns ...string) ([]*WorkflowPattern, error) { + ret := make([]*WorkflowPattern, 0, len(patterns)) + for _, pattern := range patterns { + cp, err := glob.CompileWorkflow(pattern) + if err != nil { + return nil, err + } + ret = append(ret, &WorkflowPattern{glob: cp, negative: strings.HasPrefix(pattern, "!")}) + } + return ret, nil +} + +// Skip returns true if the workflow should be skipped per paths/branches semantics. +func Skip(sequence []*WorkflowPattern, input []string) bool { + allSkipped := true + for _, file := range input { + shouldSkip := true + for _, item := range sequence { + if item.negative { + // "!README.md" doesn't match "README.md", so "README.md" should be skipped + // "!README.md" matches "help.md" but it shouldn't affect "skip or not", because "help.md" might have been skipped by other rules like "docs/*.md" + if !item.glob.Match(file) { + shouldSkip = true + } + } else if item.glob.Match(file) { + // if "*.md" matches "help.md" so it shouldn't be skipped + shouldSkip = false + } + } + allSkipped = allSkipped && shouldSkip + } + return len(sequence) > 0 && allSkipped +} + +// Filter returns true if the workflow should be skipped per paths-ignore/branches-ignore semantics. +func Filter(sequence []*WorkflowPattern, input []string) bool { + for _, file := range input { + anyMatched := false + for _, item := range sequence { + if anyMatched = item.glob.Match(file); anyMatched { + break + } + } + if !anyMatched { + return false + } + } + return len(sequence) != 0 +} diff --git a/modules/actions/workflowpattern/workflow_pattern_test.go b/modules/actions/workflowpattern/workflow_pattern_test.go new file mode 100644 index 0000000000..59a3832634 --- /dev/null +++ b/modules/actions/workflowpattern/workflow_pattern_test.go @@ -0,0 +1,416 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflowpattern + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMatchPattern(t *testing.T) { + kases := []struct { + inputs []string + patterns []string + skipResult bool + filterResult bool + }{ + { + patterns: []string{"*"}, + inputs: []string{"path/with/slash"}, + skipResult: true, + filterResult: false, + }, + { + patterns: []string{"path/a", "path/b", "path/c"}, + inputs: []string{"meta", "path/b", "otherfile"}, + skipResult: false, + filterResult: false, + }, + { + patterns: []string{"path/a", "path/b", "path/c"}, + inputs: []string{"path/b"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"path/a", "path/b", "path/c"}, + inputs: []string{"path/c", "path/b"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"path/a", "path/b", "path/c"}, + inputs: []string{"path/c", "path/b", "path/a"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"path/a", "path/b", "path/c"}, + inputs: []string{"path/c", "path/b", "path/d", "path/a"}, + skipResult: false, + filterResult: false, + }, + { + patterns: []string{}, + inputs: []string{}, + skipResult: false, + filterResult: false, + }, + { + patterns: []string{"\\!file"}, + inputs: []string{"!file"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"escape\\\\backslash"}, + inputs: []string{"escape\\backslash"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{".yml"}, + inputs: []string{"fyml"}, + skipResult: true, + filterResult: false, + }, + // https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#patterns-to-match-branches-and-tags + { + patterns: []string{"feature/*"}, + inputs: []string{"feature/my-branch"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"feature/*"}, + inputs: []string{"feature/your-branch"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"feature/**"}, + inputs: []string{"feature/beta-a/my-branch"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"feature/**"}, + inputs: []string{"feature/beta-a/my-branch"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"feature/**"}, + inputs: []string{"feature/mona/the/octocat"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"main", "releases/mona-the-octocat"}, + inputs: []string{"main"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"main", "releases/mona-the-octocat"}, + inputs: []string{"releases/mona-the-octocat"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*"}, + inputs: []string{"main"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*"}, + inputs: []string{"releases"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**"}, + inputs: []string{"all/the/branches"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**"}, + inputs: []string{"every/tag"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*feature"}, + inputs: []string{"mona-feature"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*feature"}, + inputs: []string{"feature"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*feature"}, + inputs: []string{"ver-10-feature"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"v2*"}, + inputs: []string{"v2"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"v2*"}, + inputs: []string{"v2.0"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"v2*"}, + inputs: []string{"v2.9"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"v[12].[0-9]+.[0-9]+"}, + inputs: []string{"v1.10.1"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"v[12].[0-9]+.[0-9]+"}, + inputs: []string{"v2.0.0"}, + skipResult: false, + filterResult: true, + }, + // https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#patterns-to-match-file-paths + { + patterns: []string{"*"}, + inputs: []string{"README.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*"}, + inputs: []string{"server.rb"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*.jsx?"}, + inputs: []string{"page.js"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*.jsx?"}, + inputs: []string{"page.jsx"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**"}, + inputs: []string{"all/the/files.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*.js"}, + inputs: []string{"app.js"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*.js"}, + inputs: []string{"index.js"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**.js"}, + inputs: []string{"index.js"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**.js"}, + inputs: []string{"js/index.js"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**.js"}, + inputs: []string{"src/js/app.js"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"docs/*"}, + inputs: []string{"docs/README.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"docs/*"}, + inputs: []string{"docs/file.txt"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"docs/**"}, + inputs: []string{"docs/README.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"docs/**"}, + inputs: []string{"docs/mona/octocat.txt"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"docs/**/*.md"}, + inputs: []string{"docs/README.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"docs/**/*.md"}, + inputs: []string{"docs/mona/hello-world.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"docs/**/*.md"}, + inputs: []string{"docs/a/markdown/file.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/docs/**"}, + inputs: []string{"docs/hello.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/docs/**"}, + inputs: []string{"dir/docs/my-file.txt"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/docs/**"}, + inputs: []string{"space/docs/plan/space.doc"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/README.md"}, + inputs: []string{"README.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/README.md"}, + inputs: []string{"js/README.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/*src/**"}, + inputs: []string{"a/src/app.js"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/*src/**"}, + inputs: []string{"my-src/code/js/app.js"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/*-post.md"}, + inputs: []string{"my-post.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/*-post.md"}, + inputs: []string{"path/their-post.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/migrate-*.sql"}, + inputs: []string{"migrate-10909.sql"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/migrate-*.sql"}, + inputs: []string{"db/migrate-v1.0.sql"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"**/migrate-*.sql"}, + inputs: []string{"db/sept/migrate-v1.sql"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*.md", "!README.md"}, + inputs: []string{"hello.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*.md", "!README.md"}, + inputs: []string{"README.md"}, + skipResult: true, + filterResult: true, + }, + { + patterns: []string{"*.md", "!README.md"}, + inputs: []string{"docs/hello.md"}, + skipResult: true, + filterResult: true, + }, + { + patterns: []string{"*.md", "!README.md", "README*"}, + inputs: []string{"hello.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*.md", "!README.md", "README*"}, + inputs: []string{"README.md"}, + skipResult: false, + filterResult: true, + }, + { + patterns: []string{"*.md", "!README.md", "README*"}, + inputs: []string{"README.doc"}, + skipResult: false, + filterResult: true, + }, + } + + for _, kase := range kases { + msg := fmt.Sprintf("patterns=%s, input=%s", strings.Join(kase.patterns, ","), strings.Join(kase.inputs, ",")) + patterns, err := CompilePatterns(kase.patterns...) + assert.NoError(t, err, "compile error: %s", msg) + assert.Equal(t, kase.skipResult, Skip(patterns, kase.inputs), "unexpected skip result: %s", msg) + assert.Equal(t, kase.filterResult, Filter(patterns, kase.inputs), "unexpected filter result: %s", msg) + } +} diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 0269e8b0bb..991de1af10 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -9,6 +9,7 @@ import ( "strings" "code.gitea.io/gitea/modules/actions/jobparser" + "code.gitea.io/gitea/modules/actions/workflowpattern" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/glob" "code.gitea.io/gitea/modules/log" @@ -18,7 +19,6 @@ import ( webhook_module "code.gitea.io/gitea/modules/webhook" "gitea.com/gitea/runner/act/model" - "gitea.com/gitea/runner/act/workflowpattern" "go.yaml.in/yaml/v4" ) @@ -297,7 +297,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa if err != nil { break } - if !workflowpattern.Skip(patterns, []string{refName.BranchName()}, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Skip(patterns, []string{refName.BranchName()}) { matchTimes++ } case "branches-ignore": @@ -309,7 +309,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa if err != nil { break } - if !workflowpattern.Filter(patterns, []string{refName.BranchName()}, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Filter(patterns, []string{refName.BranchName()}) { matchTimes++ } case "tags": @@ -321,7 +321,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa if err != nil { break } - if !workflowpattern.Skip(patterns, []string{refName.TagName()}, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Skip(patterns, []string{refName.TagName()}) { matchTimes++ } case "tags-ignore": @@ -333,7 +333,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa if err != nil { break } - if !workflowpattern.Filter(patterns, []string{refName.TagName()}, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Filter(patterns, []string{refName.TagName()}) { matchTimes++ } case "paths": @@ -349,7 +349,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa if err != nil { break } - if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Skip(patterns, filesChanged) { matchTimes++ } } @@ -366,7 +366,7 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa if err != nil { break } - if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Filter(patterns, filesChanged) { matchTimes++ } } @@ -492,7 +492,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa if err != nil { break } - if !workflowpattern.Skip(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Skip(patterns, []string{refName.ShortName()}) { matchTimes++ } case "branches-ignore": @@ -501,7 +501,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa if err != nil { break } - if !workflowpattern.Filter(patterns, []string{refName.ShortName()}, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Filter(patterns, []string{refName.ShortName()}) { matchTimes++ } case "paths": @@ -513,7 +513,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa if err != nil { break } - if !workflowpattern.Skip(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Skip(patterns, filesChanged) { matchTimes++ } } @@ -526,7 +526,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa if err != nil { break } - if !workflowpattern.Filter(patterns, filesChanged, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Filter(patterns, filesChanged) { matchTimes++ } } @@ -747,7 +747,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event if err != nil { break } - if !workflowpattern.Skip(patterns, []string{workflow.Name}, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Skip(patterns, []string{workflow.Name}) { matchTimes++ } case "branches": @@ -755,7 +755,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event if err != nil { break } - if !workflowpattern.Skip(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Skip(patterns, []string{payload.WorkflowRun.HeadBranch}) { matchTimes++ } case "branches-ignore": @@ -763,7 +763,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event if err != nil { break } - if !workflowpattern.Filter(patterns, []string{payload.WorkflowRun.HeadBranch}, &workflowpattern.EmptyTraceWriter{}) { + if !workflowpattern.Filter(patterns, []string{payload.WorkflowRun.HeadBranch}) { matchTimes++ } default: diff --git a/modules/glob/glob.go b/modules/glob/glob.go index d4ca77e2ee..5e5593af1c 100644 --- a/modules/glob/glob.go +++ b/modules/glob/glob.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "regexp" + "slices" "code.gitea.io/gitea/modules/util" ) @@ -18,11 +19,18 @@ type Glob interface { } type globCompiler struct { + regexpQuestion bool + regexpPlus bool + superWildcardRight bool + supportNegative bool + + separators []rune nonSeparatorChars string globPattern []rune regexpPattern string regexp *regexp.Regexp pos int + negativeFlip bool } // compileChars compiles character class patterns like [abc] or [!abc] @@ -70,13 +78,39 @@ func (g *globCompiler) compile(subPattern bool) (string, error) { switch c { case '*': if g.pos < len(g.globPattern) && g.globPattern[g.pos] == '*' { - g.pos++ + var matchRightSep bool + if g.superWildcardRight { + // check "**/" pattern, then the wildcards should also match the right separator + // e.g.: "**/docs" should match "docs" + var rightRune rune + if g.pos+1 < len(g.globPattern) { + rightRune = g.globPattern[g.pos+1] + } + if slices.Contains(g.separators, rightRune) { + matchRightSep = g.pos-2 < 0 || g.globPattern[g.pos-2] == rightRune + } + } + if matchRightSep { + g.pos += 2 + } else { + g.pos++ + } result += ".*" // match any sequence of characters } else { result += g.nonSeparatorChars + "*" // match any sequence of non-separator characters } case '?': - result += g.nonSeparatorChars // match any single non-separator character + if g.regexpQuestion { + result += "?" + } else { + result += g.nonSeparatorChars // match any single non-separator character + } + case '+': + if g.regexpPlus { + result += "+" + } else { + result += "\\" + string(c) + } case '[': chars, err := g.compileChars() if err != nil { @@ -101,7 +135,7 @@ func (g *globCompiler) compile(subPattern bool) (string, error) { } result += "\\" + string(g.globPattern[g.pos]) g.pos++ - case '.', '+', '^', '$', '(', ')', '|': + case '.', '^', '$', '(', ')', '|': result += "\\" + string(c) // escape regexp special characters default: result += string(c) @@ -111,8 +145,9 @@ func (g *globCompiler) compile(subPattern bool) (string, error) { return result, nil } -func newGlobCompiler(pattern string, separators ...rune) (Glob, error) { - g := &globCompiler{globPattern: []rune(pattern)} +func initGlobCompiler(g *globCompiler, pattern string, separators []rune) (Glob, error) { + g.globPattern = []rune(pattern) + g.separators = separators // Escape separators for use in character class escapedSeparators := regexp.QuoteMeta(string(separators)) @@ -122,6 +157,11 @@ func newGlobCompiler(pattern string, separators ...rune) (Glob, error) { g.nonSeparatorChars = "." } + if g.supportNegative && len(g.globPattern) > 0 && g.globPattern[0] == '!' { + g.negativeFlip = true + g.pos++ + } + compiled, err := g.compile(false) if err != nil { return nil, err @@ -139,11 +179,24 @@ func newGlobCompiler(pattern string, separators ...rune) (Glob, error) { } func (g *globCompiler) Match(s string) bool { - return g.regexp.MatchString(s) + ret := g.regexp.MatchString(s) + if g.negativeFlip { + ret = !ret + } + return ret } func Compile(pattern string, separators ...rune) (Glob, error) { - return newGlobCompiler(pattern, separators...) + return initGlobCompiler(&globCompiler{}, pattern, separators) +} + +func CompileWorkflow(pattern string) (Glob, error) { + return initGlobCompiler(&globCompiler{ + regexpQuestion: true, + regexpPlus: true, + superWildcardRight: true, + supportNegative: true, + }, pattern, []rune{'/'}) } func MustCompile(pattern string, separators ...rune) Glob {