Skip to content

Commit 345cc41

Browse files
committed
ci: skip E2E tests for docs and YAML-only changes
1 parent 43f1f5e commit 345cc41

File tree

3 files changed

+267
-0
lines changed

3 files changed

+267
-0
lines changed

test/e2e/alphagenerate/e2e_suite_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222

2323
. "github.com/onsi/ginkgo/v2"
2424
. "github.com/onsi/gomega"
25+
26+
"sigs.k8s.io/kubebuilder/v4/test/e2e/utils"
2527
)
2628

2729
// Run e2e tests using the Ginkgo runner.
@@ -30,3 +32,14 @@ func TestE2E(t *testing.T) {
3032
_, _ = fmt.Fprintf(GinkgoWriter, "Starting kubebuilder suite test for the alpha command generate\n")
3133
RunSpecs(t, "Kubebuilder alpha generate suite")
3234
}
35+
36+
var _ = BeforeSuite(func() {
37+
run, why, _ := utils.ShouldRun(utils.Options{
38+
RepoRoot: ".",
39+
Includes: []string{"pkg/cli/alpha/", "test/e2e/alphagenerate/"},
40+
SkipIfOnlyDocsYAML: true,
41+
})
42+
if !run {
43+
Skip("skip: " + why)
44+
}
45+
})

test/e2e/alphaupdate/e2e_suite_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222

2323
. "github.com/onsi/ginkgo/v2"
2424
. "github.com/onsi/gomega"
25+
26+
"sigs.k8s.io/kubebuilder/v4/test/e2e/utils"
2527
)
2628

2729
// Run e2e tests using the Ginkgo runner.
@@ -30,3 +32,14 @@ func TestE2E(t *testing.T) {
3032
_, _ = fmt.Fprintf(GinkgoWriter, "Starting kubebuilder suite test for the alpha update command\n")
3133
RunSpecs(t, "Kubebuilder alpha update suite")
3234
}
35+
36+
var _ = BeforeSuite(func() {
37+
run, why, _ := utils.ShouldRun(utils.Options{
38+
RepoRoot: ".",
39+
Includes: []string{"pkg/cli/alpha/", "test/e2e/alphaupdate/"},
40+
SkipIfOnlyDocsYAML: true,
41+
})
42+
if !run {
43+
Skip("skip: " + why)
44+
}
45+
})

test/e2e/utils/suite_filter.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package utils
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"errors"
23+
"fmt"
24+
"log"
25+
"os"
26+
"os/exec"
27+
"path/filepath"
28+
"regexp"
29+
"strings"
30+
"time"
31+
)
32+
33+
// Options defines filters and behavior for change detection.
34+
type Options struct {
35+
RepoRoot string
36+
Includes []string
37+
IncludeIsRegex bool
38+
SkipIfOnlyDocsYAML bool
39+
BaseEnvVar string
40+
HeadEnvVar string
41+
ChangedFilesEnvVar string
42+
}
43+
44+
// changedFiles holds a normalized list of changed file paths.
45+
type changedFiles struct {
46+
files []string
47+
}
48+
49+
// ShouldRun determines whether the current E2E suite should run, returning a boolean,
50+
// a human-readable reason, and an error if one occurred.
51+
func ShouldRun(opts Options) (bool, string, error) {
52+
validateAndNormalizeOpts(&opts)
53+
// Check CI environment first.
54+
if raw := strings.TrimSpace(os.Getenv(opts.ChangedFilesEnvVar)); raw != "" {
55+
return decide(parseChangedFiles(raw), opts)
56+
}
57+
58+
base := os.Getenv(opts.BaseEnvVar)
59+
head := os.Getenv(opts.HeadEnvVar)
60+
if head == "" {
61+
head = "HEAD"
62+
}
63+
64+
cwd, headDiffErr := os.Getwd()
65+
if headDiffErr != nil {
66+
log.Fatalf("failed to get current working directory: %v", headDiffErr)
67+
}
68+
// restore original directory at the end
69+
defer func(originalDir string) {
70+
if chdirErr := os.Chdir(originalDir); chdirErr != nil {
71+
log.Printf("WARNING: failed to restore working directory to %q: %v", originalDir, chdirErr)
72+
}
73+
}(cwd)
74+
75+
// Confirm RepoRoot exists.
76+
if info, statErr := os.Stat(opts.RepoRoot); statErr != nil {
77+
return true, "repo root path invalid or inaccessible", fmt.Errorf("stat repo root: %w", statErr)
78+
} else if !info.IsDir() {
79+
return true, "repo root path is not a directory", errors.New("repo root not a directory")
80+
}
81+
82+
// Resolve base commit SHA if not set.
83+
if base == "" {
84+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
85+
defer cancel()
86+
87+
if fetchErr := gitFetchOriginMaster(ctx, opts.RepoRoot); fetchErr != nil {
88+
// log warning, but don't fail; fallback handled below
89+
logWarning(fmt.Sprintf("git fetch origin/master failed: %v", fetchErr))
90+
}
91+
92+
b, resolveBaseErr := gitResolveBaseRef(ctx, opts.RepoRoot, head)
93+
if resolveBaseErr == nil && b != "" {
94+
base = b
95+
} else {
96+
base = head + "~1" // fallback
97+
}
98+
}
99+
100+
// Diff changed files between base and head.
101+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
102+
defer cancel()
103+
104+
out, baseDiffErr := gitDiffNames(ctx, opts.RepoRoot, base, head)
105+
if baseDiffErr != nil {
106+
// fallback to diff head~1. head
107+
out, headDiffErr = gitDiffNames(ctx, opts.RepoRoot, head+"~1", head)
108+
if headDiffErr != nil {
109+
return true, "diff failed; default to run", fmt.Errorf("git diff failed: %w", headDiffErr)
110+
}
111+
}
112+
113+
return decide(parseChangedFiles(string(out)), opts)
114+
}
115+
116+
func validateAndNormalizeOpts(opts *Options) {
117+
if opts.RepoRoot == "" {
118+
opts.RepoRoot = "."
119+
}
120+
if opts.BaseEnvVar == "" {
121+
opts.BaseEnvVar = "PULL_BASE_SHA"
122+
}
123+
if opts.HeadEnvVar == "" {
124+
opts.HeadEnvVar = "PULL_PULL_SHA"
125+
}
126+
if opts.ChangedFilesEnvVar == "" {
127+
opts.ChangedFilesEnvVar = "KUBEBUILDER_CHANGED_FILES"
128+
}
129+
}
130+
131+
func logWarning(msg string) {
132+
_, err := fmt.Fprintf(os.Stderr, "WARNING: %s\n", msg)
133+
if err != nil {
134+
return
135+
}
136+
}
137+
138+
// parseChangedFiles splits raw changed file data into normalized paths.
139+
func parseChangedFiles(raw string) changedFiles {
140+
lines := strings.Split(strings.TrimSpace(raw), "\n")
141+
files := make([]string, 0, len(lines))
142+
for _, line := range lines {
143+
line = strings.TrimSpace(line)
144+
if line != "" {
145+
files = append(files, filepath.ToSlash(line))
146+
}
147+
}
148+
return changedFiles{files: files}
149+
}
150+
151+
// decide determines if the suite should run based on changed files and options.
152+
func decide(ch changedFiles, opts Options) (bool, string, error) {
153+
if len(ch.files) == 0 {
154+
return true, "no changes detected; running tests", nil
155+
}
156+
157+
if opts.SkipIfOnlyDocsYAML && onlyDocsOrYAML(ch.files) {
158+
return false, "only documentation or YAML files changed; skipping tests", nil
159+
}
160+
161+
if len(opts.Includes) == 0 {
162+
return true, "no include filters specified; running tests", nil
163+
}
164+
165+
if opts.IncludeIsRegex {
166+
pattern := "^(" + strings.Join(opts.Includes, "|") + ")"
167+
re, err := regexp.Compile(pattern)
168+
if err != nil {
169+
return false, "invalid include regex pattern", fmt.Errorf("compile regex %q: %w", pattern, err)
170+
}
171+
172+
for _, file := range ch.files {
173+
if re.MatchString(file) {
174+
return true, "matched include regex pattern: " + re.String(), nil
175+
}
176+
}
177+
return false, "no files matched include regex patterns", nil
178+
}
179+
180+
for _, file := range ch.files {
181+
for _, include := range opts.Includes {
182+
if strings.HasPrefix(file, filepath.ToSlash(include)) {
183+
return true, "matched include prefix: " + include, nil
184+
}
185+
}
186+
}
187+
188+
return false, "no files matched include prefixes", nil
189+
}
190+
191+
func onlyDocsOrYAML(files []string) bool {
192+
pattern := `(?i)(^docs/|\.md$|\.markdown$|^\.github/|` +
193+
`(OWNERS|OWNERS_ALIASES|SECURITY_CONTACTS|LICENSE)(\.md)?$|\.ya?ml$)`
194+
re := regexp.MustCompile(pattern)
195+
for _, file := range files {
196+
if !re.MatchString(file) {
197+
return false
198+
}
199+
}
200+
return true
201+
}
202+
203+
// gitFetchOriginMaster runs `git fetch origin master --quiet`.
204+
func gitFetchOriginMaster(ctx context.Context, repoRoot string) error {
205+
cmd := exec.CommandContext(ctx, "git", "fetch", "origin", "master", "--quiet")
206+
cmd.Dir = repoRoot
207+
if originFetchErr := cmd.Run(); originFetchErr != nil {
208+
return fmt.Errorf("git fetch origin master failed: %w", originFetchErr)
209+
}
210+
return nil
211+
}
212+
213+
// gitResolveBaseRef returns the merge-base commit SHA of head and origin/master.
214+
func gitResolveBaseRef(ctx context.Context, repoRoot, head string) (string, error) {
215+
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "--quiet", "origin/master")
216+
cmd.Dir = repoRoot
217+
out, err := cmd.CombinedOutput()
218+
if err != nil || len(bytes.TrimSpace(out)) == 0 {
219+
return "", errors.New("origin/master ref not found")
220+
}
221+
222+
mergeBaseCmd := exec.CommandContext(ctx, "git", "merge-base", head, "origin/master")
223+
mergeBaseCmd.Dir = repoRoot
224+
mbOut, err := mergeBaseCmd.Output()
225+
if err != nil {
226+
return "", fmt.Errorf("git merge-base failed: %w", err)
227+
}
228+
229+
return strings.TrimSpace(string(mbOut)), nil
230+
}
231+
232+
// gitDiffNames returns the list of changed files between base and head commits.
233+
func gitDiffNames(ctx context.Context, repoRoot, base, head string) ([]byte, error) {
234+
cmd := exec.CommandContext(ctx, "git", "diff", "--name-only", base, head)
235+
cmd.Dir = repoRoot
236+
out, outErr := cmd.Output()
237+
if outErr != nil {
238+
return nil, fmt.Errorf("git diff failed: %w", outErr)
239+
}
240+
return out, nil
241+
}

0 commit comments

Comments
 (0)