detached 334d109

fix(workflow): evaluate the first 3000 changed files of a push, not 300

commit
334d109a9ac76a812498f1440aa1a0f3d2abe9b9
author
brice · · 2026-10-12 19:13 UTC
committer
brice · · 2026-10-18 14:59 UTC
parent
c69a394
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +61 -28
M workflow/push.go +8 -4
@@ -6,9 +6,13 @@ import "strings"
66
77 // Limits GitHub applies to push events.
88 const (
9- // MaxFilesEvaluated is the number of changed files a paths filter looks
10- // at; files after the 300th are ignored.
11- MaxFilesEvaluated = 300
9+ // MaxFilesEvaluated is the number of changed files a paths or paths-ignore
10+ // filter looks at; files after the 3000th are ignored. Recorded on
11+ // github.com (filter-paths-3000-boundary): a push of exactly 3000 files
12+ // whose only match is the last one runs the workflow, the same push with
13+ // 3001 files does not. It is not 300 (filter-paths-300-files and
14+ // filter-paths-1200-files: a match beyond the 300th file still runs).
15+ MaxFilesEvaluated = 3000
1216 // MaxTagsPerPush is the number of tags that can be pushed at once before
1317 // GitHub creates no event for them.
1418 MaxTagsPerPush = 3
@@ -43,7 +47,7 @@ type PushInfo struct {
4347 // - when both a branches and a tags filter exist, a ref is checked against
4448 // the filter for its kind;
4549 // - paths and paths-ignore apply to branch pushes only, against the first
46-// 300 changed files; when the list is truncated they pass;
50+// 3000 changed files; when the list is truncated they pass;
4751 // - patterns are evaluated in order and the last one that matches wins.
4852 //
4953 // Commit-message directives are separate: see [SkipCI].
@@ -6,9 +6,13 @@ import "strings"
6 6
7 // Limits GitHub applies to push events.7 // Limits GitHub applies to push events.
8 const (8 const (
9- // MaxFilesEvaluated is the number of changed files a paths filter looks9+ // MaxFilesEvaluated is the number of changed files a paths or paths-ignore
10- // at; files after the 300th are ignored.10+ // filter looks at; files after the 3000th are ignored. Recorded on
11- MaxFilesEvaluated = 30011+ // github.com (filter-paths-3000-boundary): a push of exactly 3000 files
12+ // whose only match is the last one runs the workflow, the same push with
13+ // 3001 files does not. It is not 300 (filter-paths-300-files and
14+ // filter-paths-1200-files: a match beyond the 300th file still runs).
15+ MaxFilesEvaluated = 3000
12 // MaxTagsPerPush is the number of tags that can be pushed at once before16 // MaxTagsPerPush is the number of tags that can be pushed at once before
13 // GitHub creates no event for them.17 // GitHub creates no event for them.
14 MaxTagsPerPush = 318 MaxTagsPerPush = 3
@@ -43,7 +47,7 @@ type PushInfo struct {
43 // - when both a branches and a tags filter exist, a ref is checked against47 // - when both a branches and a tags filter exist, a ref is checked against
44 // the filter for its kind;48 // the filter for its kind;
45 // - paths and paths-ignore apply to branch pushes only, against the first49 // - paths and paths-ignore apply to branch pushes only, against the first
46-// 300 changed files; when the list is truncated they pass;50+// 3000 changed files; when the list is truncated they pass;
47 // - patterns are evaluated in order and the last one that matches wins.51 // - patterns are evaluated in order and the last one that matches wins.
48 //52 //
49 // Commit-message directives are separate: see [SkipCI].53 // Commit-message directives are separate: see [SkipCI].
M workflow/push_test.go +53 -24
@@ -93,30 +93,59 @@ func TestMatchPush(t *testing.T) {
9393 }
9494 }
9595
96-func TestMatchPushFirst300Files(t *testing.T) {
97- w := pushWorkflow(t, "on:\n push:\n paths: ['src/**']")
98- files := make([]string, 0, 301)
99- for i := 0; i < MaxFilesEvaluated; i++ {
100- files = append(files, fmt.Sprintf("docs/%d.md", i))
101- }
102- files = append(files, "src/late.go") // the 301st
103- if w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) {
104- t.Error("a match beyond the 300th file must not run the workflow")
105- }
106- files[299] = "src/early.go"
107- if !w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) {
108- t.Error("a match among the first 300 files must run the workflow")
109- }
110- wi := pushWorkflow(t, "on:\n push:\n paths-ignore: ['docs/**']")
111- files = files[:0]
112- for i := 0; i < MaxFilesEvaluated; i++ {
113- files = append(files, fmt.Sprintf("docs/%d.md", i))
114- }
115- files = append(files, "src/late.go")
116- // TODO(verify with recording): with paths-ignore, files beyond the 300th
117- // are not considered either, so everything seen is ignored.
118- if wi.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) {
119- t.Error("paths-ignore: only the first 300 files are evaluated")
96+// numberedFiles returns n paths "dir/f-00000.txt" ... in the order of the diff,
97+// which for GitHub is sorted by path.
98+func 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.
110+func 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+ }
120149 }
121150 }
122151
@@ -93,30 +93,59 @@ func TestMatchPush(t *testing.T) {
93 }93 }
94 }94 }
95 95
96-func TestMatchPushFirst300Files(t *testing.T) {96+// numberedFiles returns n paths "dir/f-00000.txt" ... in the order of the diff,
97- w := pushWorkflow(t, "on:\n push:\n paths: ['src/**']")97+// which for GitHub is sorted by path.
98- files := make([]string, 0, 301)98+func numberedFiles(dir string, n int) []string {
99- for i := 0; i < MaxFilesEvaluated; i++ {99+ files := make([]string, 0, n+1)
100- files = append(files, fmt.Sprintf("docs/%d.md", i))100+ for i := 0; i < n; i++ {
101- }101+ files = append(files, fmt.Sprintf("%s/f-%05d.txt", dir, i))
102- files = append(files, "src/late.go") // the 301st102+ }
103- if w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) {103+ return files
104- t.Error("a match beyond the 300th file must not run the workflow")104+}
105- }105+
106- files[299] = "src/early.go"106+// TestMatchPushFileLimit follows the recordings filter-paths-300-files,
107- if !w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) {107+// filter-paths-1200-files, filter-paths-3000-boundary and
108- t.Error("a match among the first 300 files must run the workflow")108+// filter-paths-3100-files: a paths or paths-ignore filter looks at the first
109- }109+// 3000 changed files, whose only match (or only file not ignored) sorts last.
110- wi := pushWorkflow(t, "on:\n push:\n paths-ignore: ['docs/**']")110+func TestMatchPushFileLimit(t *testing.T) {
111- files = files[:0]111+ if MaxFilesEvaluated != 3000 {
112- for i := 0; i < MaxFilesEvaluated; i++ {112+ t.Fatalf("MaxFilesEvaluated = %d, want 3000", MaxFilesEvaluated)
113- files = append(files, fmt.Sprintf("docs/%d.md", i))113+ }
114- }114+ paths := pushWorkflow(t, "on:\n push:\n paths: ['zzz/**']")
115- files = append(files, "src/late.go")115+ early := pushWorkflow(t, "on:\n push:\n paths: ['gen/**']")
116- // TODO(verify with recording): with paths-ignore, files beyond the 300th116+ ignore := pushWorkflow(t, "on:\n push:\n paths-ignore: ['gen/**']")
117- // are not considered either, so everything seen is ignored.117+ for _, tc := range []struct {
118- if wi.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) {118+ scenario string
119- t.Error("paths-ignore: only the first 300 files are evaluated")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+ }
120 }149 }
121 }150 }
122 151

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