feat(workflow): add push filters, filter patterns and skip directives
- commit
- 028f5a1013f8db17fdd4a1152fd99af1bba2086b
- author
- brice · · 2026-10-12 15:53 UTC
- committer
- brice · · 2026-10-12 16:17 UTC
- parent
- 8372da1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +761 -1
M workflow/convert_test.go +16 -0
| @@ -54,6 +54,16 @@ func errorTexts(errs []Error) []string { | |||
| 54 | 54 | // the JavaScript library's and cannot be reproduced word for word. | |
| 55 | 55 | func isYAMLError(s string) bool { return strings.Contains(s, " at line ") } | |
| 56 | 56 | ||
| 57 | + | // ermiteOnly lists corpus files on which this package reports errors the | |
| 58 | + | // reference does not; the errors expected are given here instead. | |
| 59 | + | var ermiteOnly = map[string][]string{ | |
| 60 | + | "on_filters_both.yml": { | |
| 61 | + | "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one of them.", | |
| 62 | + | "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'tags' and 'tags-ignore'. Use only one of them.", | |
| 63 | + | "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'paths' and 'paths-ignore'. Use only one of them.", | |
| 64 | + | }, | |
| 65 | + | } | |
| 66 | + | ||
| 57 | 67 | func TestConvertAgainstReference(t *testing.T) { | |
| 58 | 68 | ref := loadRef(t) | |
| 59 | 69 | files, err := filepath.Glob("testdata/convert/*.yml") | |
| @@ -74,6 +84,12 @@ func TestConvertAgainstReference(t *testing.T) { | |||
| 74 | 84 | } | |
| 75 | 85 | w, errs := Parse(name, content) | |
| 76 | 86 | got := errorTexts(errs) | |
| 87 | + | if extra, ok := ermiteOnly[name]; ok { | |
| 88 | + | if !reflect.DeepEqual(got, extra) || w != nil { | |
| 89 | + | t.Errorf("errors:\n got %q\nwant %q", got, extra) | |
| 90 | + | } | |
| 91 | + | return | |
| 92 | + | } | |
| 77 | 93 | if len(want.Errors) == 1 && isYAMLError(want.Errors[0]) { | |
| 78 | 94 | // Syntax errors: the Go parser words them differently. Only | |
| 79 | 95 | // the fact that there is exactly one, on the file, is checked. | |
| @@ -54,6 +54,16 @@ func errorTexts(errs []Error) []string { | |||||
| 54 | // the JavaScript library's and cannot be reproduced word for word. | 54 | // the JavaScript library's and cannot be reproduced word for word. | ||
| 55 | func isYAMLError(s string) bool { return strings.Contains(s, " at line ") } | 55 | func isYAMLError(s string) bool { return strings.Contains(s, " at line ") } | ||
| 56 | 56 | ||||
| 57 | + | // ermiteOnly lists corpus files on which this package reports errors the | |||
| 58 | + | // reference does not; the errors expected are given here instead. | |||
| 59 | + | var ermiteOnly = map[string][]string{ | |||
| 60 | + | "on_filters_both.yml": { | |||
| 61 | + | "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one of them.", | |||
| 62 | + | "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'tags' and 'tags-ignore'. Use only one of them.", | |||
| 63 | + | "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'paths' and 'paths-ignore'. Use only one of them.", | |||
| 64 | + | }, | |||
| 65 | + | } | |||
| 66 | + | ||||
| 57 | func TestConvertAgainstReference(t *testing.T) { | 67 | func TestConvertAgainstReference(t *testing.T) { | ||
| 58 | ref := loadRef(t) | 68 | ref := loadRef(t) | ||
| 59 | files, err := filepath.Glob("testdata/convert/*.yml") | 69 | files, err := filepath.Glob("testdata/convert/*.yml") | ||
| @@ -74,6 +84,12 @@ func TestConvertAgainstReference(t *testing.T) { | |||||
| 74 | } | 84 | } | ||
| 75 | w, errs := Parse(name, content) | 85 | w, errs := Parse(name, content) | ||
| 76 | got := errorTexts(errs) | 86 | got := errorTexts(errs) | ||
| 87 | + | if extra, ok := ermiteOnly[name]; ok { | |||
| 88 | + | if !reflect.DeepEqual(got, extra) || w != nil { | |||
| 89 | + | t.Errorf("errors:\n got %q\nwant %q", got, extra) | |||
| 90 | + | } | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 77 | if len(want.Errors) == 1 && isYAMLError(want.Errors[0]) { | 93 | if len(want.Errors) == 1 && isYAMLError(want.Errors[0]) { | ||
| 78 | // Syntax errors: the Go parser words them differently. Only | 94 | // Syntax errors: the Go parser words them differently. Only | ||
| 79 | // the fact that there is exactly one, on the file, is checked. | 95 | // the fact that there is exactly one, on the file, is checked. | ||
M workflow/events.go +27 -0
| @@ -269,3 +269,30 @@ func (c *converter) convertInput(name string, m *template.Mapping, call bool) In | |||
| 269 | 269 | } | |
| 270 | 270 | return in | |
| 271 | 271 | } | |
| 272 | + | ||
| 273 | + | // filterPairs are the filters GitHub does not allow together on one event. | |
| 274 | + | var filterPairs = [][2]string{{"branches", "branches-ignore"}, {"tags", "tags-ignore"}, {"paths", "paths-ignore"}} | |
| 275 | + | ||
| 276 | + | // validateFilters reports an event that uses a filter and its "-ignore" | |
| 277 | + | // counterpart. @actions/workflow-parser accepts this; GitHub rejects the | |
| 278 | + | // file ("you can't use both"), so this port does too. The message is ours. | |
| 279 | + | // | |
| 280 | + | // TODO(verify with recording): the text and position of GitHub's error, and | |
| 281 | + | // whether it applies to every event that has these filters. | |
| 282 | + | func (c *converter) validateFilters() { | |
| 283 | + | if c.w.On == nil { | |
| 284 | + | return | |
| 285 | + | } | |
| 286 | + | for _, ev := range c.w.On.Events { | |
| 287 | + | have := map[string][]string{ | |
| 288 | + | "branches": ev.Branches, "branches-ignore": ev.BranchesIgnore, | |
| 289 | + | "tags": ev.Tags, "tags-ignore": ev.TagsIgnore, | |
| 290 | + | "paths": ev.Paths, "paths-ignore": ev.PathsIgnore, | |
| 291 | + | } | |
| 292 | + | for _, pr := range filterPairs { | |
| 293 | + | if have[pr[0]] != nil && have[pr[1]] != nil { | |
| 294 | + | c.ctx.Errorf(ev.Token, "Event '%s' cannot use both '%s' and '%s'. Use only one of them.", ev.Name, pr[0], pr[1]) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | } | |
| @@ -269,3 +269,30 @@ func (c *converter) convertInput(name string, m *template.Mapping, call bool) In | |||||
| 269 | } | 269 | } | ||
| 270 | return in | 270 | return in | ||
| 271 | } | 271 | } | ||
| 272 | + | ||||
| 273 | + | // filterPairs are the filters GitHub does not allow together on one event. | |||
| 274 | + | var filterPairs = [][2]string{{"branches", "branches-ignore"}, {"tags", "tags-ignore"}, {"paths", "paths-ignore"}} | |||
| 275 | + | ||||
| 276 | + | // validateFilters reports an event that uses a filter and its "-ignore" | |||
| 277 | + | // counterpart. @actions/workflow-parser accepts this; GitHub rejects the | |||
| 278 | + | // file ("you can't use both"), so this port does too. The message is ours. | |||
| 279 | + | // | |||
| 280 | + | // TODO(verify with recording): the text and position of GitHub's error, and | |||
| 281 | + | // whether it applies to every event that has these filters. | |||
| 282 | + | func (c *converter) validateFilters() { | |||
| 283 | + | if c.w.On == nil { | |||
| 284 | + | return | |||
| 285 | + | } | |||
| 286 | + | for _, ev := range c.w.On.Events { | |||
| 287 | + | have := map[string][]string{ | |||
| 288 | + | "branches": ev.Branches, "branches-ignore": ev.BranchesIgnore, | |||
| 289 | + | "tags": ev.Tags, "tags-ignore": ev.TagsIgnore, | |||
| 290 | + | "paths": ev.Paths, "paths-ignore": ev.PathsIgnore, | |||
| 291 | + | } | |||
| 292 | + | for _, pr := range filterPairs { | |||
| 293 | + | if have[pr[0]] != nil && have[pr[1]] != nil { | |||
| 294 | + | c.ctx.Errorf(ev.Token, "Event '%s' cannot use both '%s' and '%s'. Use only one of them.", ev.Name, pr[0], pr[1]) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | } | |||
A workflow/glob.go +176 -0
| @@ -0,0 +1,176 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "regexp" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Pattern is a compiled filter pattern in the syntax GitHub documents as the | |
| 12 | + | // "filter pattern cheat sheet" for branches, tags and paths: | |
| 13 | + | // | |
| 14 | + | // - zero or more characters, but not / | |
| 15 | + | // ** zero or more of any character; "**/" at the start of a pattern or | |
| 16 | + | // after a "/" may also match nothing, so "**/README.md" matches | |
| 17 | + | // "README.md" and "docs/**/*.md" matches "docs/a.md" | |
| 18 | + | // ? zero or one of the preceding character | |
| 19 | + | // - one or more of the preceding character | |
| 20 | + | // [abc] one character listed or in a range (a-z, A-Z, 0-9) | |
| 21 | + | // ! at the start of a pattern: negates the patterns before it | |
| 22 | + | // \ escapes the next character | |
| 23 | + | // | |
| 24 | + | // Every other character, "." included, stands for itself. Matching is | |
| 25 | + | // case-sensitive and covers the whole name. | |
| 26 | + | // | |
| 27 | + | // TODO(verify with recording): the exact GitHub implementation is not | |
| 28 | + | // public. The cheat sheet fixes the common cases; "**/" matching nothing, | |
| 29 | + | // "\" escapes and how ? and + behave after a wildcard or a bracket are | |
| 30 | + | // reconstructed. | |
| 31 | + | type Pattern struct { | |
| 32 | + | // Negated is set for a pattern that started with "!". | |
| 33 | + | Negated bool | |
| 34 | + | // Source is the pattern as written. | |
| 35 | + | Source string | |
| 36 | + | re *regexp.Regexp | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // CompilePattern compiles one pattern. A pattern that cannot be compiled never | |
| 40 | + | // matches; the error says why. | |
| 41 | + | func CompilePattern(p string) (*Pattern, error) { | |
| 42 | + | pat := &Pattern{Source: p} | |
| 43 | + | body := p | |
| 44 | + | if strings.HasPrefix(body, "!") { | |
| 45 | + | pat.Negated = true | |
| 46 | + | body = body[1:] | |
| 47 | + | } | |
| 48 | + | re, err := regexp.Compile("^(?s:" + translateGlob(body) + ")$") | |
| 49 | + | if err != nil { | |
| 50 | + | return pat, err | |
| 51 | + | } | |
| 52 | + | pat.re = re | |
| 53 | + | return pat, nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Match reports whether name matches the pattern, ignoring any "!" prefix. | |
| 57 | + | func (p *Pattern) Match(name string) bool { return p.re != nil && p.re.MatchString(name) } | |
| 58 | + | ||
| 59 | + | var patternCache sync.Map // string -> *Pattern | |
| 60 | + | ||
| 61 | + | func cachedPattern(p string) *Pattern { | |
| 62 | + | if v, ok := patternCache.Load(p); ok { | |
| 63 | + | return v.(*Pattern) | |
| 64 | + | } | |
| 65 | + | pat, _ := CompilePattern(p) | |
| 66 | + | if n := cacheSize(); n < 4096 { | |
| 67 | + | patternCache.Store(p, pat) | |
| 68 | + | } | |
| 69 | + | return pat | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func cacheSize() int { | |
| 73 | + | n := 0 | |
| 74 | + | patternCache.Range(func(_, _ any) bool { n++; return n < 4096 }) | |
| 75 | + | return n | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // translateGlob converts a pattern to an RE2 expression. | |
| 79 | + | func translateGlob(p string) string { | |
| 80 | + | var b strings.Builder | |
| 81 | + | rs := []rune(p) | |
| 82 | + | // quantifiable is true when the last thing written is a single character | |
| 83 | + | // or a class that ? and + can apply to. | |
| 84 | + | quantifiable := false | |
| 85 | + | for i := 0; i < len(rs); i++ { | |
| 86 | + | c := rs[i] | |
| 87 | + | switch c { | |
| 88 | + | case '\\': | |
| 89 | + | if i+1 < len(rs) { | |
| 90 | + | i++ | |
| 91 | + | b.WriteString(regexp.QuoteMeta(string(rs[i]))) | |
| 92 | + | } else { | |
| 93 | + | b.WriteString(`\\`) | |
| 94 | + | } | |
| 95 | + | quantifiable = true | |
| 96 | + | case '*': | |
| 97 | + | if i+1 < len(rs) && rs[i+1] == '*' { | |
| 98 | + | for i+1 < len(rs) && rs[i+1] == '*' { | |
| 99 | + | i++ | |
| 100 | + | } | |
| 101 | + | atStart := b.Len() == 0 || (i-2 >= 0 && rs[i-2] == '/') | |
| 102 | + | if atStart && i+1 < len(rs) && rs[i+1] == '/' { | |
| 103 | + | i++ | |
| 104 | + | b.WriteString(`(?:.*/)?`) | |
| 105 | + | } else { | |
| 106 | + | b.WriteString(`.*`) | |
| 107 | + | } | |
| 108 | + | } else { | |
| 109 | + | b.WriteString(`[^/]*`) | |
| 110 | + | } | |
| 111 | + | quantifiable = false | |
| 112 | + | case '?', '+': | |
| 113 | + | if quantifiable { | |
| 114 | + | b.WriteRune(c) | |
| 115 | + | quantifiable = false | |
| 116 | + | } else { | |
| 117 | + | b.WriteString(regexp.QuoteMeta(string(c))) | |
| 118 | + | quantifiable = true | |
| 119 | + | } | |
| 120 | + | case '[': | |
| 121 | + | if class, n, ok := parseClass(rs[i:]); ok { | |
| 122 | + | b.WriteString(class) | |
| 123 | + | i += n - 1 | |
| 124 | + | quantifiable = true | |
| 125 | + | } else { | |
| 126 | + | b.WriteString(`\[`) | |
| 127 | + | quantifiable = true | |
| 128 | + | } | |
| 129 | + | default: | |
| 130 | + | b.WriteString(regexp.QuoteMeta(string(c))) | |
| 131 | + | quantifiable = true | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return b.String() | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // parseClass reads "[...]" at the start of rs: letters, digits and ranges of | |
| 138 | + | // them. It returns the RE2 class and the number of runes consumed. | |
| 139 | + | func parseClass(rs []rune) (string, int, bool) { | |
| 140 | + | var b strings.Builder | |
| 141 | + | b.WriteByte('[') | |
| 142 | + | n := 0 | |
| 143 | + | for i := 1; i < len(rs); i++ { | |
| 144 | + | c := rs[i] | |
| 145 | + | switch { | |
| 146 | + | case c == ']': | |
| 147 | + | if n == 0 { | |
| 148 | + | return "", 0, false | |
| 149 | + | } | |
| 150 | + | b.WriteByte(']') | |
| 151 | + | return b.String(), i + 1, true | |
| 152 | + | case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': | |
| 153 | + | b.WriteRune(c) | |
| 154 | + | n++ | |
| 155 | + | case c == '-' && n > 0 && i+1 < len(rs) && rs[i+1] != ']': | |
| 156 | + | b.WriteByte('-') | |
| 157 | + | default: | |
| 158 | + | return "", 0, false | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return "", 0, false | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // matchList applies a list of patterns to name in order. A pattern that | |
| 165 | + | // matches includes the name, a "!" pattern that matches excludes it again, and | |
| 166 | + | // the last pattern that matches decides. A list with no positive pattern | |
| 167 | + | // therefore matches nothing. | |
| 168 | + | func matchList(patterns []string, name string) bool { | |
| 169 | + | matched := false | |
| 170 | + | for _, s := range patterns { | |
| 171 | + | if p := cachedPattern(s); p.Match(name) { | |
| 172 | + | matched = !p.Negated | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | return matched | |
| 176 | + | } | |
| @@ -0,0 +1,176 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "regexp" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Pattern is a compiled filter pattern in the syntax GitHub documents as the | |||
| 12 | + | // "filter pattern cheat sheet" for branches, tags and paths: | |||
| 13 | + | // | |||
| 14 | + | // - zero or more characters, but not / | |||
| 15 | + | // ** zero or more of any character; "**/" at the start of a pattern or | |||
| 16 | + | // after a "/" may also match nothing, so "**/README.md" matches | |||
| 17 | + | // "README.md" and "docs/**/*.md" matches "docs/a.md" | |||
| 18 | + | // ? zero or one of the preceding character | |||
| 19 | + | // - one or more of the preceding character | |||
| 20 | + | // [abc] one character listed or in a range (a-z, A-Z, 0-9) | |||
| 21 | + | // ! at the start of a pattern: negates the patterns before it | |||
| 22 | + | // \ escapes the next character | |||
| 23 | + | // | |||
| 24 | + | // Every other character, "." included, stands for itself. Matching is | |||
| 25 | + | // case-sensitive and covers the whole name. | |||
| 26 | + | // | |||
| 27 | + | // TODO(verify with recording): the exact GitHub implementation is not | |||
| 28 | + | // public. The cheat sheet fixes the common cases; "**/" matching nothing, | |||
| 29 | + | // "\" escapes and how ? and + behave after a wildcard or a bracket are | |||
| 30 | + | // reconstructed. | |||
| 31 | + | type Pattern struct { | |||
| 32 | + | // Negated is set for a pattern that started with "!". | |||
| 33 | + | Negated bool | |||
| 34 | + | // Source is the pattern as written. | |||
| 35 | + | Source string | |||
| 36 | + | re *regexp.Regexp | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // CompilePattern compiles one pattern. A pattern that cannot be compiled never | |||
| 40 | + | // matches; the error says why. | |||
| 41 | + | func CompilePattern(p string) (*Pattern, error) { | |||
| 42 | + | pat := &Pattern{Source: p} | |||
| 43 | + | body := p | |||
| 44 | + | if strings.HasPrefix(body, "!") { | |||
| 45 | + | pat.Negated = true | |||
| 46 | + | body = body[1:] | |||
| 47 | + | } | |||
| 48 | + | re, err := regexp.Compile("^(?s:" + translateGlob(body) + ")$") | |||
| 49 | + | if err != nil { | |||
| 50 | + | return pat, err | |||
| 51 | + | } | |||
| 52 | + | pat.re = re | |||
| 53 | + | return pat, nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Match reports whether name matches the pattern, ignoring any "!" prefix. | |||
| 57 | + | func (p *Pattern) Match(name string) bool { return p.re != nil && p.re.MatchString(name) } | |||
| 58 | + | ||||
| 59 | + | var patternCache sync.Map // string -> *Pattern | |||
| 60 | + | ||||
| 61 | + | func cachedPattern(p string) *Pattern { | |||
| 62 | + | if v, ok := patternCache.Load(p); ok { | |||
| 63 | + | return v.(*Pattern) | |||
| 64 | + | } | |||
| 65 | + | pat, _ := CompilePattern(p) | |||
| 66 | + | if n := cacheSize(); n < 4096 { | |||
| 67 | + | patternCache.Store(p, pat) | |||
| 68 | + | } | |||
| 69 | + | return pat | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func cacheSize() int { | |||
| 73 | + | n := 0 | |||
| 74 | + | patternCache.Range(func(_, _ any) bool { n++; return n < 4096 }) | |||
| 75 | + | return n | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // translateGlob converts a pattern to an RE2 expression. | |||
| 79 | + | func translateGlob(p string) string { | |||
| 80 | + | var b strings.Builder | |||
| 81 | + | rs := []rune(p) | |||
| 82 | + | // quantifiable is true when the last thing written is a single character | |||
| 83 | + | // or a class that ? and + can apply to. | |||
| 84 | + | quantifiable := false | |||
| 85 | + | for i := 0; i < len(rs); i++ { | |||
| 86 | + | c := rs[i] | |||
| 87 | + | switch c { | |||
| 88 | + | case '\\': | |||
| 89 | + | if i+1 < len(rs) { | |||
| 90 | + | i++ | |||
| 91 | + | b.WriteString(regexp.QuoteMeta(string(rs[i]))) | |||
| 92 | + | } else { | |||
| 93 | + | b.WriteString(`\\`) | |||
| 94 | + | } | |||
| 95 | + | quantifiable = true | |||
| 96 | + | case '*': | |||
| 97 | + | if i+1 < len(rs) && rs[i+1] == '*' { | |||
| 98 | + | for i+1 < len(rs) && rs[i+1] == '*' { | |||
| 99 | + | i++ | |||
| 100 | + | } | |||
| 101 | + | atStart := b.Len() == 0 || (i-2 >= 0 && rs[i-2] == '/') | |||
| 102 | + | if atStart && i+1 < len(rs) && rs[i+1] == '/' { | |||
| 103 | + | i++ | |||
| 104 | + | b.WriteString(`(?:.*/)?`) | |||
| 105 | + | } else { | |||
| 106 | + | b.WriteString(`.*`) | |||
| 107 | + | } | |||
| 108 | + | } else { | |||
| 109 | + | b.WriteString(`[^/]*`) | |||
| 110 | + | } | |||
| 111 | + | quantifiable = false | |||
| 112 | + | case '?', '+': | |||
| 113 | + | if quantifiable { | |||
| 114 | + | b.WriteRune(c) | |||
| 115 | + | quantifiable = false | |||
| 116 | + | } else { | |||
| 117 | + | b.WriteString(regexp.QuoteMeta(string(c))) | |||
| 118 | + | quantifiable = true | |||
| 119 | + | } | |||
| 120 | + | case '[': | |||
| 121 | + | if class, n, ok := parseClass(rs[i:]); ok { | |||
| 122 | + | b.WriteString(class) | |||
| 123 | + | i += n - 1 | |||
| 124 | + | quantifiable = true | |||
| 125 | + | } else { | |||
| 126 | + | b.WriteString(`\[`) | |||
| 127 | + | quantifiable = true | |||
| 128 | + | } | |||
| 129 | + | default: | |||
| 130 | + | b.WriteString(regexp.QuoteMeta(string(c))) | |||
| 131 | + | quantifiable = true | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | return b.String() | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // parseClass reads "[...]" at the start of rs: letters, digits and ranges of | |||
| 138 | + | // them. It returns the RE2 class and the number of runes consumed. | |||
| 139 | + | func parseClass(rs []rune) (string, int, bool) { | |||
| 140 | + | var b strings.Builder | |||
| 141 | + | b.WriteByte('[') | |||
| 142 | + | n := 0 | |||
| 143 | + | for i := 1; i < len(rs); i++ { | |||
| 144 | + | c := rs[i] | |||
| 145 | + | switch { | |||
| 146 | + | case c == ']': | |||
| 147 | + | if n == 0 { | |||
| 148 | + | return "", 0, false | |||
| 149 | + | } | |||
| 150 | + | b.WriteByte(']') | |||
| 151 | + | return b.String(), i + 1, true | |||
| 152 | + | case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': | |||
| 153 | + | b.WriteRune(c) | |||
| 154 | + | n++ | |||
| 155 | + | case c == '-' && n > 0 && i+1 < len(rs) && rs[i+1] != ']': | |||
| 156 | + | b.WriteByte('-') | |||
| 157 | + | default: | |||
| 158 | + | return "", 0, false | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | return "", 0, false | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // matchList applies a list of patterns to name in order. A pattern that | |||
| 165 | + | // matches includes the name, a "!" pattern that matches excludes it again, and | |||
| 166 | + | // the last pattern that matches decides. A list with no positive pattern | |||
| 167 | + | // therefore matches nothing. | |||
| 168 | + | func matchList(patterns []string, name string) bool { | |||
| 169 | + | matched := false | |||
| 170 | + | for _, s := range patterns { | |||
| 171 | + | if p := cachedPattern(s); p.Match(name) { | |||
| 172 | + | matched = !p.Negated | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | return matched | |||
| 176 | + | } | |||
A workflow/glob_test.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import "testing" | |
| 6 | + | ||
| 7 | + | func TestPatternMatch(t *testing.T) { | |
| 8 | + | // Cases from GitHub's "Filter pattern cheat sheet" and the examples | |
| 9 | + | // around it. | |
| 10 | + | tests := []struct { | |
| 11 | + | pattern, name string | |
| 12 | + | want bool | |
| 13 | + | }{ | |
| 14 | + | // feature/*: the * matches any character except /. | |
| 15 | + | {"feature/*", "feature/my-branch", true}, | |
| 16 | + | {"feature/*", "feature/your-branch", true}, | |
| 17 | + | {"feature/*", "feature", false}, | |
| 18 | + | {"feature/*", "feature/a/b", false}, | |
| 19 | + | // feature/**: matches any branch with feature/ and slashes. | |
| 20 | + | {"feature/**", "feature/beta-a/my-branch", true}, | |
| 21 | + | {"feature/**", "feature/your-branch", true}, | |
| 22 | + | {"feature/**", "feature/mona/the/octocat", true}, | |
| 23 | + | // main, releases/mona-the-octocat: exact. | |
| 24 | + | {"main", "main", true}, | |
| 25 | + | {"main", "main2", false}, | |
| 26 | + | {"main", "xmain", false}, | |
| 27 | + | {"releases/mona-the-octocat", "releases/mona-the-octocat", true}, | |
| 28 | + | // '*': all names that do not contain a slash. | |
| 29 | + | {"*", "main", true}, | |
| 30 | + | {"*", "releases", true}, | |
| 31 | + | {"*", "releases/10", false}, | |
| 32 | + | // '**': all names. | |
| 33 | + | {"**", "all/the/branches", true}, | |
| 34 | + | {"**", "every/tag", true}, | |
| 35 | + | {"**", "", true}, | |
| 36 | + | // '*feature': * is a special character in YAML, quoted here. | |
| 37 | + | {"*feature", "mona-feature", true}, | |
| 38 | + | {"*feature", "feature", true}, | |
| 39 | + | {"*feature", "ver-10-feature", true}, | |
| 40 | + | {"*feature", "a/feature", false}, | |
| 41 | + | // v2* | |
| 42 | + | {"v2*", "v2", true}, | |
| 43 | + | {"v2*", "v2.0", true}, | |
| 44 | + | {"v2*", "v2.9", true}, | |
| 45 | + | {"v2*", "v2/x", false}, | |
| 46 | + | // v[12].[0-9]+.[0-9]+ | |
| 47 | + | {"v[12].[0-9]+.[0-9]+", "v1.10.1", true}, | |
| 48 | + | {"v[12].[0-9]+.[0-9]+", "v2.0.0", true}, | |
| 49 | + | {"v[12].[0-9]+.[0-9]+", "v3.0.0", false}, | |
| 50 | + | {"v[12].[0-9]+.[0-9]+", "v1.x.1", false}, | |
| 51 | + | // releases/**-alpha | |
| 52 | + | {"releases/**-alpha", "releases/beta/3-alpha", true}, | |
| 53 | + | {"releases/**-alpha", "releases/v10/3-alpha", true}, | |
| 54 | + | {"releases/**-alpha", "releases/3-alpha", true}, | |
| 55 | + | {"releases/**-alpha", "releases/3-beta", false}, | |
| 56 | + | // ? : zero or one of the preceding character. | |
| 57 | + | {"colou?r", "color", true}, | |
| 58 | + | {"colou?r", "colour", true}, | |
| 59 | + | {"colou?r", "colouur", false}, | |
| 60 | + | // + : one or more. | |
| 61 | + | {"a+b", "ab", true}, | |
| 62 | + | {"a+b", "aaab", true}, | |
| 63 | + | {"a+b", "b", false}, | |
| 64 | + | // paths | |
| 65 | + | {"**.js", "app.js", true}, | |
| 66 | + | {"**.js", "src/app.js", true}, | |
| 67 | + | {"**.js", "src/app.ts", false}, | |
| 68 | + | {"*.js", "app.js", true}, | |
| 69 | + | {"*.js", "src/app.js", false}, | |
| 70 | + | {"docs/**", "docs/readme.md", true}, | |
| 71 | + | {"docs/**", "docs/a/b/c.md", true}, | |
| 72 | + | {"docs/**", "docs", false}, | |
| 73 | + | {"docs/**/*.md", "docs/a.md", true}, | |
| 74 | + | {"docs/**/*.md", "docs/x/y/a.md", true}, | |
| 75 | + | {"docs/**/*.md", "docs/a.txt", false}, | |
| 76 | + | {"**/README.md", "README.md", true}, | |
| 77 | + | {"**/README.md", "a/b/README.md", true}, | |
| 78 | + | {"**/README.md", "a/XREADME.md", false}, | |
| 79 | + | {"sub-project/**", "sub-project/src/main.go", true}, | |
| 80 | + | {"/README.md", "README.md", false}, | |
| 81 | + | // Special characters stand for themselves. | |
| 82 | + | {"a.b", "a.b", true}, | |
| 83 | + | {"a.b", "axb", false}, | |
| 84 | + | {"a(b)", "a(b)", true}, | |
| 85 | + | {`a\*b`, "a*b", true}, | |
| 86 | + | {`a\*b`, "axb", false}, | |
| 87 | + | // Case-sensitive. | |
| 88 | + | {"Main", "main", false}, | |
| 89 | + | } | |
| 90 | + | for _, tc := range tests { | |
| 91 | + | p, err := CompilePattern(tc.pattern) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Errorf("%q: %v", tc.pattern, err) | |
| 94 | + | continue | |
| 95 | + | } | |
| 96 | + | if got := p.Match(tc.name); got != tc.want { | |
| 97 | + | t.Errorf("Match(%q, %q) = %v, want %v", tc.pattern, tc.name, got, tc.want) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestMatchListOrder(t *testing.T) { | |
| 103 | + | // "The order that you define patterns matters": a later negative pattern | |
| 104 | + | // excludes, a later positive one includes again. | |
| 105 | + | tests := []struct { | |
| 106 | + | patterns []string | |
| 107 | + | name string | |
| 108 | + | want bool | |
| 109 | + | }{ | |
| 110 | + | {[]string{"releases/**", "!releases/**-alpha"}, "releases/v1", true}, | |
| 111 | + | {[]string{"releases/**", "!releases/**-alpha"}, "releases/v1-alpha", false}, | |
| 112 | + | {[]string{"releases/**", "!releases/**-alpha", "releases/**/ga"}, "releases/v1-alpha/ga", true}, | |
| 113 | + | {[]string{"!releases/**-alpha", "releases/**"}, "releases/v1-alpha", true}, | |
| 114 | + | {[]string{"!docs/**"}, "src/a.go", false}, // only negatives: matches nothing | |
| 115 | + | {[]string{"**", "!docs/**"}, "src/a.go", true}, | |
| 116 | + | {[]string{"**", "!docs/**"}, "docs/a.md", false}, | |
| 117 | + | {nil, "x", false}, | |
| 118 | + | } | |
| 119 | + | for _, tc := range tests { | |
| 120 | + | if got := matchList(tc.patterns, tc.name); got != tc.want { | |
| 121 | + | t.Errorf("matchList(%q, %q) = %v, want %v", tc.patterns, tc.name, got, tc.want) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestBrokenPatternNeverMatches(t *testing.T) { | |
| 127 | + | for _, p := range []string{"[", "[]", "[a-", "a[!b]", "\\"} { | |
| 128 | + | pat, err := CompilePattern(p) | |
| 129 | + | if err != nil { | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | _ = pat.Match("x") | |
| 133 | + | } | |
| 134 | + | if matchList([]string{"[abc"}, "[abc") != true { | |
| 135 | + | // An unclosed bracket is a literal. | |
| 136 | + | t.Error("unclosed bracket should be literal") | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func FuzzPattern(f *testing.F) { | |
| 141 | + | for _, s := range []string{"*", "**", "a/**/b", "[a-z]+", "!x", `\`, "?+", "[", "]]"} { | |
| 142 | + | f.Add(s, "a/b") | |
| 143 | + | } | |
| 144 | + | f.Fuzz(func(t *testing.T, pattern, name string) { | |
| 145 | + | if p, err := CompilePattern(pattern); err == nil { | |
| 146 | + | p.Match(name) | |
| 147 | + | } | |
| 148 | + | }) | |
| 149 | + | } | |
| @@ -0,0 +1,149 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import "testing" | |||
| 6 | + | ||||
| 7 | + | func TestPatternMatch(t *testing.T) { | |||
| 8 | + | // Cases from GitHub's "Filter pattern cheat sheet" and the examples | |||
| 9 | + | // around it. | |||
| 10 | + | tests := []struct { | |||
| 11 | + | pattern, name string | |||
| 12 | + | want bool | |||
| 13 | + | }{ | |||
| 14 | + | // feature/*: the * matches any character except /. | |||
| 15 | + | {"feature/*", "feature/my-branch", true}, | |||
| 16 | + | {"feature/*", "feature/your-branch", true}, | |||
| 17 | + | {"feature/*", "feature", false}, | |||
| 18 | + | {"feature/*", "feature/a/b", false}, | |||
| 19 | + | // feature/**: matches any branch with feature/ and slashes. | |||
| 20 | + | {"feature/**", "feature/beta-a/my-branch", true}, | |||
| 21 | + | {"feature/**", "feature/your-branch", true}, | |||
| 22 | + | {"feature/**", "feature/mona/the/octocat", true}, | |||
| 23 | + | // main, releases/mona-the-octocat: exact. | |||
| 24 | + | {"main", "main", true}, | |||
| 25 | + | {"main", "main2", false}, | |||
| 26 | + | {"main", "xmain", false}, | |||
| 27 | + | {"releases/mona-the-octocat", "releases/mona-the-octocat", true}, | |||
| 28 | + | // '*': all names that do not contain a slash. | |||
| 29 | + | {"*", "main", true}, | |||
| 30 | + | {"*", "releases", true}, | |||
| 31 | + | {"*", "releases/10", false}, | |||
| 32 | + | // '**': all names. | |||
| 33 | + | {"**", "all/the/branches", true}, | |||
| 34 | + | {"**", "every/tag", true}, | |||
| 35 | + | {"**", "", true}, | |||
| 36 | + | // '*feature': * is a special character in YAML, quoted here. | |||
| 37 | + | {"*feature", "mona-feature", true}, | |||
| 38 | + | {"*feature", "feature", true}, | |||
| 39 | + | {"*feature", "ver-10-feature", true}, | |||
| 40 | + | {"*feature", "a/feature", false}, | |||
| 41 | + | // v2* | |||
| 42 | + | {"v2*", "v2", true}, | |||
| 43 | + | {"v2*", "v2.0", true}, | |||
| 44 | + | {"v2*", "v2.9", true}, | |||
| 45 | + | {"v2*", "v2/x", false}, | |||
| 46 | + | // v[12].[0-9]+.[0-9]+ | |||
| 47 | + | {"v[12].[0-9]+.[0-9]+", "v1.10.1", true}, | |||
| 48 | + | {"v[12].[0-9]+.[0-9]+", "v2.0.0", true}, | |||
| 49 | + | {"v[12].[0-9]+.[0-9]+", "v3.0.0", false}, | |||
| 50 | + | {"v[12].[0-9]+.[0-9]+", "v1.x.1", false}, | |||
| 51 | + | // releases/**-alpha | |||
| 52 | + | {"releases/**-alpha", "releases/beta/3-alpha", true}, | |||
| 53 | + | {"releases/**-alpha", "releases/v10/3-alpha", true}, | |||
| 54 | + | {"releases/**-alpha", "releases/3-alpha", true}, | |||
| 55 | + | {"releases/**-alpha", "releases/3-beta", false}, | |||
| 56 | + | // ? : zero or one of the preceding character. | |||
| 57 | + | {"colou?r", "color", true}, | |||
| 58 | + | {"colou?r", "colour", true}, | |||
| 59 | + | {"colou?r", "colouur", false}, | |||
| 60 | + | // + : one or more. | |||
| 61 | + | {"a+b", "ab", true}, | |||
| 62 | + | {"a+b", "aaab", true}, | |||
| 63 | + | {"a+b", "b", false}, | |||
| 64 | + | // paths | |||
| 65 | + | {"**.js", "app.js", true}, | |||
| 66 | + | {"**.js", "src/app.js", true}, | |||
| 67 | + | {"**.js", "src/app.ts", false}, | |||
| 68 | + | {"*.js", "app.js", true}, | |||
| 69 | + | {"*.js", "src/app.js", false}, | |||
| 70 | + | {"docs/**", "docs/readme.md", true}, | |||
| 71 | + | {"docs/**", "docs/a/b/c.md", true}, | |||
| 72 | + | {"docs/**", "docs", false}, | |||
| 73 | + | {"docs/**/*.md", "docs/a.md", true}, | |||
| 74 | + | {"docs/**/*.md", "docs/x/y/a.md", true}, | |||
| 75 | + | {"docs/**/*.md", "docs/a.txt", false}, | |||
| 76 | + | {"**/README.md", "README.md", true}, | |||
| 77 | + | {"**/README.md", "a/b/README.md", true}, | |||
| 78 | + | {"**/README.md", "a/XREADME.md", false}, | |||
| 79 | + | {"sub-project/**", "sub-project/src/main.go", true}, | |||
| 80 | + | {"/README.md", "README.md", false}, | |||
| 81 | + | // Special characters stand for themselves. | |||
| 82 | + | {"a.b", "a.b", true}, | |||
| 83 | + | {"a.b", "axb", false}, | |||
| 84 | + | {"a(b)", "a(b)", true}, | |||
| 85 | + | {`a\*b`, "a*b", true}, | |||
| 86 | + | {`a\*b`, "axb", false}, | |||
| 87 | + | // Case-sensitive. | |||
| 88 | + | {"Main", "main", false}, | |||
| 89 | + | } | |||
| 90 | + | for _, tc := range tests { | |||
| 91 | + | p, err := CompilePattern(tc.pattern) | |||
| 92 | + | if err != nil { | |||
| 93 | + | t.Errorf("%q: %v", tc.pattern, err) | |||
| 94 | + | continue | |||
| 95 | + | } | |||
| 96 | + | if got := p.Match(tc.name); got != tc.want { | |||
| 97 | + | t.Errorf("Match(%q, %q) = %v, want %v", tc.pattern, tc.name, got, tc.want) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestMatchListOrder(t *testing.T) { | |||
| 103 | + | // "The order that you define patterns matters": a later negative pattern | |||
| 104 | + | // excludes, a later positive one includes again. | |||
| 105 | + | tests := []struct { | |||
| 106 | + | patterns []string | |||
| 107 | + | name string | |||
| 108 | + | want bool | |||
| 109 | + | }{ | |||
| 110 | + | {[]string{"releases/**", "!releases/**-alpha"}, "releases/v1", true}, | |||
| 111 | + | {[]string{"releases/**", "!releases/**-alpha"}, "releases/v1-alpha", false}, | |||
| 112 | + | {[]string{"releases/**", "!releases/**-alpha", "releases/**/ga"}, "releases/v1-alpha/ga", true}, | |||
| 113 | + | {[]string{"!releases/**-alpha", "releases/**"}, "releases/v1-alpha", true}, | |||
| 114 | + | {[]string{"!docs/**"}, "src/a.go", false}, // only negatives: matches nothing | |||
| 115 | + | {[]string{"**", "!docs/**"}, "src/a.go", true}, | |||
| 116 | + | {[]string{"**", "!docs/**"}, "docs/a.md", false}, | |||
| 117 | + | {nil, "x", false}, | |||
| 118 | + | } | |||
| 119 | + | for _, tc := range tests { | |||
| 120 | + | if got := matchList(tc.patterns, tc.name); got != tc.want { | |||
| 121 | + | t.Errorf("matchList(%q, %q) = %v, want %v", tc.patterns, tc.name, got, tc.want) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestBrokenPatternNeverMatches(t *testing.T) { | |||
| 127 | + | for _, p := range []string{"[", "[]", "[a-", "a[!b]", "\\"} { | |||
| 128 | + | pat, err := CompilePattern(p) | |||
| 129 | + | if err != nil { | |||
| 130 | + | continue | |||
| 131 | + | } | |||
| 132 | + | _ = pat.Match("x") | |||
| 133 | + | } | |||
| 134 | + | if matchList([]string{"[abc"}, "[abc") != true { | |||
| 135 | + | // An unclosed bracket is a literal. | |||
| 136 | + | t.Error("unclosed bracket should be literal") | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func FuzzPattern(f *testing.F) { | |||
| 141 | + | for _, s := range []string{"*", "**", "a/**/b", "[a-z]+", "!x", `\`, "?+", "[", "]]"} { | |||
| 142 | + | f.Add(s, "a/b") | |||
| 143 | + | } | |||
| 144 | + | f.Fuzz(func(t *testing.T, pattern, name string) { | |||
| 145 | + | if p, err := CompilePattern(pattern); err == nil { | |||
| 146 | + | p.Match(name) | |||
| 147 | + | } | |||
| 148 | + | }) | |||
| 149 | + | } | |||
M workflow/parse.go +4 -1
| @@ -17,7 +17,9 @@ const rootType = "workflow-root-strict" | |||
| 17 | 17 | // contexts and functions in expressions, and the checks of the converter (job | |
| 18 | 18 | // and step ids, needs, filters, inputs). When there is any error the workflow | |
| 19 | 19 | // is nil. As in the reference, the converter only runs when the file passes | |
| 20 | - | // the schema. | |
| 20 | + | // the schema. One check is not the reference's: an event that uses both a | |
| 21 | + | // filter and its "-ignore" counterpart (branches and branches-ignore, tags and | |
| 22 | + | // tags-ignore, paths and paths-ignore) is an error, as on GitHub. | |
| 21 | 23 | func Parse(path string, content []byte) (*Workflow, []Error) { | |
| 22 | 24 | schema, err := template.WorkflowSchema() | |
| 23 | 25 | if err != nil { | |
| @@ -29,6 +31,7 @@ func Parse(path string, content []byte) (*Workflow, []Error) { | |||
| 29 | 31 | if root != nil && !ctx.HasErrors() { | |
| 30 | 32 | c := &converter{ctx: ctx, schema: schema, w: w, jobIDs: newIDBuilder()} | |
| 31 | 33 | c.convertRoot(root) | |
| 34 | + | c.validateFilters() | |
| 32 | 35 | } | |
| 33 | 36 | errs := collect(ctx) | |
| 34 | 37 | if len(errs) > 0 { | |
| @@ -17,7 +17,9 @@ const rootType = "workflow-root-strict" | |||||
| 17 | // contexts and functions in expressions, and the checks of the converter (job | 17 | // contexts and functions in expressions, and the checks of the converter (job | ||
| 18 | // and step ids, needs, filters, inputs). When there is any error the workflow | 18 | // and step ids, needs, filters, inputs). When there is any error the workflow | ||
| 19 | // is nil. As in the reference, the converter only runs when the file passes | 19 | // is nil. As in the reference, the converter only runs when the file passes | ||
| 20 | - | // the schema. | 20 | + | // the schema. One check is not the reference's: an event that uses both a |
| 21 | + | // filter and its "-ignore" counterpart (branches and branches-ignore, tags and | |||
| 22 | + | // tags-ignore, paths and paths-ignore) is an error, as on GitHub. | |||
| 21 | func Parse(path string, content []byte) (*Workflow, []Error) { | 23 | func Parse(path string, content []byte) (*Workflow, []Error) { | ||
| 22 | schema, err := template.WorkflowSchema() | 24 | schema, err := template.WorkflowSchema() | ||
| 23 | if err != nil { | 25 | if err != nil { | ||
| @@ -29,6 +31,7 @@ func Parse(path string, content []byte) (*Workflow, []Error) { | |||||
| 29 | if root != nil && !ctx.HasErrors() { | 31 | if root != nil && !ctx.HasErrors() { | ||
| 30 | c := &converter{ctx: ctx, schema: schema, w: w, jobIDs: newIDBuilder()} | 32 | c := &converter{ctx: ctx, schema: schema, w: w, jobIDs: newIDBuilder()} | ||
| 31 | c.convertRoot(root) | 33 | c.convertRoot(root) | ||
| 34 | + | c.validateFilters() | |||
| 32 | } | 35 | } | ||
| 33 | errs := collect(ctx) | 36 | errs := collect(ctx) | ||
| 34 | if len(errs) > 0 { | 37 | if len(errs) > 0 { | ||
A workflow/push.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import "strings" | |
| 6 | + | ||
| 7 | + | // Limits GitHub applies to push events. | |
| 8 | + | const ( | |
| 9 | + | // MaxFilesEvaluated is the number of changed files a paths filter looks | |
| 10 | + | // at; files after the 300th are ignored. | |
| 11 | + | MaxFilesEvaluated = 300 | |
| 12 | + | // MaxTagsPerPush is the number of tags that can be pushed at once before | |
| 13 | + | // GitHub creates no event for them. | |
| 14 | + | MaxTagsPerPush = 3 | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // PushInfo describes a push to one ref. | |
| 18 | + | type PushInfo struct { | |
| 19 | + | // Ref is the full name, "refs/heads/main" or "refs/tags/v1". | |
| 20 | + | Ref string | |
| 21 | + | // Created and Deleted say whether the push created or deleted the ref. | |
| 22 | + | Created bool | |
| 23 | + | Deleted bool | |
| 24 | + | // ChangedFiles are the paths changed by the push, in the order of the | |
| 25 | + | // diff. Only the first MaxFilesEvaluated are looked at. | |
| 26 | + | ChangedFiles []string | |
| 27 | + | // FilesTruncated is set when the list of changed files is not available: | |
| 28 | + | // the push has more than 1000 commits, or the diff could not be | |
| 29 | + | // computed. A paths filter is then considered satisfied. | |
| 30 | + | FilesTruncated bool | |
| 31 | + | // TagCount is the number of tags pushed in the same push. | |
| 32 | + | TagCount int | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // MatchPush reports whether a push triggers the workflow, applying the | |
| 36 | + | // "push" event's filters like GitHub: | |
| 37 | + | // | |
| 38 | + | // - the deletion of a ref triggers nothing; | |
| 39 | + | // - a push of more than three tags creates no event for its tags; | |
| 40 | + | // - a workflow with only branches or branches-ignore does not run for a tag, | |
| 41 | + | // and one with only tags or tags-ignore does not run for a branch; with | |
| 42 | + | // neither it runs for both; | |
| 43 | + | // - when both a branches and a tags filter exist, a ref is checked against | |
| 44 | + | // the filter for its kind; | |
| 45 | + | // - paths and paths-ignore apply to branch pushes only, against the first | |
| 46 | + | // 300 changed files; when the list is truncated they pass; | |
| 47 | + | // - patterns are evaluated in order and the last one that matches wins. | |
| 48 | + | // | |
| 49 | + | // Commit-message directives are separate: see [SkipCI]. | |
| 50 | + | // | |
| 51 | + | // TODO(verify with recording): the order in which a branch filter and a | |
| 52 | + | // paths filter combine is "both must pass" (documented); behaviour on refs | |
| 53 | + | // that are neither a branch nor a tag, and on a push that changes no file, is | |
| 54 | + | // assumed. | |
| 55 | + | func (w *Workflow) MatchPush(p PushInfo) bool { | |
| 56 | + | if w == nil || w.On == nil { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | ev := w.On.Event("push") | |
| 60 | + | if ev == nil { | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | if p.Deleted { | |
| 64 | + | return false | |
| 65 | + | } | |
| 66 | + | switch { | |
| 67 | + | case strings.HasPrefix(p.Ref, "refs/heads/"): | |
| 68 | + | name := strings.TrimPrefix(p.Ref, "refs/heads/") | |
| 69 | + | if !ev.refFilterAllows(true, name) { | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | return ev.pathsAllow(p) | |
| 73 | + | case strings.HasPrefix(p.Ref, "refs/tags/"): | |
| 74 | + | if p.TagCount > MaxTagsPerPush { | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | return ev.refFilterAllows(false, strings.TrimPrefix(p.Ref, "refs/tags/")) | |
| 78 | + | } | |
| 79 | + | return false | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // refFilterAllows applies the branches or tags filters to the name of a | |
| 83 | + | // branch (branch is true) or a tag. | |
| 84 | + | func (e *Event) refFilterAllows(branch bool, name string) bool { | |
| 85 | + | var inc, exc, otherInc, otherExc []string | |
| 86 | + | if branch { | |
| 87 | + | inc, exc, otherInc, otherExc = e.Branches, e.BranchesIgnore, e.Tags, e.TagsIgnore | |
| 88 | + | } else { | |
| 89 | + | inc, exc, otherInc, otherExc = e.Tags, e.TagsIgnore, e.Branches, e.BranchesIgnore | |
| 90 | + | } | |
| 91 | + | hasOwn := inc != nil || exc != nil | |
| 92 | + | hasOther := otherInc != nil || otherExc != nil | |
| 93 | + | if !hasOwn && hasOther { | |
| 94 | + | return false | |
| 95 | + | } | |
| 96 | + | if inc != nil && !matchList(inc, name) { | |
| 97 | + | return false | |
| 98 | + | } | |
| 99 | + | if exc != nil && matchList(exc, name) { | |
| 100 | + | return false | |
| 101 | + | } | |
| 102 | + | return true | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // pathsAllow applies paths and paths-ignore to a branch push. | |
| 106 | + | func (e *Event) pathsAllow(p PushInfo) bool { | |
| 107 | + | if e.Paths == nil && e.PathsIgnore == nil { | |
| 108 | + | return true | |
| 109 | + | } | |
| 110 | + | if p.FilesTruncated { | |
| 111 | + | return true | |
| 112 | + | } | |
| 113 | + | files := p.ChangedFiles | |
| 114 | + | if len(files) > MaxFilesEvaluated { | |
| 115 | + | files = files[:MaxFilesEvaluated] | |
| 116 | + | } | |
| 117 | + | if e.Paths != nil { | |
| 118 | + | any := false | |
| 119 | + | for _, f := range files { | |
| 120 | + | if matchList(e.Paths, f) { | |
| 121 | + | any = true | |
| 122 | + | break | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | if !any { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if e.PathsIgnore != nil && len(files) > 0 { | |
| 130 | + | any := false | |
| 131 | + | for _, f := range files { | |
| 132 | + | if !matchList(e.PathsIgnore, f) { | |
| 133 | + | any = true | |
| 134 | + | break | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if !any { | |
| 138 | + | return false | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | return true | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import "strings" | |||
| 6 | + | ||||
| 7 | + | // Limits GitHub applies to push events. | |||
| 8 | + | const ( | |||
| 9 | + | // MaxFilesEvaluated is the number of changed files a paths filter looks | |||
| 10 | + | // at; files after the 300th are ignored. | |||
| 11 | + | MaxFilesEvaluated = 300 | |||
| 12 | + | // MaxTagsPerPush is the number of tags that can be pushed at once before | |||
| 13 | + | // GitHub creates no event for them. | |||
| 14 | + | MaxTagsPerPush = 3 | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // PushInfo describes a push to one ref. | |||
| 18 | + | type PushInfo struct { | |||
| 19 | + | // Ref is the full name, "refs/heads/main" or "refs/tags/v1". | |||
| 20 | + | Ref string | |||
| 21 | + | // Created and Deleted say whether the push created or deleted the ref. | |||
| 22 | + | Created bool | |||
| 23 | + | Deleted bool | |||
| 24 | + | // ChangedFiles are the paths changed by the push, in the order of the | |||
| 25 | + | // diff. Only the first MaxFilesEvaluated are looked at. | |||
| 26 | + | ChangedFiles []string | |||
| 27 | + | // FilesTruncated is set when the list of changed files is not available: | |||
| 28 | + | // the push has more than 1000 commits, or the diff could not be | |||
| 29 | + | // computed. A paths filter is then considered satisfied. | |||
| 30 | + | FilesTruncated bool | |||
| 31 | + | // TagCount is the number of tags pushed in the same push. | |||
| 32 | + | TagCount int | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // MatchPush reports whether a push triggers the workflow, applying the | |||
| 36 | + | // "push" event's filters like GitHub: | |||
| 37 | + | // | |||
| 38 | + | // - the deletion of a ref triggers nothing; | |||
| 39 | + | // - a push of more than three tags creates no event for its tags; | |||
| 40 | + | // - a workflow with only branches or branches-ignore does not run for a tag, | |||
| 41 | + | // and one with only tags or tags-ignore does not run for a branch; with | |||
| 42 | + | // neither it runs for both; | |||
| 43 | + | // - when both a branches and a tags filter exist, a ref is checked against | |||
| 44 | + | // the filter for its kind; | |||
| 45 | + | // - paths and paths-ignore apply to branch pushes only, against the first | |||
| 46 | + | // 300 changed files; when the list is truncated they pass; | |||
| 47 | + | // - patterns are evaluated in order and the last one that matches wins. | |||
| 48 | + | // | |||
| 49 | + | // Commit-message directives are separate: see [SkipCI]. | |||
| 50 | + | // | |||
| 51 | + | // TODO(verify with recording): the order in which a branch filter and a | |||
| 52 | + | // paths filter combine is "both must pass" (documented); behaviour on refs | |||
| 53 | + | // that are neither a branch nor a tag, and on a push that changes no file, is | |||
| 54 | + | // assumed. | |||
| 55 | + | func (w *Workflow) MatchPush(p PushInfo) bool { | |||
| 56 | + | if w == nil || w.On == nil { | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | ev := w.On.Event("push") | |||
| 60 | + | if ev == nil { | |||
| 61 | + | return false | |||
| 62 | + | } | |||
| 63 | + | if p.Deleted { | |||
| 64 | + | return false | |||
| 65 | + | } | |||
| 66 | + | switch { | |||
| 67 | + | case strings.HasPrefix(p.Ref, "refs/heads/"): | |||
| 68 | + | name := strings.TrimPrefix(p.Ref, "refs/heads/") | |||
| 69 | + | if !ev.refFilterAllows(true, name) { | |||
| 70 | + | return false | |||
| 71 | + | } | |||
| 72 | + | return ev.pathsAllow(p) | |||
| 73 | + | case strings.HasPrefix(p.Ref, "refs/tags/"): | |||
| 74 | + | if p.TagCount > MaxTagsPerPush { | |||
| 75 | + | return false | |||
| 76 | + | } | |||
| 77 | + | return ev.refFilterAllows(false, strings.TrimPrefix(p.Ref, "refs/tags/")) | |||
| 78 | + | } | |||
| 79 | + | return false | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // refFilterAllows applies the branches or tags filters to the name of a | |||
| 83 | + | // branch (branch is true) or a tag. | |||
| 84 | + | func (e *Event) refFilterAllows(branch bool, name string) bool { | |||
| 85 | + | var inc, exc, otherInc, otherExc []string | |||
| 86 | + | if branch { | |||
| 87 | + | inc, exc, otherInc, otherExc = e.Branches, e.BranchesIgnore, e.Tags, e.TagsIgnore | |||
| 88 | + | } else { | |||
| 89 | + | inc, exc, otherInc, otherExc = e.Tags, e.TagsIgnore, e.Branches, e.BranchesIgnore | |||
| 90 | + | } | |||
| 91 | + | hasOwn := inc != nil || exc != nil | |||
| 92 | + | hasOther := otherInc != nil || otherExc != nil | |||
| 93 | + | if !hasOwn && hasOther { | |||
| 94 | + | return false | |||
| 95 | + | } | |||
| 96 | + | if inc != nil && !matchList(inc, name) { | |||
| 97 | + | return false | |||
| 98 | + | } | |||
| 99 | + | if exc != nil && matchList(exc, name) { | |||
| 100 | + | return false | |||
| 101 | + | } | |||
| 102 | + | return true | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // pathsAllow applies paths and paths-ignore to a branch push. | |||
| 106 | + | func (e *Event) pathsAllow(p PushInfo) bool { | |||
| 107 | + | if e.Paths == nil && e.PathsIgnore == nil { | |||
| 108 | + | return true | |||
| 109 | + | } | |||
| 110 | + | if p.FilesTruncated { | |||
| 111 | + | return true | |||
| 112 | + | } | |||
| 113 | + | files := p.ChangedFiles | |||
| 114 | + | if len(files) > MaxFilesEvaluated { | |||
| 115 | + | files = files[:MaxFilesEvaluated] | |||
| 116 | + | } | |||
| 117 | + | if e.Paths != nil { | |||
| 118 | + | any := false | |||
| 119 | + | for _, f := range files { | |||
| 120 | + | if matchList(e.Paths, f) { | |||
| 121 | + | any = true | |||
| 122 | + | break | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | if !any { | |||
| 126 | + | return false | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | if e.PathsIgnore != nil && len(files) > 0 { | |||
| 130 | + | any := false | |||
| 131 | + | for _, f := range files { | |||
| 132 | + | if !matchList(e.PathsIgnore, f) { | |||
| 133 | + | any = true | |||
| 134 | + | break | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | if !any { | |||
| 138 | + | return false | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | return true | |||
| 142 | + | } | |||
A workflow/push_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func pushWorkflow(t testing.TB, on string) *Workflow { | |
| 11 | + | t.Helper() | |
| 12 | + | w, errs := Parse("w.yml", []byte(on+"\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")) | |
| 13 | + | if len(errs) > 0 { | |
| 14 | + | t.Fatalf("Parse: %v", errs) | |
| 15 | + | } | |
| 16 | + | return w | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestMatchPush(t *testing.T) { | |
| 20 | + | br := func(n string) PushInfo { return PushInfo{Ref: "refs/heads/" + n} } | |
| 21 | + | tg := func(n string) PushInfo { return PushInfo{Ref: "refs/tags/" + n, TagCount: 1} } | |
| 22 | + | files := func(n string, f ...string) PushInfo { return PushInfo{Ref: "refs/heads/" + n, ChangedFiles: f} } | |
| 23 | + | tests := []struct { | |
| 24 | + | name string | |
| 25 | + | on string | |
| 26 | + | push PushInfo | |
| 27 | + | want bool | |
| 28 | + | }{ | |
| 29 | + | {"push string, branch", "on: push", br("main"), true}, | |
| 30 | + | {"push string, tag", "on: push", tg("v1"), true}, | |
| 31 | + | {"push list", "on: [push, pull_request]", br("main"), true}, | |
| 32 | + | {"other event only", "on: pull_request", br("main"), false}, | |
| 33 | + | {"push null", "on:\n push:", br("x/y"), true}, | |
| 34 | + | {"deleted branch", "on: push", PushInfo{Ref: "refs/heads/main", Deleted: true}, false}, | |
| 35 | + | {"deleted tag", "on: push", PushInfo{Ref: "refs/tags/v1", Deleted: true, TagCount: 1}, false}, | |
| 36 | + | {"created branch", "on: push", PushInfo{Ref: "refs/heads/new", Created: true}, true}, | |
| 37 | + | {"other ref kind", "on: push", PushInfo{Ref: "refs/notes/x"}, false}, | |
| 38 | + | ||
| 39 | + | // branches | |
| 40 | + | {"branches match", "on:\n push:\n branches: [main]", br("main"), true}, | |
| 41 | + | {"branches no match", "on:\n push:\n branches: [main]", br("dev"), false}, | |
| 42 | + | {"branches glob", "on:\n push:\n branches: ['release/**']", br("release/1/x"), true}, | |
| 43 | + | {"branches single string", "on:\n push:\n branches: main", br("main"), true}, | |
| 44 | + | {"branches-ignore hit", "on:\n push:\n branches-ignore: ['wip/**']", br("wip/x"), false}, | |
| 45 | + | {"branches-ignore miss", "on:\n push:\n branches-ignore: ['wip/**']", br("main"), true}, | |
| 46 | + | {"branches negation order", "on:\n push:\n branches: ['releases/**', '!releases/**-alpha']", br("releases/1-alpha"), false}, | |
| 47 | + | {"branches negation re-include", "on:\n push:\n branches: ['releases/**', '!releases/**-alpha', 'releases/**/ga']", br("releases/1-alpha/ga"), true}, | |
| 48 | + | {"branches empty list", "on:\n push:\n branches: []", br("main"), false}, | |
| 49 | + | ||
| 50 | + | // branches-only filter: tags don't run; tags-only filter: branches don't run. | |
| 51 | + | {"branches filter, tag push", "on:\n push:\n branches: [main]", tg("main"), false}, | |
| 52 | + | {"branches-ignore filter, tag push", "on:\n push:\n branches-ignore: [dev]", tg("v1"), false}, | |
| 53 | + | {"tags filter, branch push", "on:\n push:\n tags: ['v*']", br("v1"), false}, | |
| 54 | + | {"tags-ignore filter, branch push", "on:\n push:\n tags-ignore: ['v*']", br("main"), false}, | |
| 55 | + | {"tags match", "on:\n push:\n tags: ['v*']", tg("v1.2"), true}, | |
| 56 | + | {"tags no match", "on:\n push:\n tags: ['v*']", tg("x1"), false}, | |
| 57 | + | {"tags-ignore hit", "on:\n push:\n tags-ignore: ['v*-rc*']", tg("v1-rc1"), false}, | |
| 58 | + | {"tags-ignore miss", "on:\n push:\n tags-ignore: ['v*-rc*']", tg("v1"), true}, | |
| 59 | + | {"branches and tags: branch", "on:\n push:\n branches: [main]\n tags: ['v*']", br("main"), true}, | |
| 60 | + | {"branches and tags: other branch", "on:\n push:\n branches: [main]\n tags: ['v*']", br("v1"), false}, | |
| 61 | + | {"branches and tags: tag", "on:\n push:\n branches: [main]\n tags: ['v*']", tg("v1"), true}, | |
| 62 | + | {"branches and tags: other tag", "on:\n push:\n branches: [main]\n tags: ['v*']", tg("main"), false}, | |
| 63 | + | {"tags with paths ignores paths", "on:\n push:\n tags: ['v*']\n paths: ['src/**']", tg("v1"), true}, | |
| 64 | + | ||
| 65 | + | // tag count | |
| 66 | + | {"3 tags", "on: push", PushInfo{Ref: "refs/tags/v1", TagCount: 3}, true}, | |
| 67 | + | {"4 tags", "on: push", PushInfo{Ref: "refs/tags/v1", TagCount: 4}, false}, | |
| 68 | + | {"4 tags and a branch push", "on: push", PushInfo{Ref: "refs/heads/main", TagCount: 4}, true}, | |
| 69 | + | ||
| 70 | + | // paths | |
| 71 | + | {"paths match", "on:\n push:\n paths: ['src/**']", files("main", "src/a.go"), true}, | |
| 72 | + | {"paths no match", "on:\n push:\n paths: ['src/**']", files("main", "docs/a.md"), false}, | |
| 73 | + | {"paths any file", "on:\n push:\n paths: ['src/**']", files("main", "docs/a.md", "src/b.go"), true}, | |
| 74 | + | {"paths no files", "on:\n push:\n paths: ['src/**']", files("main"), false}, | |
| 75 | + | {"paths-ignore all ignored", "on:\n push:\n paths-ignore: ['docs/**']", files("main", "docs/a.md", "docs/b.md"), false}, | |
| 76 | + | {"paths-ignore one other", "on:\n push:\n paths-ignore: ['docs/**']", files("main", "docs/a.md", "x.go"), true}, | |
| 77 | + | {"paths negation", "on:\n push:\n paths: ['sub-project/**', '!sub-project/docs/**']", files("main", "sub-project/docs/a.md"), false}, | |
| 78 | + | {"paths negation, other file", "on:\n push:\n paths: ['sub-project/**', '!sub-project/docs/**']", files("main", "sub-project/docs/a.md", "sub-project/a.go"), true}, | |
| 79 | + | {"paths with branches", "on:\n push:\n branches: [main]\n paths: ['src/**']", files("dev", "src/a.go"), false}, | |
| 80 | + | {"paths with branches both", "on:\n push:\n branches: [main]\n paths: ['src/**']", files("main", "src/a.go"), true}, | |
| 81 | + | {"paths truncated", "on:\n push:\n paths: ['src/**']", PushInfo{Ref: "refs/heads/main", ChangedFiles: []string{"docs/a.md"}, FilesTruncated: true}, true}, | |
| 82 | + | {"paths-ignore truncated", "on:\n push:\n paths-ignore: ['**']", PushInfo{Ref: "refs/heads/main", FilesTruncated: true}, true}, | |
| 83 | + | {"no paths filter, no files", "on: push", files("main"), true}, | |
| 84 | + | } | |
| 85 | + | for _, tc := range tests { | |
| 86 | + | t.Run(tc.name, func(t *testing.T) { | |
| 87 | + | w := pushWorkflow(t, tc.on) | |
| 88 | + | if got := w.MatchPush(tc.push); got != tc.want { | |
| 89 | + | t.Errorf("MatchPush = %v, want %v", got, tc.want) | |
| 90 | + | } | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestMatchPushFirst300Files(t *testing.T) { | |
| 96 | + | w := pushWorkflow(t, "on:\n push:\n paths: ['src/**']") | |
| 97 | + | files := make([]string, 0, 301) | |
| 98 | + | for i := 0; i < MaxFilesEvaluated; i++ { | |
| 99 | + | files = append(files, fmt.Sprintf("docs/%d.md", i)) | |
| 100 | + | } | |
| 101 | + | files = append(files, "src/late.go") // the 301st | |
| 102 | + | if w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) { | |
| 103 | + | t.Error("a match beyond the 300th file must not run the workflow") | |
| 104 | + | } | |
| 105 | + | files[299] = "src/early.go" | |
| 106 | + | if !w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) { | |
| 107 | + | t.Error("a match among the first 300 files must run the workflow") | |
| 108 | + | } | |
| 109 | + | wi := pushWorkflow(t, "on:\n push:\n paths-ignore: ['docs/**']") | |
| 110 | + | files = files[:0] | |
| 111 | + | for i := 0; i < MaxFilesEvaluated; i++ { | |
| 112 | + | files = append(files, fmt.Sprintf("docs/%d.md", i)) | |
| 113 | + | } | |
| 114 | + | files = append(files, "src/late.go") | |
| 115 | + | // TODO(verify with recording): with paths-ignore, files beyond the 300th | |
| 116 | + | // are not considered either, so everything seen is ignored. | |
| 117 | + | if wi.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) { | |
| 118 | + | t.Error("paths-ignore: only the first 300 files are evaluated") | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestMatchPushNilSafe(t *testing.T) { | |
| 123 | + | var w *Workflow | |
| 124 | + | if w.MatchPush(PushInfo{Ref: "refs/heads/main"}) { | |
| 125 | + | t.Error("nil workflow matched") | |
| 126 | + | } | |
| 127 | + | if (&Workflow{}).MatchPush(PushInfo{Ref: "refs/heads/main"}) { | |
| 128 | + | t.Error("workflow without on matched") | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestFilterPairsAreRejected(t *testing.T) { | |
| 133 | + | for _, on := range []string{ | |
| 134 | + | "on:\n push:\n branches: [a]\n branches-ignore: [b]", | |
| 135 | + | "on:\n push:\n tags: [a]\n tags-ignore: [b]", | |
| 136 | + | "on:\n pull_request:\n paths: [a]\n paths-ignore: [b]", | |
| 137 | + | } { | |
| 138 | + | if _, errs := Parse("w.yml", []byte(on+"\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")); len(errs) != 1 { | |
| 139 | + | t.Errorf("%q: errors = %v", on, errs) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func pushWorkflow(t testing.TB, on string) *Workflow { | |||
| 11 | + | t.Helper() | |||
| 12 | + | w, errs := Parse("w.yml", []byte(on+"\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")) | |||
| 13 | + | if len(errs) > 0 { | |||
| 14 | + | t.Fatalf("Parse: %v", errs) | |||
| 15 | + | } | |||
| 16 | + | return w | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestMatchPush(t *testing.T) { | |||
| 20 | + | br := func(n string) PushInfo { return PushInfo{Ref: "refs/heads/" + n} } | |||
| 21 | + | tg := func(n string) PushInfo { return PushInfo{Ref: "refs/tags/" + n, TagCount: 1} } | |||
| 22 | + | files := func(n string, f ...string) PushInfo { return PushInfo{Ref: "refs/heads/" + n, ChangedFiles: f} } | |||
| 23 | + | tests := []struct { | |||
| 24 | + | name string | |||
| 25 | + | on string | |||
| 26 | + | push PushInfo | |||
| 27 | + | want bool | |||
| 28 | + | }{ | |||
| 29 | + | {"push string, branch", "on: push", br("main"), true}, | |||
| 30 | + | {"push string, tag", "on: push", tg("v1"), true}, | |||
| 31 | + | {"push list", "on: [push, pull_request]", br("main"), true}, | |||
| 32 | + | {"other event only", "on: pull_request", br("main"), false}, | |||
| 33 | + | {"push null", "on:\n push:", br("x/y"), true}, | |||
| 34 | + | {"deleted branch", "on: push", PushInfo{Ref: "refs/heads/main", Deleted: true}, false}, | |||
| 35 | + | {"deleted tag", "on: push", PushInfo{Ref: "refs/tags/v1", Deleted: true, TagCount: 1}, false}, | |||
| 36 | + | {"created branch", "on: push", PushInfo{Ref: "refs/heads/new", Created: true}, true}, | |||
| 37 | + | {"other ref kind", "on: push", PushInfo{Ref: "refs/notes/x"}, false}, | |||
| 38 | + | ||||
| 39 | + | // branches | |||
| 40 | + | {"branches match", "on:\n push:\n branches: [main]", br("main"), true}, | |||
| 41 | + | {"branches no match", "on:\n push:\n branches: [main]", br("dev"), false}, | |||
| 42 | + | {"branches glob", "on:\n push:\n branches: ['release/**']", br("release/1/x"), true}, | |||
| 43 | + | {"branches single string", "on:\n push:\n branches: main", br("main"), true}, | |||
| 44 | + | {"branches-ignore hit", "on:\n push:\n branches-ignore: ['wip/**']", br("wip/x"), false}, | |||
| 45 | + | {"branches-ignore miss", "on:\n push:\n branches-ignore: ['wip/**']", br("main"), true}, | |||
| 46 | + | {"branches negation order", "on:\n push:\n branches: ['releases/**', '!releases/**-alpha']", br("releases/1-alpha"), false}, | |||
| 47 | + | {"branches negation re-include", "on:\n push:\n branches: ['releases/**', '!releases/**-alpha', 'releases/**/ga']", br("releases/1-alpha/ga"), true}, | |||
| 48 | + | {"branches empty list", "on:\n push:\n branches: []", br("main"), false}, | |||
| 49 | + | ||||
| 50 | + | // branches-only filter: tags don't run; tags-only filter: branches don't run. | |||
| 51 | + | {"branches filter, tag push", "on:\n push:\n branches: [main]", tg("main"), false}, | |||
| 52 | + | {"branches-ignore filter, tag push", "on:\n push:\n branches-ignore: [dev]", tg("v1"), false}, | |||
| 53 | + | {"tags filter, branch push", "on:\n push:\n tags: ['v*']", br("v1"), false}, | |||
| 54 | + | {"tags-ignore filter, branch push", "on:\n push:\n tags-ignore: ['v*']", br("main"), false}, | |||
| 55 | + | {"tags match", "on:\n push:\n tags: ['v*']", tg("v1.2"), true}, | |||
| 56 | + | {"tags no match", "on:\n push:\n tags: ['v*']", tg("x1"), false}, | |||
| 57 | + | {"tags-ignore hit", "on:\n push:\n tags-ignore: ['v*-rc*']", tg("v1-rc1"), false}, | |||
| 58 | + | {"tags-ignore miss", "on:\n push:\n tags-ignore: ['v*-rc*']", tg("v1"), true}, | |||
| 59 | + | {"branches and tags: branch", "on:\n push:\n branches: [main]\n tags: ['v*']", br("main"), true}, | |||
| 60 | + | {"branches and tags: other branch", "on:\n push:\n branches: [main]\n tags: ['v*']", br("v1"), false}, | |||
| 61 | + | {"branches and tags: tag", "on:\n push:\n branches: [main]\n tags: ['v*']", tg("v1"), true}, | |||
| 62 | + | {"branches and tags: other tag", "on:\n push:\n branches: [main]\n tags: ['v*']", tg("main"), false}, | |||
| 63 | + | {"tags with paths ignores paths", "on:\n push:\n tags: ['v*']\n paths: ['src/**']", tg("v1"), true}, | |||
| 64 | + | ||||
| 65 | + | // tag count | |||
| 66 | + | {"3 tags", "on: push", PushInfo{Ref: "refs/tags/v1", TagCount: 3}, true}, | |||
| 67 | + | {"4 tags", "on: push", PushInfo{Ref: "refs/tags/v1", TagCount: 4}, false}, | |||
| 68 | + | {"4 tags and a branch push", "on: push", PushInfo{Ref: "refs/heads/main", TagCount: 4}, true}, | |||
| 69 | + | ||||
| 70 | + | // paths | |||
| 71 | + | {"paths match", "on:\n push:\n paths: ['src/**']", files("main", "src/a.go"), true}, | |||
| 72 | + | {"paths no match", "on:\n push:\n paths: ['src/**']", files("main", "docs/a.md"), false}, | |||
| 73 | + | {"paths any file", "on:\n push:\n paths: ['src/**']", files("main", "docs/a.md", "src/b.go"), true}, | |||
| 74 | + | {"paths no files", "on:\n push:\n paths: ['src/**']", files("main"), false}, | |||
| 75 | + | {"paths-ignore all ignored", "on:\n push:\n paths-ignore: ['docs/**']", files("main", "docs/a.md", "docs/b.md"), false}, | |||
| 76 | + | {"paths-ignore one other", "on:\n push:\n paths-ignore: ['docs/**']", files("main", "docs/a.md", "x.go"), true}, | |||
| 77 | + | {"paths negation", "on:\n push:\n paths: ['sub-project/**', '!sub-project/docs/**']", files("main", "sub-project/docs/a.md"), false}, | |||
| 78 | + | {"paths negation, other file", "on:\n push:\n paths: ['sub-project/**', '!sub-project/docs/**']", files("main", "sub-project/docs/a.md", "sub-project/a.go"), true}, | |||
| 79 | + | {"paths with branches", "on:\n push:\n branches: [main]\n paths: ['src/**']", files("dev", "src/a.go"), false}, | |||
| 80 | + | {"paths with branches both", "on:\n push:\n branches: [main]\n paths: ['src/**']", files("main", "src/a.go"), true}, | |||
| 81 | + | {"paths truncated", "on:\n push:\n paths: ['src/**']", PushInfo{Ref: "refs/heads/main", ChangedFiles: []string{"docs/a.md"}, FilesTruncated: true}, true}, | |||
| 82 | + | {"paths-ignore truncated", "on:\n push:\n paths-ignore: ['**']", PushInfo{Ref: "refs/heads/main", FilesTruncated: true}, true}, | |||
| 83 | + | {"no paths filter, no files", "on: push", files("main"), true}, | |||
| 84 | + | } | |||
| 85 | + | for _, tc := range tests { | |||
| 86 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 87 | + | w := pushWorkflow(t, tc.on) | |||
| 88 | + | if got := w.MatchPush(tc.push); got != tc.want { | |||
| 89 | + | t.Errorf("MatchPush = %v, want %v", got, tc.want) | |||
| 90 | + | } | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestMatchPushFirst300Files(t *testing.T) { | |||
| 96 | + | w := pushWorkflow(t, "on:\n push:\n paths: ['src/**']") | |||
| 97 | + | files := make([]string, 0, 301) | |||
| 98 | + | for i := 0; i < MaxFilesEvaluated; i++ { | |||
| 99 | + | files = append(files, fmt.Sprintf("docs/%d.md", i)) | |||
| 100 | + | } | |||
| 101 | + | files = append(files, "src/late.go") // the 301st | |||
| 102 | + | if w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) { | |||
| 103 | + | t.Error("a match beyond the 300th file must not run the workflow") | |||
| 104 | + | } | |||
| 105 | + | files[299] = "src/early.go" | |||
| 106 | + | if !w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) { | |||
| 107 | + | t.Error("a match among the first 300 files must run the workflow") | |||
| 108 | + | } | |||
| 109 | + | wi := pushWorkflow(t, "on:\n push:\n paths-ignore: ['docs/**']") | |||
| 110 | + | files = files[:0] | |||
| 111 | + | for i := 0; i < MaxFilesEvaluated; i++ { | |||
| 112 | + | files = append(files, fmt.Sprintf("docs/%d.md", i)) | |||
| 113 | + | } | |||
| 114 | + | files = append(files, "src/late.go") | |||
| 115 | + | // TODO(verify with recording): with paths-ignore, files beyond the 300th | |||
| 116 | + | // are not considered either, so everything seen is ignored. | |||
| 117 | + | if wi.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) { | |||
| 118 | + | t.Error("paths-ignore: only the first 300 files are evaluated") | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestMatchPushNilSafe(t *testing.T) { | |||
| 123 | + | var w *Workflow | |||
| 124 | + | if w.MatchPush(PushInfo{Ref: "refs/heads/main"}) { | |||
| 125 | + | t.Error("nil workflow matched") | |||
| 126 | + | } | |||
| 127 | + | if (&Workflow{}).MatchPush(PushInfo{Ref: "refs/heads/main"}) { | |||
| 128 | + | t.Error("workflow without on matched") | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestFilterPairsAreRejected(t *testing.T) { | |||
| 133 | + | for _, on := range []string{ | |||
| 134 | + | "on:\n push:\n branches: [a]\n branches-ignore: [b]", | |||
| 135 | + | "on:\n push:\n tags: [a]\n tags-ignore: [b]", | |||
| 136 | + | "on:\n pull_request:\n paths: [a]\n paths-ignore: [b]", | |||
| 137 | + | } { | |||
| 138 | + | if _, errs := Parse("w.yml", []byte(on+"\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")); len(errs) != 1 { | |||
| 139 | + | t.Errorf("%q: errors = %v", on, errs) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||
A workflow/skip.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import "strings" | |
| 6 | + | ||
| 7 | + | var skipMarkers = []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"} | |
| 8 | + | ||
| 9 | + | // SkipCI reports whether the message of the head commit of a push tells | |
| 10 | + | // GitHub not to run workflows for it: it contains [skip ci], [ci skip], | |
| 11 | + | // [no ci], [skip actions] or [actions skip], or it ends with a | |
| 12 | + | // "skip-checks: true" (or "skip-checks:true") trailer in its last paragraph. | |
| 13 | + | // | |
| 14 | + | // TODO(verify with recording): the following is assumed. Markers are matched | |
| 15 | + | // without regard to case, anywhere in the message, subject or body. GitHub | |
| 16 | + | // documents the trailer as "two empty lines followed by skip-checks: true"; | |
| 17 | + | // here one blank line is enough, the trailer must be in the last paragraph | |
| 18 | + | // and not the first one, its key and value are case-insensitive, and other | |
| 19 | + | // trailers may share its paragraph. Whether GitHub looks at the head commit | |
| 20 | + | // only, or at every commit of the push, is not settled by its documentation. | |
| 21 | + | func SkipCI(headCommitMessage string) bool { | |
| 22 | + | lower := strings.ToLower(headCommitMessage) | |
| 23 | + | for _, m := range skipMarkers { | |
| 24 | + | if strings.Contains(lower, m) { | |
| 25 | + | return true | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | return hasSkipChecksTrailer(headCommitMessage) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func hasSkipChecksTrailer(msg string) bool { | |
| 32 | + | msg = strings.ReplaceAll(msg, "\r\n", "\n") | |
| 33 | + | var paras [][]string | |
| 34 | + | var cur []string | |
| 35 | + | for _, line := range strings.Split(msg, "\n") { | |
| 36 | + | if strings.TrimSpace(line) == "" { | |
| 37 | + | if len(cur) > 0 { | |
| 38 | + | paras = append(paras, cur) | |
| 39 | + | cur = nil | |
| 40 | + | } | |
| 41 | + | continue | |
| 42 | + | } | |
| 43 | + | cur = append(cur, line) | |
| 44 | + | } | |
| 45 | + | if len(cur) > 0 { | |
| 46 | + | paras = append(paras, cur) | |
| 47 | + | } | |
| 48 | + | if len(paras) < 2 { | |
| 49 | + | return false | |
| 50 | + | } | |
| 51 | + | for _, line := range paras[len(paras)-1] { | |
| 52 | + | key, val, ok := strings.Cut(line, ":") | |
| 53 | + | if ok && strings.EqualFold(strings.TrimSpace(key), "skip-checks") && strings.EqualFold(strings.TrimSpace(val), "true") { | |
| 54 | + | return true | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return false | |
| 58 | + | } | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import "strings" | |||
| 6 | + | ||||
| 7 | + | var skipMarkers = []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"} | |||
| 8 | + | ||||
| 9 | + | // SkipCI reports whether the message of the head commit of a push tells | |||
| 10 | + | // GitHub not to run workflows for it: it contains [skip ci], [ci skip], | |||
| 11 | + | // [no ci], [skip actions] or [actions skip], or it ends with a | |||
| 12 | + | // "skip-checks: true" (or "skip-checks:true") trailer in its last paragraph. | |||
| 13 | + | // | |||
| 14 | + | // TODO(verify with recording): the following is assumed. Markers are matched | |||
| 15 | + | // without regard to case, anywhere in the message, subject or body. GitHub | |||
| 16 | + | // documents the trailer as "two empty lines followed by skip-checks: true"; | |||
| 17 | + | // here one blank line is enough, the trailer must be in the last paragraph | |||
| 18 | + | // and not the first one, its key and value are case-insensitive, and other | |||
| 19 | + | // trailers may share its paragraph. Whether GitHub looks at the head commit | |||
| 20 | + | // only, or at every commit of the push, is not settled by its documentation. | |||
| 21 | + | func SkipCI(headCommitMessage string) bool { | |||
| 22 | + | lower := strings.ToLower(headCommitMessage) | |||
| 23 | + | for _, m := range skipMarkers { | |||
| 24 | + | if strings.Contains(lower, m) { | |||
| 25 | + | return true | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | return hasSkipChecksTrailer(headCommitMessage) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func hasSkipChecksTrailer(msg string) bool { | |||
| 32 | + | msg = strings.ReplaceAll(msg, "\r\n", "\n") | |||
| 33 | + | var paras [][]string | |||
| 34 | + | var cur []string | |||
| 35 | + | for _, line := range strings.Split(msg, "\n") { | |||
| 36 | + | if strings.TrimSpace(line) == "" { | |||
| 37 | + | if len(cur) > 0 { | |||
| 38 | + | paras = append(paras, cur) | |||
| 39 | + | cur = nil | |||
| 40 | + | } | |||
| 41 | + | continue | |||
| 42 | + | } | |||
| 43 | + | cur = append(cur, line) | |||
| 44 | + | } | |||
| 45 | + | if len(cur) > 0 { | |||
| 46 | + | paras = append(paras, cur) | |||
| 47 | + | } | |||
| 48 | + | if len(paras) < 2 { | |||
| 49 | + | return false | |||
| 50 | + | } | |||
| 51 | + | for _, line := range paras[len(paras)-1] { | |||
| 52 | + | key, val, ok := strings.Cut(line, ":") | |||
| 53 | + | if ok && strings.EqualFold(strings.TrimSpace(key), "skip-checks") && strings.EqualFold(strings.TrimSpace(val), "true") { | |||
| 54 | + | return true | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return false | |||
| 58 | + | } | |||
A workflow/skip_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import "testing" | |
| 6 | + | ||
| 7 | + | func TestSkipCI(t *testing.T) { | |
| 8 | + | tests := []struct { | |
| 9 | + | name, msg string | |
| 10 | + | want bool | |
| 11 | + | }{ | |
| 12 | + | {"empty", "", false}, | |
| 13 | + | {"plain", "fix: a bug", false}, | |
| 14 | + | {"skip ci", "docs: typo [skip ci]", true}, | |
| 15 | + | {"ci skip", "docs: typo [ci skip]", true}, | |
| 16 | + | {"no ci", "docs: typo [no ci]", true}, | |
| 17 | + | {"skip actions", "docs: typo [skip actions]", true}, | |
| 18 | + | {"actions skip", "docs: typo [actions skip]", true}, | |
| 19 | + | {"in body", "docs: typo\n\nlong text [skip ci] more", true}, | |
| 20 | + | {"in subject start", "[skip ci] docs", true}, | |
| 21 | + | {"no brackets", "skip ci", false}, | |
| 22 | + | {"other brackets", "[skip tests]", false}, | |
| 23 | + | {"partial", "[skip ci]", false}, | |
| 24 | + | // TODO(verify with recording): case of the markers. | |
| 25 | + | {"upper case marker", "docs [SKIP CI]", true}, | |
| 26 | + | {"mixed case marker", "docs [Skip Ci]", true}, | |
| 27 | + | ||
| 28 | + | // skip-checks trailer. | |
| 29 | + | {"trailer", "feat: x\n\nskip-checks: true", true}, | |
| 30 | + | {"trailer no space", "feat: x\n\nskip-checks:true", true}, | |
| 31 | + | {"trailer with body", "feat: x\n\nbody text\n\nskip-checks: true", true}, | |
| 32 | + | {"trailer after two blank lines", "feat: x\n\n\nskip-checks: true\n", true}, | |
| 33 | + | {"trailer crlf", "feat: x\r\n\r\nskip-checks: true\r\n", true}, | |
| 34 | + | {"trailer with other trailers", "feat: x\n\nSigned-off-by: A <a@x>\nskip-checks: true", true}, | |
| 35 | + | {"trailer false", "feat: x\n\nskip-checks: false", false}, | |
| 36 | + | {"trailer not last paragraph", "feat: x\n\nskip-checks: true\n\nmore text", false}, | |
| 37 | + | {"trailer in first paragraph only", "skip-checks: true", false}, | |
| 38 | + | {"trailer in subject", "feat: x skip-checks: true", false}, | |
| 39 | + | // TODO(verify with recording): case of key and value. | |
| 40 | + | {"trailer upper", "feat: x\n\nSkip-Checks: TRUE", true}, | |
| 41 | + | } | |
| 42 | + | for _, tc := range tests { | |
| 43 | + | if got := SkipCI(tc.msg); got != tc.want { | |
| 44 | + | t.Errorf("%s: SkipCI(%q) = %v, want %v", tc.name, tc.msg, got, tc.want) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package workflow | |||
| 4 | + | ||||
| 5 | + | import "testing" | |||
| 6 | + | ||||
| 7 | + | func TestSkipCI(t *testing.T) { | |||
| 8 | + | tests := []struct { | |||
| 9 | + | name, msg string | |||
| 10 | + | want bool | |||
| 11 | + | }{ | |||
| 12 | + | {"empty", "", false}, | |||
| 13 | + | {"plain", "fix: a bug", false}, | |||
| 14 | + | {"skip ci", "docs: typo [skip ci]", true}, | |||
| 15 | + | {"ci skip", "docs: typo [ci skip]", true}, | |||
| 16 | + | {"no ci", "docs: typo [no ci]", true}, | |||
| 17 | + | {"skip actions", "docs: typo [skip actions]", true}, | |||
| 18 | + | {"actions skip", "docs: typo [actions skip]", true}, | |||
| 19 | + | {"in body", "docs: typo\n\nlong text [skip ci] more", true}, | |||
| 20 | + | {"in subject start", "[skip ci] docs", true}, | |||
| 21 | + | {"no brackets", "skip ci", false}, | |||
| 22 | + | {"other brackets", "[skip tests]", false}, | |||
| 23 | + | {"partial", "[skip ci]", false}, | |||
| 24 | + | // TODO(verify with recording): case of the markers. | |||
| 25 | + | {"upper case marker", "docs [SKIP CI]", true}, | |||
| 26 | + | {"mixed case marker", "docs [Skip Ci]", true}, | |||
| 27 | + | ||||
| 28 | + | // skip-checks trailer. | |||
| 29 | + | {"trailer", "feat: x\n\nskip-checks: true", true}, | |||
| 30 | + | {"trailer no space", "feat: x\n\nskip-checks:true", true}, | |||
| 31 | + | {"trailer with body", "feat: x\n\nbody text\n\nskip-checks: true", true}, | |||
| 32 | + | {"trailer after two blank lines", "feat: x\n\n\nskip-checks: true\n", true}, | |||
| 33 | + | {"trailer crlf", "feat: x\r\n\r\nskip-checks: true\r\n", true}, | |||
| 34 | + | {"trailer with other trailers", "feat: x\n\nSigned-off-by: A <a@x>\nskip-checks: true", true}, | |||
| 35 | + | {"trailer false", "feat: x\n\nskip-checks: false", false}, | |||
| 36 | + | {"trailer not last paragraph", "feat: x\n\nskip-checks: true\n\nmore text", false}, | |||
| 37 | + | {"trailer in first paragraph only", "skip-checks: true", false}, | |||
| 38 | + | {"trailer in subject", "feat: x skip-checks: true", false}, | |||
| 39 | + | // TODO(verify with recording): case of key and value. | |||
| 40 | + | {"trailer upper", "feat: x\n\nSkip-Checks: TRUE", true}, | |||
| 41 | + | } | |||
| 42 | + | for _, tc := range tests { | |||
| 43 | + | if got := SkipCI(tc.msg); got != tc.want { | |||
| 44 | + | t.Errorf("%s: SkipCI(%q) = %v, want %v", tc.name, tc.msg, got, tc.want) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||