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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +61 -28
M workflow/push.go +8 -4
| @@ -6,9 +6,13 @@ import "strings" | |||
| 6 | 6 | ||
| 7 | 7 | // Limits GitHub applies to push events. | |
| 8 | 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 | |
| 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 | |
| 12 | 16 | // MaxTagsPerPush is the number of tags that can be pushed at once before | |
| 13 | 17 | // GitHub creates no event for them. | |
| 14 | 18 | MaxTagsPerPush = 3 | |
| @@ -43,7 +47,7 @@ type PushInfo struct { | |||
| 43 | 47 | // - when both a branches and a tags filter exist, a ref is checked against | |
| 44 | 48 | // the filter for its kind; | |
| 45 | 49 | // - 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 | 51 | // - patterns are evaluated in order and the last one that matches wins. | |
| 48 | 52 | // | |
| 49 | 53 | // 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 looks | 9 | + | // 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 = 300 | 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 | |||
| 12 | // MaxTagsPerPush is the number of tags that can be pushed at once before | 16 | // 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 = 3 | 18 | MaxTagsPerPush = 3 | ||
| @@ -43,7 +47,7 @@ type PushInfo struct { | |||||
| 43 | // - when both a branches and a tags filter exist, a ref is checked against | 47 | // - 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 first | 49 | // - 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) { | |||
| 93 | 93 | } | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 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 | + | } | |
| 120 | 149 | } | |
| 121 | 150 | } | |
| 122 | 151 | ||
| @@ -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 301st | 102 | + | } |
| 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 300th | 116 | + | 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 | ||||