diff --git a/.github/actions/setup-build-environment/action.yml b/.github/actions/setup-build-environment/action.yml new file mode 100644 index 0000000000..500f9f6d3a --- /dev/null +++ b/.github/actions/setup-build-environment/action.yml @@ -0,0 +1,47 @@ +name: 'Setup Build Environment' +description: 'Sets up the build environment with Go, Python, uv, and ruff' + +inputs: + cache-key: + description: 'Cache key identifier for Go cache' + required: true + +runs: + using: 'composite' + steps: + - name: Checkout repository and submodules + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Create cache identifier + run: echo "${{ inputs.cache-key }}" > cache.txt + shell: bash + + - name: Setup Go + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + cache-dependency-path: | + go.sum + cache.txt + + - name: Setup Python + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 + with: + version: "0.8.9" + + - name: Install ruff (Python linter and formatter) + uses: astral-sh/ruff-action@57714a7c8a2e59f32539362ba31877a1957dded1 # v3.5.1 + with: + version: "0.9.1" + args: "--version" + + - name: Pull external libraries + run: | + go mod download + pip3 install wheel==0.45.1 + shell: bash diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index b656432fa5..f6fa713e58 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -31,8 +31,52 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh cache delete --all --repo databricks/cli || true + testmask: + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.mask1.outputs.targets || steps.mask2.outputs.targets || steps.mask3.outputs.targets }} + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: tools/go.mod + + - name: Run testmask (pull requests) + if: ${{ github.event_name == 'pull_request' }} + id: mask1 + working-directory: tools/testmask + run: | + go run . ${{ github.event.pull_request.head.sha }} ${{ github.event.pull_request.base.sha }} | tee output.json + echo "targets=$(jq -c '.' output.json)" >> $GITHUB_OUTPUT + + - name: Run testmask (merge group) + if: ${{ github.event_name == 'merge_group' }} + id: mask2 + working-directory: tools/testmask + run: | + go run . ${{ github.event.merge_group.head.sha }} ${{ github.event.merge_group.base.sha }} | tee output.json + echo "targets=$(jq -c '.' output.json)" >> $GITHUB_OUTPUT + + - name: Run testmask (other events) + if: ${{ github.event_name != 'pull_request' && github.event_name != 'merge_group' }} + id: mask3 + working-directory: tools/testmask + run: | + # Always run all tests + echo "targets=[\"test\"]" >> $GITHUB_OUTPUT + tests: - needs: cleanups + needs: + - cleanups + - testmask + + # Only run if the target is in the list of targets from testmask + if: ${{ contains(fromJSON(needs.testmask.outputs.targets), 'test') }} runs-on: ${{ matrix.os }} strategy: @@ -53,37 +97,10 @@ jobs: - name: Checkout repository and submodules uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Create deployment-specific cache identifier - run: echo "${{ matrix.deployment }}" > deployment-type.txt - - - name: Setup Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + - name: Setup build environment + uses: ./.github/actions/setup-build-environment with: - go-version-file: go.mod - cache-dependency-path: | - go.sum - deployment-type.txt - - - name: Setup Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 - with: - python-version: '3.13' - - - name: Install uv - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 - with: - version: "0.8.9" - - - name: Install ruff (Python linter and formatter) - uses: astral-sh/ruff-action@57714a7c8a2e59f32539362ba31877a1957dded1 # v3.5.1 - with: - version: "0.9.1" - args: "--version" - - - name: Pull external libraries - run: | - go mod download - pip3 install wheel==0.45.1 + cache-key: ${{matrix.target}}-${{ matrix.deployment }} - name: Run tests without coverage # We run tests without coverage on PR, merge_group, and schedule because we don't make use of coverage information @@ -104,6 +121,130 @@ jobs: - name: Analyze slow tests run: make slowest + test-exp-aitools: + needs: + - cleanups + - testmask + + # Only run if the target is in the list of targets from testmask + if: ${{ contains(fromJSON(needs.testmask.outputs.targets), 'test-exp-aitools') }} + name: "make test-exp-aitools" + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + + steps: + - name: Checkout repository and submodules + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup build environment + uses: ./.github/actions/setup-build-environment + with: + cache-key: test-exp-aitools + + - name: Run tests + run: | + make test-exp-aitools + + test-exp-apps-mcp: + needs: + - cleanups + - testmask + + # Only run if the target is in the list of targets from testmask + if: ${{ contains(fromJSON(needs.testmask.outputs.targets), 'test-exp-apps-mcp') }} + name: "make test-exp-apps-mcp" + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + + steps: + - name: Checkout repository and submodules + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup build environment + uses: ./.github/actions/setup-build-environment + with: + cache-key: test-exp-apps-mcp + + - name: Run tests + run: | + make test-exp-apps-mcp + + test-exp-ssh: + needs: + - cleanups + - testmask + + # Only run if the target is in the list of targets from testmask + if: ${{ contains(fromJSON(needs.testmask.outputs.targets), 'test-exp-ssh') }} + name: "make test-exp-ssh" + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + + steps: + - name: Checkout repository and submodules + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup build environment + uses: ./.github/actions/setup-build-environment + with: + cache-key: test-exp-ssh + + - name: Run tests + run: | + make test-exp-ssh + + test-pipelines: + needs: + - cleanups + - testmask + + # Only run if the target is in the list of targets from testmask + if: ${{ contains(fromJSON(needs.testmask.outputs.targets), 'test-pipelines') }} + name: "make test-pipelines" + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + + steps: + - name: Checkout repository and submodules + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup build environment + uses: ./.github/actions/setup-build-environment + with: + cache-key: test-pipelines + + - name: Run tests + run: | + make test-pipelines + validate-generated-is-up-to-date: needs: cleanups runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index bb976362a2..50775d493e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ default: checks fmt lint -# gotestsum: when go test args are used with --rerun-fails the list of packages to test must be specified by the --packages flag -PACKAGES=--packages "./acceptance/... ./libs/... ./internal/... ./cmd/... ./bundle/... ./experimental/aitools/... ./experimental/ssh/... ." +# Default packages to test (all) +TEST_PACKAGES = . ./acceptance/internal ./libs/... ./internal/... ./cmd/... ./bundle/... ./experimental/... + +# Default acceptance test filter (all) +ACCEPTANCE_TEST_FILTER = TestAccept GO_TOOL ?= go tool -modfile=tools/go.mod GOTESTSUM_FORMAT ?= pkgname-and-test-fails @@ -56,10 +59,11 @@ links: checks: tidy ws links test: - ${GOTESTSUM_CMD} ${PACKAGES} -- -timeout=${LOCAL_TIMEOUT} -short + ${GOTESTSUM_CMD} --packages "${TEST_PACKAGES}" -- -timeout=${LOCAL_TIMEOUT} ${SHORT_FLAG} + ${GOTESTSUM_CMD} --packages ./acceptance/... -- -timeout=${LOCAL_TIMEOUT} ${SHORT_FLAG} -run ${ACCEPTANCE_TEST_FILTER} test-slow: - ${GOTESTSUM_CMD} ${PACKAGES} -- -timeout=${LOCAL_TIMEOUT} + make test SHORT_FLAG="-short" # Updates acceptance test output (local tests) test-update: @@ -82,7 +86,7 @@ slowest: cover: rm -fr ./acceptance/build/cover/ - VERBOSE_TEST=1 CLI_GOCOVERDIR=build/cover ${GOTESTSUM_CMD} ${PACKAGES} -- -coverprofile=coverage.txt -timeout=${LOCAL_TIMEOUT} + VERBOSE_TEST=1 CLI_GOCOVERDIR=build/cover ${GOTESTSUM_CMD} --packages ${TEST_PACKAGES} -- -coverprofile=coverage.txt -timeout=${LOCAL_TIMEOUT} rm -fr ./acceptance/build/cover-merged/ mkdir -p acceptance/build/cover-merged/ go tool covdata merge -i $$(printf '%s,' acceptance/build/cover/* | sed 's/,$$//') -o acceptance/build/cover-merged/ @@ -151,3 +155,15 @@ generate: .PHONY: lint lintfull tidy lintcheck fmt fmtfull test cover showcover build snapshot snapshot-release schema integration integration-short acc-cover acc-showcover docs ws wsfix links checks test-update test-update-templates test-update-aws test-update-all generate-validation + +test-exp-aitools: + make test TEST_PACKAGES="./experimental/aitools/..." ACCEPTANCE_TEST_FILTER="TestAccept/idontexistyet/aitools" + +test-exp-apps-mcp: + make test TEST_PACKAGES="./experimental/apps-mcp/..." ACCEPTANCE_TEST_FILTER="TestAccept/idontexistyet/apps-mcp" + +test-exp-ssh: + make test TEST_PACKAGES="./experimental/ssh/..." ACCEPTANCE_TEST_FILTER="TestAccept/ssh" + +test-pipelines: + make test TEST_PACKAGES="./cmd/pipelines/..." ACCEPTANCE_TEST_FILTER="TestAccept/pipelines" diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 5949e2ebc5..041e2f0bd3 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -901,7 +901,7 @@ func BuildCLI(t *testing.T, buildDir, coverDir, osName, arch string) string { } args := []string{ - "go", "build", "-o", execPath, + "go", "build", "-o", execPath, "-buildvcs=false", } if coverDir != "" { diff --git a/experimental/ssh/trigger.txt b/experimental/ssh/trigger.txt new file mode 100644 index 0000000000..bb5c1ac3d3 --- /dev/null +++ b/experimental/ssh/trigger.txt @@ -0,0 +1 @@ +Trigger test-exp-ssh diff --git a/tools/testmask/git.go b/tools/testmask/git.go new file mode 100644 index 0000000000..b3cf950850 --- /dev/null +++ b/tools/testmask/git.go @@ -0,0 +1,33 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "os/exec" + "strings" +) + +// GetChangedFiles returns the list of files changed between two git refs. +func GetChangedFiles(headRef, baseRef string) ([]string, error) { + cmd := exec.Command("git", "diff", "--name-only", baseRef, headRef) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get diff between %s and %s: %w", baseRef, headRef, err) + } + + return parseLines(output), nil +} + +// parseLines parses command output into a slice of non-empty lines. +func parseLines(output []byte) []string { + var lines []string + scanner := bufio.NewScanner(bytes.NewReader(output)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + lines = append(lines, line) + } + } + return lines +} diff --git a/tools/testmask/git_test.go b/tools/testmask/git_test.go new file mode 100644 index 0000000000..ca8cbd6ce8 --- /dev/null +++ b/tools/testmask/git_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "testing" +) + +func TestParseLines(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "empty input", + input: "", + expected: []string{}, + }, + { + name: "multiple lines", + input: "file1.go\nfile2.go\nfile3.go\n", + expected: []string{"file1.go", "file2.go", "file3.go"}, + }, + { + name: "whitespace trimmed and empty lines ignored", + input: " file1.go \n\nfile2.go\n\t\n", + expected: []string{"file1.go", "file2.go"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseLines([]byte(tt.input)) + if len(result) != len(tt.expected) { + t.Errorf("expected %d lines, got %d: %v", len(tt.expected), len(result), result) + return + } + for i, line := range result { + if line != tt.expected[i] { + t.Errorf("line[%d]: expected %q, got %q", i, tt.expected[i], line) + } + } + }) + } +} + +func TestGetChangedFiles(t *testing.T) { + // Test with HEAD to HEAD - should return empty list + result, err := GetChangedFiles("HEAD", "HEAD") + if err != nil { + t.Skipf("unable to run git: %v", err) + return + } + if len(result) > 0 { + t.Errorf("expected empty list, got %v", result) + } + + // Test with HEAD to HEAD~2 - should produce non-empty result if there are commits + result, err = GetChangedFiles("HEAD", "HEAD~2") + if err != nil { + t.Errorf("unable to run git: %v", err) + return + } + if len(result) == 0 { + t.Errorf("expected non-empty list, got %v", result) + } + + // Test with invalid refs - should error + _, err = GetChangedFiles("invalid-ref-12345", "invalid-ref-67890") + if err == nil { + t.Error("expected error for invalid refs") + } +} diff --git a/tools/testmask/main.go b/tools/testmask/main.go new file mode 100644 index 0000000000..028e7199c1 --- /dev/null +++ b/tools/testmask/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +func main() { + baseRef := os.Getenv("GITHUB_BASE_REF") + if baseRef == "" { + baseRef = "HEAD" + } + + headRef := os.Getenv("GITHUB_HEAD_REF") + if headRef == "" { + headRef = "HEAD" + } + + // Accept CLI arguments for testing + if len(os.Args) == 3 { + headRef = os.Args[1] + baseRef = os.Args[2] + } + + changedFiles, err := GetChangedFiles(headRef, baseRef) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting changed files: %v\n", err) + os.Exit(1) + } + + targets := GetTargets(changedFiles) + err = json.NewEncoder(os.Stdout).Encode(targets) + if err != nil { + fmt.Fprintf(os.Stderr, "Error encoding targets: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/testmask/targets.go b/tools/testmask/targets.go new file mode 100644 index 0000000000..24478f3d3b --- /dev/null +++ b/tools/testmask/targets.go @@ -0,0 +1,85 @@ +package main + +import ( + "sort" + "strings" +) + +type targetMapping struct { + patterns []string + target string +} + +var fileTargetMappings = []targetMapping{ + { + patterns: []string{ + "experimental/aitools/", + }, + target: "test-exp-aitools", + }, + { + patterns: []string{ + "experimental/apps-mcp/", + }, + target: "test-exp-apps-mcp", + }, + { + patterns: []string{ + "experimental/ssh/", + "acceptance/ssh/", + }, + target: "test-exp-ssh", + }, + { + patterns: []string{ + "cmd/pipelines/", + "acceptance/pipelines/", + }, + target: "test-pipelines", + }, +} + +// GetTargets matches files to targets based on patterns and returns the matched targets. +func GetTargets(files []string) []string { + targetSet := make(map[string]bool) + unmatchedFiles := []string{} + + for _, file := range files { + matched := false + for _, mapping := range fileTargetMappings { + for _, pattern := range mapping.patterns { + if strings.HasPrefix(file, pattern) { + targetSet[mapping.target] = true + matched = true + break + } + } + if matched { + break + } + } + if !matched { + unmatchedFiles = append(unmatchedFiles, file) + } + } + + // If there are unmatched files, add the "test" target to run all tests. + if len(unmatchedFiles) > 0 { + targetSet["test"] = true + } + + // If there are no targets, add the "test" target to run all tests. + if len(targetSet) == 0 { + return []string{"test"} + } + + // Convert map to sorted slice + targets := make([]string, 0, len(targetSet)) + for target := range targetSet { + targets = append(targets, target) + } + + // Sort for consistent output + sort.Strings(targets) + return targets +} diff --git a/tools/testmask/targets_test.go b/tools/testmask/targets_test.go new file mode 100644 index 0000000000..c4224eaa6e --- /dev/null +++ b/tools/testmask/targets_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "os" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetTargets(t *testing.T) { + tests := []struct { + name string + files []string + targets []string + }{ + { + name: "experimental_ssh", + files: []string{ + "experimental/ssh/main.go", + "experimental/ssh/lib/server.go", + }, + targets: []string{"test-exp-ssh"}, + }, + { + name: "experimental_aitools", + files: []string{ + "experimental/aitools/server.go", + }, + targets: []string{"test-exp-aitools"}, + }, + { + name: "pipelines", + files: []string{ + "cmd/pipelines/main.go", + }, + targets: []string{"test-pipelines"}, + }, + { + name: "non_matching", + files: []string{ + "bundle/config.go", + "cmd/bundle/deploy.go", + }, + targets: []string{"test"}, + }, + { + name: "mixed_matching_and_unmatched", + files: []string{ + "experimental/ssh/main.go", + "go.mod", + }, + targets: []string{"test", "test-exp-ssh"}, + }, + { + name: "empty_files", + files: []string{}, + targets: []string{"test"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + targets := GetTargets(tt.files) + assert.Equal(t, tt.targets, targets) + }) + } +} + +func TestTargetsExistInMakefile(t *testing.T) { + // Collect all targets from fileTargetMappings + expectedTargets := make(map[string]bool) + for _, mapping := range fileTargetMappings { + expectedTargets[mapping.target] = true + } + + // Also include "test" since it's used in GetTargets + expectedTargets["test"] = true + + // Read and parse Makefile to extract target names + makefileTargets := parseMakefileTargets(t, "../../Makefile") + + // Verify all expected targets exist in Makefile + var missingTargets []string + for target := range expectedTargets { + if !makefileTargets[target] { + missingTargets = append(missingTargets, target) + } + } + + if len(missingTargets) > 0 { + t.Errorf("The following targets are defined in targets.go but do not exist in Makefile: %v", missingTargets) + } +} + +// parseMakefileTargets parses a Makefile and returns a set of target names +func parseMakefileTargets(t *testing.T, makefilePath string) map[string]bool { + targets := make(map[string]bool) + targetRegex := regexp.MustCompile(`^([a-zA-Z0-9_-]+):`) + + content, err := os.ReadFile(makefilePath) + require.NoError(t, err) + + lines := strings.Split(string(content), "\n") + for _, line := range lines { + // Match Makefile target pattern: target: + matches := targetRegex.FindStringSubmatch(line) + if len(matches) > 1 { + targets[matches[1]] = true + } + } + + return targets +}