1// SPDX-License-Identifier: MIT
2
3package workflow
4
5import (
6 "fmt"
7 "strings"
8 "testing"
9)
10
11func pushWorkflow(t testing.TB, on string) *Workflow {
12 t.Helper()
13 w, errs := Parse("w.yml", []byte(on+"\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n"))
14 if len(errs) > 0 {
15 t.Fatalf("Parse: %v", errs)
16 }
17 return w
18}
19
20func TestMatchPush(t *testing.T) {
21 br := func(n string) PushInfo { return PushInfo{Ref: "refs/heads/" + n} }
22 tg := func(n string) PushInfo { return PushInfo{Ref: "refs/tags/" + n, TagCount: 1} }
23 files := func(n string, f ...string) PushInfo { return PushInfo{Ref: "refs/heads/" + n, ChangedFiles: f} }
24 tests := []struct {
25 name string
26 on string
27 push PushInfo
28 want bool
29 }{
30 {"push string, branch", "on: push", br("main"), true},
31 {"push string, tag", "on: push", tg("v1"), true},
32 {"push list", "on: [push, pull_request]", br("main"), true},
33 {"other event only", "on: pull_request", br("main"), false},
34 {"push null", "on:\n push:", br("x/y"), true},
35 {"deleted branch", "on: push", PushInfo{Ref: "refs/heads/main", Deleted: true}, false},
36 {"deleted tag", "on: push", PushInfo{Ref: "refs/tags/v1", Deleted: true, TagCount: 1}, false},
37 {"created branch", "on: push", PushInfo{Ref: "refs/heads/new", Created: true}, true},
38 {"other ref kind", "on: push", PushInfo{Ref: "refs/notes/x"}, false},
39
40 // branches
41 {"branches match", "on:\n push:\n branches: [main]", br("main"), true},
42 {"branches no match", "on:\n push:\n branches: [main]", br("dev"), false},
43 {"branches glob", "on:\n push:\n branches: ['release/**']", br("release/1/x"), true},
44 {"branches single string", "on:\n push:\n branches: main", br("main"), true},
45 {"branches-ignore hit", "on:\n push:\n branches-ignore: ['wip/**']", br("wip/x"), false},
46 {"branches-ignore miss", "on:\n push:\n branches-ignore: ['wip/**']", br("main"), true},
47 {"branches negation order", "on:\n push:\n branches: ['releases/**', '!releases/**-alpha']", br("releases/1-alpha"), false},
48 {"branches negation re-include", "on:\n push:\n branches: ['releases/**', '!releases/**-alpha', 'releases/**/ga']", br("releases/1-alpha/ga"), true},
49 {"branches empty list", "on:\n push:\n branches: []", br("main"), false},
50
51 // branches-only filter: tags don't run; tags-only filter: branches don't run.
52 {"branches filter, tag push", "on:\n push:\n branches: [main]", tg("main"), false},
53 {"branches-ignore filter, tag push", "on:\n push:\n branches-ignore: [dev]", tg("v1"), false},
54 {"tags filter, branch push", "on:\n push:\n tags: ['v*']", br("v1"), false},
55 {"tags-ignore filter, branch push", "on:\n push:\n tags-ignore: ['v*']", br("main"), false},
56 {"tags match", "on:\n push:\n tags: ['v*']", tg("v1.2"), true},
57 {"tags no match", "on:\n push:\n tags: ['v*']", tg("x1"), false},
58 {"tags-ignore hit", "on:\n push:\n tags-ignore: ['v*-rc*']", tg("v1-rc1"), false},
59 {"tags-ignore miss", "on:\n push:\n tags-ignore: ['v*-rc*']", tg("v1"), true},
60 {"branches and tags: branch", "on:\n push:\n branches: [main]\n tags: ['v*']", br("main"), true},
61 {"branches and tags: other branch", "on:\n push:\n branches: [main]\n tags: ['v*']", br("v1"), false},
62 {"branches and tags: tag", "on:\n push:\n branches: [main]\n tags: ['v*']", tg("v1"), true},
63 {"branches and tags: other tag", "on:\n push:\n branches: [main]\n tags: ['v*']", tg("main"), false},
64 {"tags with paths ignores paths", "on:\n push:\n tags: ['v*']\n paths: ['src/**']", tg("v1"), true},
65
66 // tag count
67 {"3 tags", "on: push", PushInfo{Ref: "refs/tags/v1", TagCount: 3}, true},
68 {"4 tags", "on: push", PushInfo{Ref: "refs/tags/v1", TagCount: 4}, false},
69 {"4 tags and a branch push", "on: push", PushInfo{Ref: "refs/heads/main", TagCount: 4}, true},
70
71 // paths
72 {"paths match", "on:\n push:\n paths: ['src/**']", files("main", "src/a.go"), true},
73 {"paths no match", "on:\n push:\n paths: ['src/**']", files("main", "docs/a.md"), false},
74 {"paths any file", "on:\n push:\n paths: ['src/**']", files("main", "docs/a.md", "src/b.go"), true},
75 {"paths no files", "on:\n push:\n paths: ['src/**']", files("main"), false},
76 {"paths-ignore all ignored", "on:\n push:\n paths-ignore: ['docs/**']", files("main", "docs/a.md", "docs/b.md"), false},
77 {"paths-ignore one other", "on:\n push:\n paths-ignore: ['docs/**']", files("main", "docs/a.md", "x.go"), true},
78 {"paths negation", "on:\n push:\n paths: ['sub-project/**', '!sub-project/docs/**']", files("main", "sub-project/docs/a.md"), false},
79 {"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},
80 {"paths with branches", "on:\n push:\n branches: [main]\n paths: ['src/**']", files("dev", "src/a.go"), false},
81 {"paths with branches both", "on:\n push:\n branches: [main]\n paths: ['src/**']", files("main", "src/a.go"), true},
82 {"paths truncated", "on:\n push:\n paths: ['src/**']", PushInfo{Ref: "refs/heads/main", ChangedFiles: []string{"docs/a.md"}, FilesTruncated: true}, true},
83 {"paths-ignore truncated", "on:\n push:\n paths-ignore: ['**']", PushInfo{Ref: "refs/heads/main", FilesTruncated: true}, true},
84 {"no paths filter, no files", "on: push", files("main"), true},
85 }
86 for _, tc := range tests {
87 t.Run(tc.name, func(t *testing.T) {
88 w := pushWorkflow(t, tc.on)
89 if got := w.MatchPush(tc.push); got != tc.want {
90 t.Errorf("MatchPush = %v, want %v", got, tc.want)
91 }
92 })
93 }
94}
95
96// numberedFiles returns n paths "dir/f-00000.txt" ... in the order of the diff,
97// which for GitHub is sorted by path.
98func numberedFiles(dir string, n int) []string {
99 files := make([]string, 0, n+1)
100 for i := 0; i < n; i++ {
101 files = append(files, fmt.Sprintf("%s/f-%05d.txt", dir, i))
102 }
103 return files
104}
105
106// TestMatchPushFileLimit follows the recordings filter-paths-300-files,
107// filter-paths-1200-files, filter-paths-3000-boundary and
108// filter-paths-3100-files: a paths or paths-ignore filter looks at the first
109// 3000 changed files, whose only match (or only file not ignored) sorts last.
110func TestMatchPushFileLimit(t *testing.T) {
111 if MaxFilesEvaluated != 3000 {
112 t.Fatalf("MaxFilesEvaluated = %d, want 3000", MaxFilesEvaluated)
113 }
114 paths := pushWorkflow(t, "on:\n push:\n paths: ['zzz/**']")
115 early := pushWorkflow(t, "on:\n push:\n paths: ['gen/**']")
116 ignore := pushWorkflow(t, "on:\n push:\n paths-ignore: ['gen/**']")
117 for _, tc := range []struct {
118 scenario string
119 total int // changed files, the last one is zzz/late.txt
120 late bool
121 early bool
122 ignore bool
123 }{
124 // filter-paths-300-files, push 2 (301 files): late-match, early-match
125 // and ignore-late all ran.
126 {"filter-paths-300-files", 301, true, true, true},
127 // filter-paths-1200-files, push 2 (1201 files): same.
128 {"filter-paths-1200-files", 1201, true, true, true},
129 // filter-paths-3000-boundary, push 2 (3000 files, match last): ran.
130 {"filter-paths-3000-boundary 3000", 3000, true, true, true},
131 // filter-paths-3000-boundary, push 3 (3001 files): the match is the
132 // 3001st file, the workflow did not run.
133 {"filter-paths-3000-boundary 3001", 3001, false, true, false},
134 // filter-paths-3100-files, push 2 (3101 files): late-match and
135 // ignore-late did not run, early-match did.
136 {"filter-paths-3100-files", 3101, false, true, false},
137 } {
138 files := append(numberedFiles("gen", tc.total-1), "zzz/late.txt")
139 p := PushInfo{Ref: "refs/heads/scn/x", ChangedFiles: files}
140 if got := paths.MatchPush(p); got != tc.late {
141 t.Errorf("%s: paths with the match last = %v, want %v", tc.scenario, got, tc.late)
142 }
143 if got := early.MatchPush(p); got != tc.early {
144 t.Errorf("%s: paths with an early match = %v, want %v", tc.scenario, got, tc.early)
145 }
146 if got := ignore.MatchPush(p); got != tc.ignore {
147 t.Errorf("%s: paths-ignore with the last file not ignored = %v, want %v", tc.scenario, got, tc.ignore)
148 }
149 }
150}
151
152// TestMatchPushRecordedTags replays what github.com did in the scenarios
153// push-tags-limit, filter-tags, filter-branches-ignore,
154// filter-tag-branches-only and filter-paths-tag. The branches are named
155// scn/<scenario>/<run> and the tags scn-<scenario>-<run>-<n>.
156func TestMatchPushRecordedTags(t *testing.T) {
157 tag := func(n, count int) PushInfo {
158 return PushInfo{Ref: fmt.Sprintf("refs/tags/scn-filter-tags-1-%d", n), Created: true, TagCount: count}
159 }
160 branch := PushInfo{Ref: "refs/heads/scn/filter-tags/1", Created: true, ChangedFiles: []string{"a.txt"}}
161 tests := []struct {
162 scenario string
163 on string
164 push PushInfo
165 want bool
166 }{
167 // push-tags-limit: three tags at once start three runs, four tags none.
168 {"push-tags-limit 3 tags", "on: push", tag(1, 3), true},
169 {"push-tags-limit 3 tags (third)", "on: push", tag(3, 3), true},
170 {"push-tags-limit 4 tags (first)", "on: push", tag(1, 4), false},
171 {"push-tags-limit 4 tags (last)", "on: push", tag(4, 4), false},
172 {"push-tags-limit 4 tags, tags filter", "on:\n push:\n tags: ['scn-*']", tag(2, 4), false},
173
174 // filter-tags, push 1 (a branch): t-both and t-branches-only ran, the
175 // tags-only workflows did not.
176 {"filter-tags branch t-both", "on:\n push:\n branches: ['scn/**']\n tags: ['scn-*']", branch, true},
177 {"filter-tags branch t-branches-only", "on:\n push:\n branches: ['scn/**']", branch, true},
178 {"filter-tags branch t-match", "on:\n push:\n tags: ['scn-*']", branch, false},
179 {"filter-tags branch t-ignore-other", "on:\n push:\n tags-ignore: ['v*']", branch, false},
180 {"filter-tags branch t-negated", "on:\n push:\n tags:\n - 'scn-*'\n - '!scn-*-1'", branch, false},
181 // filter-tags, push 2 (tag scn-...-1): ran for t-both, t-match,
182 // t-ignore-other and t-negated-other; not for t-branches-only, t-ignore,
183 // t-negated and t-nomatch.
184 {"filter-tags tag t-both", "on:\n push:\n branches: ['scn/**']\n tags: ['scn-*']", tag(1, 1), true},
185 {"filter-tags tag t-match", "on:\n push:\n tags: ['scn-*']", tag(1, 1), true},
186 {"filter-tags tag t-ignore-other", "on:\n push:\n tags-ignore: ['v*']", tag(1, 1), true},
187 {"filter-tags tag t-negated-other", "on:\n push:\n tags:\n - 'scn-*'\n - '!scn-*-2'", tag(1, 1), true},
188 {"filter-tags tag t-branches-only", "on:\n push:\n branches: ['scn/**']", tag(1, 1), false},
189 {"filter-tags tag t-ignore", "on:\n push:\n tags-ignore: ['scn-*']", tag(1, 1), false},
190 {"filter-tags tag t-negated", "on:\n push:\n tags:\n - 'scn-*'\n - '!scn-*-1'", tag(1, 1), false},
191 {"filter-tags tag t-nomatch", "on:\n push:\n tags: ['v*']", tag(1, 1), false},
192
193 // filter-branches-ignore: the branch push ran bi-other and
194 // bi-single-star, not bi-ignored and not bi-tags-too; the tag push ran
195 // only bi-tags-too (branches-ignore alone ignores tags).
196 {"filter-branches-ignore branch bi-ignored", "on:\n push:\n branches-ignore: ['scn/**']", branch, false},
197 {"filter-branches-ignore branch bi-other", "on:\n push:\n branches-ignore: ['main', 'release/**']", branch, true},
198 {"filter-branches-ignore branch bi-single-star", "on:\n push:\n branches-ignore: ['scn/*']", branch, true},
199 {"filter-branches-ignore branch bi-tags-too", "on:\n push:\n branches-ignore: ['scn/**']\n tags: ['scn-*']", branch, false},
200 {"filter-branches-ignore tag bi-tags-too", "on:\n push:\n branches-ignore: ['scn/**']\n tags: ['scn-*']", tag(1, 1), true},
201 {"filter-branches-ignore tag bi-ignored", "on:\n push:\n branches-ignore: ['scn/**']", tag(1, 1), false},
202 {"filter-branches-ignore tag bi-other", "on:\n push:\n branches-ignore: ['main']", tag(1, 1), false},
203
204 // filter-tag-branches-only: a tag pushed to a repository whose only
205 // workflow filters on branches starts nothing.
206 {"filter-tag-branches-only", "on:\n push:\n branches: ['scn/**']", tag(1, 1), false},
207
208 // filter-paths-tag: paths are not evaluated for tags.
209 {"filter-paths-tag paths-only", "on:\n push:\n paths: ['nothing/**']", tag(1, 1), true},
210 {"filter-paths-tag tag-and-paths-ignore", "on:\n push:\n tags: ['scn-*']\n paths-ignore: ['**']", tag(1, 1), true},
211 {"filter-paths-tag tag-and-paths-nomatch", "on:\n push:\n tags: ['scn-*']\n paths: ['nothing/**']", tag(1, 1), true},
212 {"filter-paths-tag branches-and-paths", "on:\n push:\n branches: ['scn/**']\n paths: ['nothing/**']", tag(1, 1), false},
213 {"filter-paths-tag branches-and-paths, branch", "on:\n push:\n branches: ['scn/**']\n paths: ['nothing/**']", branch, false},
214
215 // push-delete-branch, push-delete-tag: a deletion starts nothing,
216 // whatever the filters.
217 {"push-delete-branch", "on: push", PushInfo{Ref: "refs/heads/scn/push-delete-branch/1", Deleted: true}, false},
218 {"push-delete-tag", "on: push", PushInfo{Ref: "refs/tags/scn-push-delete-tag-1-1", Deleted: true, TagCount: 1}, false},
219 }
220 for _, tc := range tests {
221 t.Run(tc.scenario, func(t *testing.T) {
222 if got := pushWorkflow(t, tc.on).MatchPush(tc.push); got != tc.want {
223 t.Errorf("MatchPush = %v, want %v", got, tc.want)
224 }
225 })
226 }
227}
228
229// TestMatchPushRecordedGlobs replays filter-branches (branch
230// scn/filter-branches/<run>: ran for b-match, b-negated-then-readded,
231// b-prefix-only, b-star-star and b-two-levels; not for b-negated, b-one-level
232// and b-other), filter-paths and filter-paths-ignore.
233func TestMatchPushRecordedGlobs(t *testing.T) {
234 br := PushInfo{Ref: "refs/heads/scn/filter-branches/1"}
235 for _, tc := range []struct {
236 name, filter string
237 want bool
238 }{
239 {"b-match", "branches: ['scn/filter-branches/**']", true},
240 {"b-negated-then-readded", "branches: ['**', '!scn/**', 'scn/filter-branches/**']", true},
241 {"b-prefix-only", "branches: ['scn/filter-*/**']", true},
242 {"b-star-star", "branches: ['**']", true},
243 {"b-two-levels", "branches: ['scn/*/*']", true},
244 {"b-negated", "branches: ['scn/**', '!scn/filter-branches/**']", false},
245 {"b-one-level", "branches: ['scn/*']", false},
246 {"b-other", "branches: [main, 'release/**']", false},
247 } {
248 if got := pushWorkflow(t, "on:\n push:\n "+tc.filter).MatchPush(br); got != tc.want {
249 t.Errorf("filter-branches %s: %v, want %v", tc.name, got, tc.want)
250 }
251 }
252
253 // filter-paths: the files of each push.
254 pushes := [][]string{
255 {".github/workflows/p-docs.yml", "docs/readme.md", "other/x.txt", "src/a.txt", "top.md"}, // 1: the workflow files are new too
256 {"docs/readme.md"}, // 2
257 {"src/a.txt"}, // 3
258 {"top.md"}, // 4
259 {"docs/new.md", "other/x.txt"}, // 5
260 }
261 for _, tc := range []struct {
262 name, filter string
263 ran [5]bool
264 }{
265 {"p-docs", "paths: ['docs/**']", [5]bool{true, true, false, false, true}},
266 {"p-double-star-md", "paths: ['**.md']", [5]bool{true, true, false, true, true}},
267 {"p-exact-file", "paths: ['src/a.txt']", [5]bool{true, false, true, false, false}},
268 {"p-negated", "paths: ['**', '!docs/**']", [5]bool{true, false, true, true, true}},
269 {"p-no-match", "paths: ['nothing/**']", [5]bool{}},
270 {"p-single-star-md", "paths: ['*.md']", [5]bool{true, false, false, true, false}},
271 {"p-src", "paths: ['src/**']", [5]bool{true, false, true, false, false}},
272 } {
273 w := pushWorkflow(t, "on:\n push:\n branches: ['scn/**']\n "+tc.filter)
274 for n, files := range pushes {
275 got := w.MatchPush(PushInfo{Ref: "refs/heads/scn/filter-paths/1", ChangedFiles: files})
276 if got != tc.ran[n] {
277 t.Errorf("filter-paths %s push %d: %v, want %v", tc.name, n+1, got, tc.ran[n])
278 }
279 }
280 }
281
282 // filter-paths-ignore: pushes 1 to 4 changed these files.
283 ignorePushes := [][]string{
284 {".github/workflows/pi-docs.yml", "docs/readme.md", "src/a.txt"}, // 1: the workflow files are new too
285 {"docs/readme.md"}, // 2
286 {"docs/readme.md", "src/a.txt"}, // 3
287 {"src/a.txt"}, // 4
288 }
289 for _, tc := range []struct {
290 name, filter string
291 ran [4]bool
292 }{
293 {"pi-docs", "paths-ignore: ['docs/**']", [4]bool{true, false, true, true}},
294 {"pi-md", "paths-ignore: ['**.md']", [4]bool{true, false, true, true}},
295 {"pi-src-docs", "paths-ignore: ['docs/**', 'src/**']", [4]bool{true, false, false, false}},
296 {"pi-everything", "paths-ignore: ['**']", [4]bool{}},
297 } {
298 w := pushWorkflow(t, "on:\n push:\n branches: ['scn/**']\n "+tc.filter)
299 for n, files := range ignorePushes {
300 got := w.MatchPush(PushInfo{Ref: "refs/heads/scn/filter-paths-ignore/1", ChangedFiles: files})
301 if got != tc.ran[n] {
302 t.Errorf("filter-paths-ignore %s push %d: %v, want %v", tc.name, n+1, got, tc.ran[n])
303 }
304 }
305 }
306}
307
308func TestMatchPushNilSafe(t *testing.T) {
309 var w *Workflow
310 if w.MatchPush(PushInfo{Ref: "refs/heads/main"}) {
311 t.Error("nil workflow matched")
312 }
313 if (&Workflow{}).MatchPush(PushInfo{Ref: "refs/heads/main"}) {
314 t.Error("workflow without on matched")
315 }
316}
317
318func TestFilterPairsAreRejected(t *testing.T) {
319 for _, on := range []string{
320 "on:\n push:\n branches: [a]\n branches-ignore: [b]",
321 "on:\n push:\n tags: [a]\n tags-ignore: [b]",
322 "on:\n pull_request:\n paths: [a]\n paths-ignore: [b]",
323 } {
324 if _, errs := Parse("w.yml", []byte(on+"\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")); len(errs) != 1 {
325 t.Errorf("%q: errors = %v", on, errs)
326 }
327 }
328}
329
330func TestFilterValidation(t *testing.T) {
331 tail := "\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n"
332 tests := []struct{ name, on, want string }{
333 {"only negated branches", "on:\n push:\n branches: ['!main']", "Event 'push' filter 'branches' has only patterns that start with '!'"},
334 {"only negated paths", "on:\n push:\n paths: ['!docs/**', '!x']", "filter 'paths' has only patterns"},
335 {"negated with positive", "on:\n push:\n branches: ['**', '!main']", ""},
336 {"empty list", "on:\n push:\n branches: []", ""},
337 {"pull_request too", "on:\n pull_request:\n branches: ['!x']", "Event 'pull_request' filter 'branches'"},
338 }
339 for _, tc := range tests {
340 _, errs := Parse("w.yml", []byte(tc.on+tail))
341 switch {
342 case tc.want == "" && len(errs) > 0:
343 t.Errorf("%s: unexpected errors %v", tc.name, errs)
344 case tc.want != "" && (len(errs) != 1 || !strings.Contains(errs[0].Error(), tc.want)):
345 t.Errorf("%s: errors = %v, want %q", tc.name, errs, tc.want)
346 }
347 }
348}
349
350func TestFiltersAreCompiledOnce(t *testing.T) {
351 w := pushWorkflow(t, "on:\n push:\n branches: ['release/**']")
352 ev := w.On.Event("push")
353 if ev.cf == nil {
354 t.Fatal("Parse did not compile the filters")
355 }
356 first := ev.filters()
357 if ev.filters() != first || len(first.branches) != 1 {
358 t.Error("filters are recompiled on each use")
359 }
360 // An Event built by hand still works.
361 hand := &Workflow{On: &On{Events: []*Event{{Name: "push", Branches: []string{"main"}}}}}
362 if !hand.MatchPush(PushInfo{Ref: "refs/heads/main"}) || hand.MatchPush(PushInfo{Ref: "refs/heads/dev"}) {
363 t.Error("hand-built workflow does not filter")
364 }
365}