Skip to content

Commit c1bb6af

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

File tree

3 files changed

+251
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)