diff --git a/.github/workflows/affected-tests.yml b/.github/workflows/affected-tests.yml new file mode 100644 index 000000000..138fc1887 --- /dev/null +++ b/.github/workflows/affected-tests.yml @@ -0,0 +1,192 @@ +name: Affected Go Tests + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + contents: read + +jobs: + test-affected: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + unit_has_changes: ${{ steps.affected.outputs.has_changes }} + e2e_has_changes: ${{ steps.affected_e2e.outputs.has_changes }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Go Mod Download + run: go mod download + + + - name: Compute base ref + id: pr-info + run: | + echo "BASE_REF=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT" + echo "Base ref: origin/${{ github.base_ref }}" + + # 2) Detect relevant unit packages and e2e tests + - name: Compute affected packages + id: affected + run: | + set -euo pipefail + echo "Base: ${{ steps.pr-info.outputs.BASE_REF }}" + # Generate affected package list to a file for reuse in subsequent steps + go run ./scripts/affected-packages.go -base "${{ steps.pr-info.outputs.BASE_REF }}" > /tmp/affected.txt + echo "Affected packages:" || true + if [ -s /tmp/affected.txt ]; then + cat /tmp/affected.txt + else + echo "(none)" + fi + # Expose whether we have any packages to test + if [ -s /tmp/affected.txt ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Compute affected e2e tests + id: affected_e2e + run: | + set -euo pipefail + go run ./scripts/affected-packages.go -mode=suites -base "${{ steps.pr-info.outputs.BASE_REF }}" > /tmp/affected-e2e.txt + awk -F: '$1=="preflight"{print $2}' /tmp/affected-e2e.txt > /tmp/preflight-tests.txt + awk -F: '$1=="support-bundle"{print $2}' /tmp/affected-e2e.txt > /tmp/support-tests.txt + if [ -s /tmp/preflight-tests.txt ] || [ -s /tmp/support-tests.txt ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish affected summary + if: always() + run: | + { + echo "### Affected unit packages"; + if [ -s /tmp/affected.txt ]; then + sed 's/^/- /' /tmp/affected.txt; + else + echo "- (none)"; + fi; + echo; + echo "### Affected e2e tests"; + if [ -s /tmp/affected-e2e.txt ]; then + sed 's/^/- /' /tmp/affected-e2e.txt; + else + echo "- (none)"; + fi; + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload affected unit packages + uses: actions/upload-artifact@v4 + with: + name: affected-unit + path: /tmp/affected.txt + if-no-files-found: warn + + - name: Upload affected e2e artifacts + uses: actions/upload-artifact@v4 + with: + name: affected-e2e + path: | + /tmp/affected-e2e.txt + /tmp/preflight-tests.txt + /tmp/support-tests.txt + if-no-files-found: warn + + + - name: No affected packages — skip tests + if: steps.affected.outputs.has_changes != 'true' + run: echo "No Go packages affected by this PR; skipping tests." + + e2e-affected: + needs: test-affected + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + suite: [unit, preflight, support-bundle] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Go Mod Download + run: go mod download + + - name: Download affected unit packages + uses: actions/download-artifact@v4 + with: + name: affected-unit + path: /tmp + + - name: Download affected e2e artifacts + uses: actions/download-artifact@v4 + with: + name: affected-e2e + path: /tmp + + - name: Run unit tests (filtered) + if: matrix.suite == 'unit' && needs.test-affected.outputs.unit_has_changes == 'true' + run: | + set -euo pipefail + if grep -qx "./..." /tmp/affected.txt; then + echo "Module files changed; running all unit tests" + make test + else + echo "Running unit tests for affected packages" + pkgs=$(tr '\n' ' ' < /tmp/affected.txt) + PACKAGES="$pkgs" make test-packages + fi + + + - name: Run e2e (filtered) - ${{ matrix.suite }} + if: matrix.suite != 'unit' && needs.test-affected.outputs.e2e_has_changes == 'true' + run: | + set -euo pipefail + docker rm -f kind-cluster-control-plane 2>/dev/null || true + if [ "${{ matrix.suite }}" = "preflight" ]; then + file=/tmp/preflight-tests.txt + path=./test/e2e/preflight + else + file=/tmp/support-tests.txt + path=./test/e2e/support-bundle + fi + if [ -s "$file" ]; then + regex="$(grep -v '^$' "$file" | tr '\n' '|' | sed 's/|$//')" + if [ -n "$regex" ]; then + if [ "${{ matrix.suite }}" = "preflight" ]; then + E2EPATHS="$path" RUN="^(${regex})$" make preflight-e2e-go-test + else + E2EPATHS="$path" RUN="^(${regex})$" make support-bundle-e2e-go-test + fi + else + echo "No valid ${{ matrix.suite }} tests matched after filtering" + fi + else + echo "No ${{ matrix.suite }} e2e changes" + fi + + diff --git a/.github/workflows/build-test-deploy.yaml b/.github/workflows/build-test-deploy.yaml index d9ff051e3..7ae690a37 100644 --- a/.github/workflows/build-test-deploy.yaml +++ b/.github/workflows/build-test-deploy.yaml @@ -37,6 +37,7 @@ jobs: - run: make tidy-diff test-integration: + if: github.event_name == 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -65,6 +66,7 @@ jobs: path: bin/preflight validate-preflight-e2e: + if: github.event_name == 'push' runs-on: ubuntu-latest needs: compile-preflight steps: @@ -95,6 +97,7 @@ jobs: path: bin/support-bundle validate-supportbundle-e2e: + if: github.event_name == 'push' runs-on: ubuntu-latest needs: compile-supportbundle steps: @@ -113,9 +116,10 @@ jobs: # Additional e2e tests for support bundle that run in Go, these create a Kind cluster validate-supportbundle-e2e-go: - runs-on: ubuntu-latest - needs: compile-supportbundle - steps: + if: github.event_name == 'push' + runs-on: ubuntu-latest + needs: compile-supportbundle + steps: - uses: actions/checkout@v5 - name: Download support bundle binary uses: actions/download-artifact@v5 @@ -133,6 +137,7 @@ jobs: # summary jobs, these jobs will only run if all the other jobs have succeeded validate-pr-tests: + if: github.event_name == 'push' runs-on: ubuntu-latest needs: - tidy-check @@ -147,10 +152,10 @@ jobs: # this job will validate that the validation did not fail and that all pr-tests succeed # it is used for the github branch protection rule validate-success: + if: ${{ always() && github.event_name == 'push' }} runs-on: ubuntu-latest needs: - validate-pr-tests - if: always() steps: # https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context # if the validate-pr-tests job was not successful, this job will fail diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build.yaml similarity index 61% rename from .github/workflows/build-test.yaml rename to .github/workflows/build.yaml index 7c3f8bf3a..2ad6570d1 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build.yaml @@ -1,4 +1,4 @@ -name: build-test +name: build on: pull_request: @@ -47,6 +47,19 @@ jobs: - uses: actions/checkout@v5 - uses: ./.github/actions/setup-go + - name: Cache Go build and modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Go mod download + run: go mod download + - name: Check go mod tidy run: | go mod tidy @@ -65,22 +78,7 @@ jobs: make vet # Unit and integration tests - test: - if: needs.changes.outputs.go-files == 'true' - needs: [changes, lint] - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v5 - - uses: ./.github/actions/setup-go - - - name: Setup K3s - uses: replicatedhq/action-k3s@main - with: - version: v1.31.2-k3s1 - - - name: Run tests - run: make test-integration + # (moved to push-full-tests.yml) # Build binaries build: @@ -91,71 +89,46 @@ jobs: steps: - uses: actions/checkout@v5 - uses: ./.github/actions/setup-go - - run: make build - - uses: actions/upload-artifact@v4 - with: - name: binaries - path: bin/ - retention-days: 1 - - # E2E tests - e2e: - if: needs.changes.outputs.go-files == 'true' || github.event_name == 'push' - needs: [changes, build] - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - include: - - name: preflight - target: preflight-e2e-test - needs-k3s: true - - name: support-bundle-shell - target: support-bundle-e2e-test - needs-k3s: true - - name: support-bundle-go - target: support-bundle-e2e-go-test - needs-k3s: false - steps: - - uses: actions/checkout@v5 - - name: Setup K3s - if: matrix.needs-k3s - uses: replicatedhq/action-k3s@main + - name: Cache Go build and modules + uses: actions/cache@v4 with: - version: v1.31.2-k3s1 + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- - - uses: actions/download-artifact@v4 + - name: Go mod download + run: go mod download + - run: make build + - uses: actions/upload-artifact@v4 with: name: binaries path: bin/ + retention-days: 1 - - run: chmod +x bin/* - - run: make ${{ matrix.target }} + # (moved to push-full-tests.yml) # Success summary success: if: always() - needs: [lint, test, build, e2e] + needs: [lint, build] runs-on: ubuntu-latest steps: - name: Check results run: | # Check if any required jobs failed if [[ "${{ needs.lint.result }}" == "failure" ]] || \ - [[ "${{ needs.test.result }}" == "failure" ]] || \ - [[ "${{ needs.build.result }}" == "failure" ]] || \ - [[ "${{ needs.e2e.result }}" == "failure" ]]; then + [[ "${{ needs.build.result }}" == "failure" ]]; then echo "::error::Some jobs failed or were cancelled" exit 1 fi # Check if any required jobs were cancelled if [[ "${{ needs.lint.result }}" == "cancelled" ]] || \ - [[ "${{ needs.test.result }}" == "cancelled" ]] || \ - [[ "${{ needs.build.result }}" == "cancelled" ]] || \ - [[ "${{ needs.e2e.result }}" == "cancelled" ]]; then + [[ "${{ needs.build.result }}" == "cancelled" ]]; then echo "::error::Some jobs failed or were cancelled" exit 1 fi diff --git a/.github/workflows/push-full-tests.yml b/.github/workflows/push-full-tests.yml new file mode 100644 index 000000000..e8396d9eb --- /dev/null +++ b/.github/workflows/push-full-tests.yml @@ -0,0 +1,77 @@ +name: push-full-tests + +on: + push: + branches: [main] + +jobs: + unit-integration: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-go + - name: Setup K3s + uses: replicatedhq/action-k3s@main + with: + version: v1.31.2-k3s1 + - name: Run tests + run: make test-integration + + build-binaries: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-go + - name: Cache Go build and modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Go mod download + run: go mod download + - name: Build binaries + run: make build + - uses: actions/upload-artifact@v4 + with: + name: binaries + path: bin/ + retention-days: 1 + + e2e: + needs: [unit-integration, build-binaries] + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - name: preflight + target: preflight-e2e-test + needs-k3s: true + - name: support-bundle-shell + target: support-bundle-e2e-test + needs-k3s: true + - name: support-bundle-go + target: support-bundle-e2e-go-test + needs-k3s: false + steps: + - uses: actions/checkout@v5 + - uses: actions/download-artifact@v4 + with: + name: binaries + path: bin/ + - run: chmod +x bin/* + - name: Setup K3s + if: matrix.needs-k3s + uses: replicatedhq/action-k3s@main + with: + version: v1.31.2-k3s1 + - run: make ${{ matrix.target }} + + diff --git a/.github/workflows/regression-test.yaml b/.github/workflows/regression-test.yaml index a19e197f1..991c803dc 100644 --- a/.github/workflows/regression-test.yaml +++ b/.github/workflows/regression-test.yaml @@ -2,7 +2,7 @@ name: Regression Test Suite on: push: - branches: [main, v1beta3] + branches: [main] pull_request: types: [opened, synchronize, reopened] workflow_dispatch: @@ -289,4 +289,4 @@ jobs: continue-on-error: true with: api-token: ${{ secrets.REPLICATED_API_TOKEN }} - cluster-id: ${{ steps.create-cluster.outputs.cluster-id }} + cluster-id: ${{ steps.create-cluster.outputs.cluster-id }} \ No newline at end of file diff --git a/Makefile b/Makefile index 2588f3bfa..db7d556f8 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ endef BUILDTAGS = "netgo containers_image_ostree_stub exclude_graphdriver_devicemapper exclude_graphdriver_btrfs containers_image_openpgp" BUILDFLAGS = -tags ${BUILDTAGS} -installsuffix netgo BUILDPATHS = ./pkg/... ./cmd/... ./internal/... -E2EPATHS = ./test/e2e/... +E2EPATHS ?= ./test/e2e/... TESTFLAGS ?= -v -coverprofile cover.out .DEFAULT_GOAL := all @@ -49,12 +49,23 @@ ffi: fmt vet .PHONY: test test: generate fmt vet - if [ -n $(RUN) ]; then \ - go test ${BUILDFLAGS} ${BUILDPATHS} ${TESTFLAGS} -run $(RUN); \ + if [ -n "$(RUN)" ]; then \ + go test ${BUILDFLAGS} ${BUILDPATHS} ${TESTFLAGS} -run "$(RUN)"; \ else \ go test ${BUILDFLAGS} ${BUILDPATHS} ${TESTFLAGS}; \ fi +# Run unit tests only for a provided list of packages. +# Usage: make test-packages PACKAGES="pkg/a pkg/b cmd/foo" +.PHONY: test-packages +test-packages: + @if [ -z "$(PACKAGES)" ]; then \ + echo "No PACKAGES provided; nothing to test."; \ + exit 0; \ + fi + @echo "Running unit tests for packages: $(PACKAGES)" + go test ${BUILDFLAGS} $(PACKAGES) ${TESTFLAGS} + # Go tests that require a K8s instance # TODOLATER: merge with test, so we get unified coverage reports? it'll add 21~sec to the test job though... .PHONY: test-integration @@ -73,10 +84,18 @@ run-examples: support-bundle-e2e-test: ./test/validate-support-bundle-e2e.sh +.PHONY: preflight-e2e-go-test +preflight-e2e-go-test: bin/preflight + if [ -n "$(RUN)" ]; then \ + go test ${BUILDFLAGS} ${E2EPATHS} -v -run "$(RUN)"; \ + else \ + go test ${BUILDFLAGS} ${E2EPATHS} -v; \ + fi + .PHONY: support-bundle-e2e-go-test -support-bundle-e2e-go-test: - if [ -n $(RUN) ]; then \ - go test ${BUILDFLAGS} ${E2EPATHS} -v -run $(RUN); \ +support-bundle-e2e-go-test: bin/support-bundle + if [ -n "$(RUN)" ]; then \ + go test ${BUILDFLAGS} ${E2EPATHS} -v -run "$(RUN)"; \ else \ go test ${BUILDFLAGS} ${E2EPATHS} -v; \ fi diff --git a/scripts/affected-packages.go b/scripts/affected-packages.go new file mode 100644 index 000000000..e3fe752dd --- /dev/null +++ b/scripts/affected-packages.go @@ -0,0 +1,549 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// goListPackageJSON models the subset of fields we need from `go list -json` output. +// The JSON can be quite large; we intentionally only decode what we use to keep memory reasonable. +type goListPackageJSON struct { + ImportPath string `json:"ImportPath"` + Deps []string `json:"Deps"` +} + +// runCommand executes a command and returns stdout as bytes with trimmed trailing newline. +func runCommand(name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return nil, err + } + return bytes.TrimRight(out, "\n"), nil +} + +// listPackageWithDeps returns the full transitive dependency set for a package import path, +// including the package itself. +func listPackageWithDeps(importPath string) (map[string]struct{}, error) { + cmd := exec.Command("go", "list", "-json", "-deps", importPath) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("go list -json -deps %s failed: %w", importPath, err) + } + deps := make(map[string]struct{}) + dec := json.NewDecoder(bytes.NewReader(out)) + for { + var pkg goListPackageJSON + if err := dec.Decode(&pkg); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("decode go list json: %w", err) + } + if pkg.ImportPath != "" { + deps[pkg.ImportPath] = struct{}{} + } + for _, d := range pkg.Deps { + deps[d] = struct{}{} + } + } + return deps, nil +} + +// changedFiles returns a slice of file paths changed between baseRef and HEAD. +func changedFiles(baseRef string) ([]string, error) { + // Use triple-dot to include merge base with baseRef, typical for PR diffs. + out, err := runCommand("git", "diff", "--name-only", baseRef+"...HEAD") + if err != nil { + return nil, fmt.Errorf("git diff failed: %w", err) + } + var files []string + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + files = append(files, line) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return files, nil +} + +// mapFilesToPackages resolves a set of Go package import paths that directly contain the changed Go files. +func mapFilesToPackages(files []string) (map[string]struct{}, error) { + packages := make(map[string]struct{}) + // Collect unique directories that contain changed Go files. + dirSet := make(map[string]struct{}) + for _, f := range files { + if strings.HasPrefix(f, "vendor/") { + continue + } + if filepath.Ext(f) != ".go" { + continue + } + d := filepath.Dir(f) + if d == "." { + d = "." + } + dirSet[d] = struct{}{} + } + if len(dirSet) == 0 { + return packages, nil + } + + // Convert to a stable-ordered slice of directories to avoid nondeterminism. + var dirs []string + for d := range dirSet { + // Ensure relative paths are treated as packages; prepend ./ for clarity. + if strings.HasPrefix(d, "./") || d == "." { + dirs = append(dirs, d) + } else { + dirs = append(dirs, "./"+d) + } + } + sort.Strings(dirs) + + // `go list` accepts directories and returns their package import paths. + args := append([]string{"list", "-f", "{{.ImportPath}}"}, dirs...) + out, err := runCommand("go", args...) + if err != nil { + return nil, fmt.Errorf("go list for files failed: %w", err) + } + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + pkg := strings.TrimSpace(scanner.Text()) + if pkg != "" { + packages[pkg] = struct{}{} + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return packages, nil +} + +// computeAffectedPackages expands directly changed packages to include reverse dependencies across the module. +// We query all packages with test dependencies (-test) to ensure test-only imports are considered. +func computeAffectedPackages(directPkgs map[string]struct{}) (map[string]struct{}, error) { + affected := make(map[string]struct{}) + for p := range directPkgs { + affected[p] = struct{}{} + } + if len(directPkgs) == 0 { + return affected, nil + } + + // Enumerate all packages in the module with their deps. + // We stream decode concatenated JSON objects produced by `go list -json`. + cmd := exec.Command("go", "list", "-json", "-deps", "-test", "./...") + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("go list -json failed: %w", err) + } + + dec := json.NewDecoder(bytes.NewReader(out)) + for { + var pkg goListPackageJSON + if err := dec.Decode(&pkg); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("decode go list json: %w", err) + } + // If this package is directly changed, it's already included. + // If it depends (directly or transitively) on any changed package, include it. + for changed := range directPkgs { + if pkg.ImportPath == changed { + affected[pkg.ImportPath] = struct{}{} + break + } + // Linear scan over deps is acceptable given typical package counts. + for _, dep := range pkg.Deps { + if dep == changed { + affected[pkg.ImportPath] = struct{}{} + break + } + } + } + } + return affected, nil +} + +// listTestFunctions scans a directory for Go test files and returns names of functions +// that match the pattern `func TestXxx(t *testing.T)`. +func listTestFunctions(dir string) ([]string, error) { + var tests []string + // Regex to capture test function names. This is a simple heuristic suitable for our codebase. + testFuncRe := regexp.MustCompile(`^func\s+(Test[\w\d_]+)\s*\(`) + + walkFn := func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(d.Name(), "_test.go") { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + scanner := bufio.NewScanner(bytes.NewReader(b)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if m := testFuncRe.FindStringSubmatch(line); m != nil { + tests = append(tests, m[1]) + } + } + return scanner.Err() + } + + if err := filepath.WalkDir(dir, walkFn); err != nil { + return nil, err + } + sort.Strings(tests) + return tests, nil +} + +func main() { + baseRef := flag.String("base", "origin/main", "Git base ref to diff against (e.g., origin/main)") + printAllOnChanges := flag.Bool("all-on-mod-change", true, "Run all tests if go.mod or go.sum changed") + verbose := flag.Bool("v", false, "Enable verbose diagnostics to stderr") + mode := flag.String("mode", "packages", "Output mode: 'packages' to print import paths; 'suites' to print e2e suite names") + changedFilesCSV := flag.String("changed-files", "", "Comma-separated paths to treat as changed (bypass git)") + changedFilesFile := flag.String("changed-files-file", "", "File with newline-separated paths to treat as changed") + flag.Parse() + + // Determine the set of changed files: explicit list if provided, otherwise via git diff. + var files []string + if *changedFilesCSV != "" || *changedFilesFile != "" { + if *changedFilesCSV != "" { + parts := strings.Split(*changedFilesCSV, ",") + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + files = append(files, s) + } + } + } + if *changedFilesFile != "" { + b, err := os.ReadFile(*changedFilesFile) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + scanner := bufio.NewScanner(bytes.NewReader(b)) + for scanner.Scan() { + if s := strings.TrimSpace(scanner.Text()); s != "" { + files = append(files, s) + } + } + if err := scanner.Err(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + } + } else { + var err error + files, err = changedFiles(*baseRef) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + } + if *verbose { + fmt.Fprintln(os.Stderr, "Changed files vs base:") + if len(files) == 0 { + fmt.Fprintln(os.Stderr, " (none)") + } else { + for _, f := range files { + fmt.Fprintln(os.Stderr, " ", f) + } + } + } + + // Track module change and CI configuration changes to drive conservative behavior. + moduleChanged := false + ciChanged := false + if *printAllOnChanges { + for _, f := range files { + if f == "go.mod" || f == "go.sum" { + moduleChanged = true + } + if strings.HasPrefix(f, "scripts/") || strings.HasPrefix(f, ".github/workflows/") { + ciChanged = true + } + } + if (moduleChanged || ciChanged) && *mode == "packages" { + if *verbose { + if moduleChanged { + fmt.Fprintln(os.Stderr, "Detected module file change (go.mod/go.sum); selecting all packages ./...") + } + if ciChanged { + fmt.Fprintln(os.Stderr, "Detected CI/detector change (scripts/ or .github/workflows/); selecting all packages ./...") + } + } + fmt.Println("./...") + return + } + } + + directPkgs, err := mapFilesToPackages(files) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if *verbose { + // Stable dump of direct packages + var dirs []string + for p := range directPkgs { + dirs = append(dirs, p) + } + sort.Strings(dirs) + fmt.Fprintln(os.Stderr, "Directly changed packages:") + if len(dirs) == 0 { + fmt.Fprintln(os.Stderr, " (none)") + } else { + for _, p := range dirs { + fmt.Fprintln(os.Stderr, " ", p) + } + } + } + + switch *mode { + case "packages": + affected, err := computeAffectedPackages(directPkgs) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if *verbose { + var dbg []string + for p := range affected { + dbg = append(dbg, p) + } + sort.Strings(dbg) + fmt.Fprintln(os.Stderr, "Final affected packages:") + if len(dbg) == 0 { + fmt.Fprintln(os.Stderr, " (none)") + } else { + for _, p := range dbg { + fmt.Fprintln(os.Stderr, " ", p) + } + } + } + // Normalize and filter import paths: + // - Strip test variant suffixes like "pkg [pkg.test]" + // - Exclude e2e test packages (./test/e2e/...) + normalized := make(map[string]struct{}) + for p := range affected { + // Trim Go test variant decorations that appear in `go list -test` + if idx := strings.Index(p, " ["); idx != -1 { + p = p[:idx] + } + // Exclude synthetic test packages like github.com/org/repo/pkg.name.test + if strings.HasSuffix(p, ".test") { + continue + } + if strings.Contains(p, "/test/e2e/") { + continue + } + if p != "" { + normalized[p] = struct{}{} + } + } + var list []string + for p := range normalized { + list = append(list, p) + } + sort.Strings(list) + for _, p := range list { + fmt.Println(p) + } + case "suites": + // Determine impacted suites by dependency mapping and direct e2e test changes, + // then print exact test names for those suites. + preflightRoot := "github.com/replicatedhq/troubleshoot/cmd/preflight" + supportRoot := "github.com/replicatedhq/troubleshoot/cmd/troubleshoot" + + preflightDeps, err := listPackageWithDeps(preflightRoot) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + supportDeps, err := listPackageWithDeps(supportRoot) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + preflightHit := false + supportHit := false + + // Track whether e2e test files were directly changed per suite and collect specific test names + changedPreflightTests := make(map[string]struct{}) + changedSupportTests := make(map[string]struct{}) + preflightE2EChangedNonGo := false + supportE2EChangedNonGo := false + for _, f := range files { + if strings.HasPrefix(f, "test/e2e/preflight/") { + if strings.HasSuffix(f, "_test.go") { + // Extract test names from just this file + b, err := os.ReadFile(f) + if err == nil { // ignore read errors; they will be caught later if needed + scanner := bufio.NewScanner(bytes.NewReader(b)) + re := regexp.MustCompile(`^func\s+(Test[\w\d_]+)\s*\(`) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if m := re.FindStringSubmatch(line); m != nil { + changedPreflightTests[m[1]] = struct{}{} + } + } + } + preflightHit = true + } else { + // Non-go change under preflight e2e; run whole suite + preflightE2EChangedNonGo = true + preflightHit = true + } + } + if strings.HasPrefix(f, "test/e2e/support-bundle/") { + if strings.HasSuffix(f, "_test.go") { + b, err := os.ReadFile(f) + if err == nil { + scanner := bufio.NewScanner(bytes.NewReader(b)) + re := regexp.MustCompile(`^func\s+(Test[\w\d_]+)\s*\(`) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if m := re.FindStringSubmatch(line); m != nil { + changedSupportTests[m[1]] = struct{}{} + } + } + } + supportHit = true + } else { + supportE2EChangedNonGo = true + supportHit = true + } + } + } + for changed := range directPkgs { + if !preflightHit { + if _, ok := preflightDeps[changed]; ok { + preflightHit = true + } + } + if !supportHit { + if _, ok := supportDeps[changed]; ok { + supportHit = true + } + } + if preflightHit && supportHit { + break + } + } + if *verbose { + fmt.Fprintln(os.Stderr, "E2E suite impact:") + fmt.Fprintf(os.Stderr, " preflight: %v\n", preflightHit) + fmt.Fprintf(os.Stderr, " support-bundle: %v\n", supportHit) + } + + // If module files or CI/detector changed, conservatively select all tests for both suites. + if moduleChanged || ciChanged { + preTests, err := listTestFunctions("test/e2e/preflight") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + for _, tname := range preTests { + fmt.Printf("preflight:%s\n", tname) + } + sbTests, err := listTestFunctions("test/e2e/support-bundle") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + for _, tname := range sbTests { + fmt.Printf("support-bundle:%s\n", tname) + } + return + } + + // Collect tests for impacted suites and print as `:` + if preflightHit || supportHit { + if preflightHit { + toPrint := make(map[string]struct{}) + if preflightE2EChangedNonGo || len(changedPreflightTests) == 0 { + // Run full suite if e2e non-go assets changed or no specific test names collected + preTests, err := listTestFunctions("test/e2e/preflight") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + for _, t := range preTests { + toPrint[t] = struct{}{} + } + } else { + for t := range changedPreflightTests { + toPrint[t] = struct{}{} + } + } + var list []string + for t := range toPrint { + list = append(list, t) + } + sort.Strings(list) + for _, tname := range list { + fmt.Printf("preflight:%s\n", tname) + } + } + if supportHit { + toPrint := make(map[string]struct{}) + if supportE2EChangedNonGo || len(changedSupportTests) == 0 { + sbTests, err := listTestFunctions("test/e2e/support-bundle") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + for _, t := range sbTests { + toPrint[t] = struct{}{} + } + } else { + for t := range changedSupportTests { + toPrint[t] = struct{}{} + } + } + var list []string + for t := range toPrint { + list = append(list, t) + } + sort.Strings(list) + for _, tname := range list { + fmt.Printf("support-bundle:%s\n", tname) + } + } + } + default: + fmt.Fprintln(os.Stderr, "unknown mode; use 'packages' or 'suites'") + os.Exit(2) + } +} diff --git a/scripts/run-affected.sh b/scripts/run-affected.sh new file mode 100755 index 000000000..5ad762d47 --- /dev/null +++ b/scripts/run-affected.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 0) Preconditions (one-time) +export PATH="$(go env GOPATH)/bin:$PATH" +go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.19.0 >/dev/null +go install k8s.io/code-generator/cmd/client-gen@v0.34.0 >/dev/null +git fetch origin main --depth=1 || true + +# 1) Determine changed files source: explicit args or git base diff +if [ "$#" -gt 0 ]; then + # Treat provided paths as changed files + CHANGED_CSV=$(printf "%s," "$@" | sed 's/,$//') + echo "Simulating changes in: $CHANGED_CSV" + PKGS="$(go run ./scripts/affected-packages.go -changed-files "${CHANGED_CSV}")" + E2E_OUT="$(go run ./scripts/affected-packages.go -mode=suites -changed-files "${CHANGED_CSV}")" +else + # Compute base (robust to unrelated histories) + BASE="$(git merge-base HEAD origin/main 2>/dev/null || true)" + if [ -z "${BASE}" ]; then + echo "No merge-base with origin/main → running full set" + PKGS="./..." + E2E_OUT="$(go run ./scripts/affected-packages.go -mode=suites -changed-files go.mod || true)" + else + PKGS="$(go run ./scripts/affected-packages.go -base "${BASE}")" + E2E_OUT="$(go run ./scripts/affected-packages.go -mode=suites -base "${BASE}")" + fi +fi + +# 2) Print what will run +echo "=== Affected unit packages ===" +if [ -n "${PKGS}" ]; then echo "${PKGS}"; else echo "(none)"; fi +echo +echo "=== Affected e2e tests ===" +if [ -n "${E2E_OUT}" ]; then echo "${E2E_OUT}"; else echo "(none)"; fi +echo + +# 3) Unit tests via Makefile (inherits required build tags) +if [ "${PKGS}" = "./..." ]; then + echo "Running: make test (all)" + make test +elif [ -n "${PKGS}" ]; then + echo "Running: make test-packages for affected pkgs" + PACKAGES="$(echo "${PKGS}" | xargs)" make test-packages +else + echo "No affected unit packages" +fi + +# 4) E2E tests via Makefile (filtered by regex) +PRE="$(echo "${E2E_OUT}" | awk -F: '$1=="preflight"{print $2}' | paste -sd'|' -)" +SB="$( echo "${E2E_OUT}" | awk -F: '$1=="support-bundle"{print $2}' | paste -sd'|' -)" + +# Use direct go test with the same build tags as the Makefile to avoid RUN quoting issues locally +BUILD_TAGS='netgo containers_image_ostree_stub exclude_graphdriver_devicemapper exclude_graphdriver_btrfs containers_image_openpgp' + +overall=0 + +if [ -n "${PRE}" ]; then + echo "Running preflight e2e: ${PRE}" + go test -tags "${BUILD_TAGS}" -installsuffix netgo -v -count=1 ./test/e2e/preflight -run "^(${PRE})$" || overall=1 +fi +if [ -n "${SB}" ]; then + echo "Running support-bundle e2e: ${SB}" + go test -tags "${BUILD_TAGS}" -installsuffix netgo -v -count=1 ./test/e2e/support-bundle -run "^(${SB})$" || overall=1 +fi + +exit $overall \ No newline at end of file diff --git a/scripts/test-affected-detection.sh b/scripts/test-affected-detection.sh new file mode 100755 index 000000000..4cf600caf --- /dev/null +++ b/scripts/test-affected-detection.sh @@ -0,0 +1,181 @@ +#!/bin/bash +# Comprehensive test for affected test detection +# Tests various code change scenarios to ensure correct suite detection + +set -e + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +TESTS_PASSED=0 +TESTS_FAILED=0 + +echo "========================================" +echo "Affected Test Detection Validation" +echo "========================================" +echo "" + +# Helper function to run test +run_test() { + local test_name="$1" + local test_file="$2" + local expected_suites="$3" + + echo -e "${BLUE}Test: $test_name${NC}" + echo "File: $test_file" + echo "Expected: $expected_suites" + + # Get affected tests from explicit changed files (no git required); detector prints : + local detector_output=$(go run ./scripts/affected-packages.go -mode=suites -changed-files "$test_file" 2>/dev/null) + # Derive suites from prefixes for comparison + local actual_suites=$(echo "$detector_output" | cut -d':' -f1 | grep -v '^$' | sort | uniq | tr '\n' ' ' | xargs) + + # Compare results + if [ "$actual_suites" = "$expected_suites" ]; then + echo -e "${GREEN}✓ PASS${NC} - Got: $actual_suites" + if [ -n "$detector_output" ]; then + echo "Tests:" && echo "$detector_output" | sed 's/^/ - /' + fi + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + echo -e "${RED}✗ FAIL${NC} - Got: '$actual_suites', Expected: '$expected_suites'" + if [ -n "$detector_output" ]; then + echo "Tests:" && echo "$detector_output" | sed 's/^/ - /' + fi + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi + echo "" +} + +# Test 1: Preflight-only package (should only trigger preflight) +run_test "Preflight-only package change" \ + "pkg/preflight/run.go" \ + "preflight" + +# Test 2: Support-bundle-only package +run_test "Support-bundle-only package change" \ + "pkg/supportbundle/supportbundle.go" \ + "support-bundle" + +# Test 3: Shared package - collect +run_test "Shared package (collect) change" \ + "pkg/collect/run.go" \ + "preflight support-bundle" + +# Test 4: Shared package - analyze +run_test "Shared package (analyze) change" \ + "pkg/analyze/analyzer.go" \ + "preflight support-bundle" + +# Test 5: Shared package - k8sutil +run_test "Shared package (k8sutil) change" \ + "pkg/k8sutil/config.go" \ + "preflight support-bundle" + +# Test 6: Shared package - convert +run_test "Shared package (convert) change" \ + "pkg/convert/output.go" \ + "preflight support-bundle" + +# Test 7: Shared package - redact (another shared one) +run_test "Shared package (redact) change" \ + "pkg/redact/redact.go" \ + "preflight support-bundle" + +# Test 8: Preflight command (should only trigger preflight) +run_test "Preflight command change" \ + "cmd/preflight/main.go" \ + "preflight" + +# Test 9: Support-bundle types (support-bundle only package) +run_test "Support-bundle types change" \ + "pkg/supportbundle/types/types.go" \ + "support-bundle" + +# Test 10: Workflow file (should not trigger e2e) +echo -e "${BLUE}Test: Workflow file change (should trigger nothing)${NC}" +echo "File: .github/workflows/affected-tests.yml" +echo "Expected: (no suites)" + +detector_output=$(go run ./scripts/affected-packages.go -mode=suites -changed-files ".github/workflows/affected-tests.yml" 2>/dev/null) +actual_suites=$(echo "$detector_output" | cut -d':' -f1 | grep -v '^$' | sort | uniq | tr '\n' ' ' | xargs) + +if [ -z "$actual_suites" ]; then + echo -e "${GREEN}✓ PASS${NC} - No suites affected (as expected)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e "${RED}✗ FAIL${NC} - Got: '$actual_suites', Expected: (empty)" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +echo "" + +# Test 11: go.mod change (should trigger all) +echo -e "${BLUE}Test: go.mod change (should trigger all suites)${NC}" +echo "File: go.mod" +echo "Expected: preflight support-bundle" + +detector_output=$(go run ./scripts/affected-packages.go -mode=suites -changed-files "go.mod" 2>/dev/null) +actual_suites=$(echo "$detector_output" | cut -d':' -f1 | grep -v '^$' | sort | uniq | tr '\n' ' ' | xargs) + +if [ "$actual_suites" = "preflight support-bundle" ]; then + echo -e "${GREEN}✓ PASS${NC} - Got: $actual_suites" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e "${RED}✗ FAIL${NC} - Got: '$actual_suites', Expected: 'preflight support-bundle'" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +echo "" + +# Test 12: Multiple files across different areas +echo -e "${BLUE}Test: Multiple file changes (support-bundle + shared)${NC}" +echo "Files: pkg/supportbundle/supportbundle.go + pkg/collect/run.go" +echo "Expected: preflight support-bundle" + +detector_output=$(go run ./scripts/affected-packages.go -mode=suites -changed-files "pkg/supportbundle/supportbundle.go,pkg/collect/run.go" 2>/dev/null) +actual_suites=$(echo "$detector_output" | cut -d':' -f1 | grep -v '^$' | sort | uniq | tr '\n' ' ' | xargs) + +if [ "$actual_suites" = "preflight support-bundle" ]; then + echo -e "${GREEN}✓ PASS${NC} - Got: $actual_suites" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e "${RED}✗ FAIL${NC} - Got: '$actual_suites', Expected: 'preflight support-bundle'" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +echo "" + +# Test 13: README change (should not trigger e2e) +echo -e "${BLUE}Test: Documentation change (should trigger nothing)${NC}" +echo "File: README.md" +echo "Expected: (no suites)" + +detector_output=$(go run ./scripts/affected-packages.go -mode=suites -changed-files "README.md" 2>/dev/null) +actual_suites=$(echo "$detector_output" | cut -d':' -f1 | grep -v '^$' | sort | uniq | tr '\n' ' ' | xargs) + +if [ -z "$actual_suites" ]; then + echo -e "${GREEN}✓ PASS${NC} - No suites affected (as expected)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + echo -e "${RED}✗ FAIL${NC} - Got: '$actual_suites', Expected: (empty)" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi +echo "" + +# Summary +echo "========================================" +echo -e "${GREEN}Tests Passed: $TESTS_PASSED${NC}" +echo -e "${RED}Tests Failed: $TESTS_FAILED${NC}" +echo "========================================" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "${GREEN}✓ All tests passed!${NC}" + exit 0 +else + echo -e "${RED}✗ Some tests failed${NC}" + exit 1 +fi + +