detached 028f5a1

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
5454 // the JavaScript library's and cannot be reproduced word for word.
5555 func isYAMLError(s string) bool { return strings.Contains(s, " at line ") }
5656
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+
5767 func TestConvertAgainstReference(t *testing.T) {
5868 ref := loadRef(t)
5969 files, err := filepath.Glob("testdata/convert/*.yml")
@@ -74,6 +84,12 @@ func TestConvertAgainstReference(t *testing.T) {
7484 }
7585 w, errs := Parse(name, content)
7686 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+ }
7793 if len(want.Errors) == 1 && isYAMLError(want.Errors[0]) {
7894 // Syntax errors: the Go parser words them differently. Only
7995 // 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. Only94 // 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
269269 }
270270 return in
271271 }
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 in270 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"
1717 // contexts and functions in expressions, and the checks of the converter (job
1818 // and step ids, needs, filters, inputs). When there is any error the workflow
1919 // 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.
2123 func Parse(path string, content []byte) (*Workflow, []Error) {
2224 schema, err := template.WorkflowSchema()
2325 if err != nil {
@@ -29,6 +31,7 @@ func Parse(path string, content []byte) (*Workflow, []Error) {
2931 if root != nil && !ctx.HasErrors() {
3032 c := &converter{ctx: ctx, schema: schema, w: w, jobIDs: newIDBuilder()}
3133 c.convertRoot(root)
34+ c.validateFilters()
3235 }
3336 errs := collect(ctx)
3437 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 (job17 // 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 workflow18 // 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 passes19 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close