main 98594d0

push_test.go RawHistoryFolder

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}

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