From 1dc346a47da1ed7b8fb8591216470a48f1e1af9b Mon Sep 17 00:00:00 2001 From: Yun Peng <pcloudy@google.com> Date: Thu, 27 Apr 2023 19:08:41 +0200 Subject: [PATCH 1/5] Support bisecting Bazel to find which Bazel change breaks your build - Use --bisect=<BASE>..<HEAD> to specify the bisecting range, Bazelisk uses the GitHub API to get the list of commits to bisect. You may need to set `BAZELISK_GITHUB_TOKEN` to get around GitHub rate limit. - BAZELISK_SHUTDOWN, BAZELISK_CLEAN can be used to run `bazel shutdown` or `bazel clean --expunge` between builds. --- core/core.go | 224 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 203 insertions(+), 21 deletions(-) diff --git a/core/core.go b/core/core.go index 139c17b9..e40d529c 100644 --- a/core/core.go +++ b/core/core.go @@ -6,10 +6,12 @@ package core import ( "bufio" "crypto/sha256" + "encoding/json" "fmt" "io" "io/ioutil" "log" + "net/http" "os" "os/exec" "os/signal" @@ -89,24 +91,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error // If we aren't using a local Bazel binary, we'll have to parse the version string and // download the version that the user wants. if !filepath.IsAbs(bazelPath) { - bazelFork, bazelVersion, err := parseBazelForkAndVersion(bazelVersionString) - if err != nil { - return -1, fmt.Errorf("could not parse Bazel fork and version: %v", err) - } - - var downloader DownloadFunc - resolvedBazelVersion, downloader, err = repos.ResolveVersion(bazeliskHome, bazelFork, bazelVersion) - if err != nil { - return -1, fmt.Errorf("could not resolve the version '%s' to an actual version number: %v", bazelVersion, err) - } - - bazelForkOrURL := dirForURL(GetEnvOrConfig(BaseURLEnv)) - if len(bazelForkOrURL) == 0 { - bazelForkOrURL = bazelFork - } - - baseDirectory := filepath.Join(bazeliskHome, "downloads", bazelForkOrURL) - bazelPath, err = downloadBazelIfNecessary(resolvedBazelVersion, baseDirectory, repos, downloader) + bazelPath, err = downloadBazel(bazelVersionString, bazeliskHome, repos) if err != nil { return -1, fmt.Errorf("could not download Bazel: %v", err) } @@ -130,7 +115,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error return 0, nil } - // --strict and --migrate must be the first argument. + // --strict and --migrate and --bisect must be the first argument. if len(args) > 0 && (args[0] == "--strict" || args[0] == "--migrate") { cmd, err := getBazelCommand(args) if err != nil { @@ -140,14 +125,25 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error if err != nil { return -1, fmt.Errorf("could not get the list of incompatible flags: %v", err) } - if args[0] == "--migrate" { migrate(bazelPath, args[1:], newFlags) - } else { + } else if args[0] == "--migrate" { // When --strict is present, it expands to the list of --incompatible_ flags // that should be enabled for the given Bazel version. args = insertArgs(args[1:], newFlags) } + } else if len(args) > 0 && strings.HasPrefix(args[0], "--bisect") { + // When --bisect is present, we run the bisect logic. + if !strings.HasPrefix(args[0], "--bisect=") { + return -1, fmt.Errorf("Error: --bisect must have a value. Expected format: '--bisect=<good bazel commit>..<bad bazel commit>'") + } + value := args[0][len("--bisect="):] + commits := strings.Split(value, "..") + if len(commits) == 2 { + bisect(commits[0], commits[1], args[1:], bazeliskHome, repos) + } else { + return -1, fmt.Errorf("Error: Invalid format for --bisect. Expected format: '--bisect=<good bazel commit>..<bad bazel commit>'") + } } // print bazelisk version information if "version" is the first argument @@ -404,6 +400,27 @@ func parseBazelForkAndVersion(bazelForkAndVersion string) (string, string, error return bazelFork, bazelVersion, nil } +func downloadBazel(bazelVersionString string, bazeliskHome string, repos *Repositories) (string, error) { + bazelFork, bazelVersion, err := parseBazelForkAndVersion(bazelVersionString) + if err != nil { + return "", fmt.Errorf("could not parse Bazel fork and version: %v", err) + } + + resolvedBazelVersion, downloader, err := repos.ResolveVersion(bazeliskHome, bazelFork, bazelVersion) + if err != nil { + return "", fmt.Errorf("could not resolve the version '%s' to an actual version number: %v", bazelVersion, err) + } + + bazelForkOrURL := dirForURL(GetEnvOrConfig(BaseURLEnv)) + if len(bazelForkOrURL) == 0 { + bazelForkOrURL = bazelFork + } + + baseDirectory := filepath.Join(bazeliskHome, "downloads", bazelForkOrURL) + bazelPath, err := downloadBazelIfNecessary(resolvedBazelVersion, baseDirectory, repos, downloader) + return bazelPath, err +} + func downloadBazelIfNecessary(version string, baseDirectory string, repos *Repositories, downloader DownloadFunc) (string, error) { pathSegment, err := platforms.DetermineBazelFilename(version, false) if err != nil { @@ -717,6 +734,171 @@ func cleanIfNeeded(bazelPath string, startupOptions []string) { } } +type Commit struct { + SHA string `json:"sha"` +} + +type CompareResponse struct { + Commits []Commit `json:"commits"` + MergeBaseCommit Commit `json:"merge_base_commit"` +} + +type Message struct { + Content string `json:"message"` +} + +func sendRequest(url string) (*http.Response, error) { + client := &http.Client{} + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + githubToken := GetEnvOrConfig("BAZELISK_GITHUB_TOKEN") + if len(githubToken) != 0 { + req.Header.Set("Authorization", fmt.Sprintf("token %s", githubToken)) + } + + return client.Do(req) +} + +func getBazelCommitsBetween(goodCommit string, badCommit string) ([]string, error) { + commitList := make([]string, 0) + page := 1 + perPage := 250 // 250 is the maximum number of commits per page + + for { + url := fmt.Sprintf("https://api.github.com/repos/bazelbuild/bazel/compare/%s...%s?page=%d&per_page=%d", goodCommit, badCommit, page, perPage) + + response, err := sendRequest(url) + if err != nil { + return nil, fmt.Errorf("Error fetching commit data: %v", err) + } + defer response.Body.Close() + + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("Error reading response body: %v", err) + } + + if response.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("repository or commit not found: %s", string(body)) + } else if response.StatusCode == 403 { + return nil, fmt.Errorf("github API rate limit hit, consider setting BAZELISK_GITHUB_TOKEN: %s", string(body)) + } else if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected response status code %d: %s", response.StatusCode, string(body)) + } + + var compareResponse CompareResponse + err = json.Unmarshal(body, &compareResponse) + if err != nil { + return nil, fmt.Errorf("Error unmarshaling JSON: %v", err) + } + + if len(compareResponse.Commits) == 0 { + break + } + + if page == 1 { + mergeBaseCommit := compareResponse.MergeBaseCommit.SHA + if compareResponse.MergeBaseCommit.SHA != goodCommit { + fmt.Printf("The good Bazel commit is not an ancestor of the bad Bazel commit, overriding the good Bazel commit to the merge base commit %s\n", mergeBaseCommit) + goodCommit = mergeBaseCommit + } + } + + for _, commit := range compareResponse.Commits { + commitList = append(commitList, commit.SHA) + } + + // Check if there are more commits to fetch + if len(compareResponse.Commits) < perPage { + break + } + + page++ + } + + if len(commitList) == 0 { + return nil, fmt.Errorf("no commits found between (%s, %s], the good commit should be first, maybe try with --bisect=%s..%s ?", goodCommit, badCommit, badCommit, goodCommit) + } + fmt.Printf("Found %d commits between (%s, %s]\n", len(commitList), goodCommit, badCommit) + return commitList, nil +} + +func bisect(goodCommit string, badCommit string, args []string, bazeliskHome string, repos *Repositories) { + + // 1. Get the list of commits between goodCommit and badCommit + fmt.Printf("\n\n--- Getting the list of commits between %s and %s\n\n", goodCommit, badCommit) + commitList, err := getBazelCommitsBetween(goodCommit, badCommit) + if err != nil { + log.Fatalf("Failed to get commits: %v", err) + os.Exit(1) + } + + // 2. Check if goodCommit is actually good + fmt.Printf("\n\n--- Verifying if the given good Bazel commit (%s) is actually good\n\n", goodCommit) + bazelExitCode, err := testWithBazelAtCommit(goodCommit, args, bazeliskHome, repos) + if err != nil { + log.Fatalf("could not run Bazel: %v", err) + os.Exit(1) + } + if bazelExitCode != 0 { + fmt.Printf("Failure: Given good bazel commit is already broken.\n") + os.Exit(1) + } + + // 3. Bisect commits + fmt.Printf("\n\n--- Start bisecting\n\n") + left := 0 + right := len(commitList) + for left < right { + mid := (left + right) / 2 + midCommit := commitList[mid] + fmt.Printf("\n\n--- Testing with Bazel built at %s, %d commits remaining...\n\n", midCommit, right -left) + bazelExitCode, err := testWithBazelAtCommit(midCommit, args, bazeliskHome, repos) + if err != nil { + log.Fatalf("could not run Bazel: %v", err) + os.Exit(1) + } + if bazelExitCode == 0 { + fmt.Printf("\n\n--- Succeeded at %s\n\n", midCommit) + left = mid + 1 + } else { + fmt.Printf("\n\n--- Failed at %s\n\n", midCommit) + right = mid + } + } + + // 4. Print the result + fmt.Printf("\n\n--- Bisect Result\n\n") + if right == len(commitList) { + fmt.Printf("first bad commit not found, every commit succeeded.\n") + } else { + firstBadCommit := commitList[right] + fmt.Printf("first bad commit is https://github.com/bazelbuild/bazel/commit/%s\n", firstBadCommit) + } + + os.Exit(0) +} + +func testWithBazelAtCommit(bazelCommit string, args []string, bazeliskHome string, repos *Repositories) (int, error) { + bazelPath, err := downloadBazel(bazelCommit, bazeliskHome, repos) + if err != nil { + return 1, fmt.Errorf("could not download Bazel: %v", err) + } + startupOptions := parseStartupOptions(args) + shutdownIfNeeded(bazelPath, startupOptions) + cleanIfNeeded(bazelPath, startupOptions) + fmt.Printf("bazel %s\n", strings.Join(args, " ")) + bazelExitCode, err := runBazel(bazelPath, args, nil) + if err != nil { + return -1, fmt.Errorf("could not run Bazel: %v", err) + } + return bazelExitCode, nil +} + // migrate will run Bazel with each flag separately and report which ones are failing. func migrate(bazelPath string, baseArgs []string, flags []string) { var startupOptions = parseStartupOptions(baseArgs) From d1b28c147b6e8d4d3608fb9596e4caa7c5883fd0 Mon Sep 17 00:00:00 2001 From: Yun Peng <pcloudy@google.com> Date: Fri, 28 Apr 2023 10:01:17 +0200 Subject: [PATCH 2/5] Add doc --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1d059b51..76a3010e 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,9 @@ require users update their bazel. [shell wrapper script]: https://github.com/bazelbuild/bazel/blob/master/scripts/packages/bazel.sh ## Other features -The Go version of Bazelisk offers two new flags. +The Go version of Bazelisk offers three new flags. + +### --strict `--strict` expands to the set of incompatible flags which may be enabled for the given version of Bazel. @@ -108,17 +110,32 @@ The Go version of Bazelisk offers two new flags. bazelisk --strict build //... ``` +### --migrate + `--migrate` will run Bazel multiple times to help you identify compatibility issues. If the code fails with `--strict`, the flag `--migrate` will run Bazel with each one of the flag separately, and print a report at the end. This will show you which flags can safely enabled, and which flags require a migration. + +### --bisect + +`--bisect` flag allows you to bisect Bazel versions to find which version introduced a build failure. You can specify the range of versions to bisect with `--bisect=<GOOD>..<BAD>`, where GOOD is the last known working Bazel version and BAD is the first known non-working Bazel version. Bazelisk uses [GitHub's compare API](https://docs.github.com/en/rest/commits/commits#compare-two-commits) to get the list of commits to bisect. When GOOD is not an ancestor of BAD, GOOD is reset to their merge base commit. + +```shell +bazelisk --bisect=6.0.0..HEAD test //foo:bar_test +``` + +### Useful environment variables + You can set `BAZELISK_INCOMPATIBLE_FLAGS` to set a list of incompatible flags (separated by `,`) to be tested, otherwise Bazelisk tests all flags starting with `--incompatible_`. You can set `BAZELISK_GITHUB_TOKEN` to set a GitHub access token to use for API requests to avoid rate limiting when on shared networks. -You can set `BAZELISK_SHUTDOWN` to run `shutdown` between builds when migrating if you suspect this affects your results. +You can set `BAZELISK_SHUTDOWN` to run `shutdown` between builds when migrating or bisecting if you suspect this affects your results. + +You can set `BAZELISK_CLEAN` to run `clean --expunge` between builds when migrating or bisecting if you suspect this affects your results. -You can set `BAZELISK_CLEAN` to run `clean --expunge` between builds when migrating if you suspect this affects your results. +## tools/bazel If `tools/bazel` exists in your workspace root and is executable, Bazelisk will run this file, instead of the Bazel version it downloaded. It will set the environment variable `BAZEL_REAL` to the path of the downloaded Bazel binary. From c3811face6d83bd191b5916fc49c111e2d47e658 Mon Sep 17 00:00:00 2001 From: Yun Peng <pcloudy@google.com> Date: Fri, 28 Apr 2023 10:12:23 +0200 Subject: [PATCH 3/5] Remove unused struct --- core/core.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/core.go b/core/core.go index e40d529c..63856c5c 100644 --- a/core/core.go +++ b/core/core.go @@ -743,10 +743,6 @@ type CompareResponse struct { MergeBaseCommit Commit `json:"merge_base_commit"` } -type Message struct { - Content string `json:"message"` -} - func sendRequest(url string) (*http.Response, error) { client := &http.Client{} From fc077ff618a03bf76216dc1c899f1b734843a305 Mon Sep 17 00:00:00 2001 From: Yun Peng <pcloudy@google.com> Date: Fri, 28 Apr 2023 10:17:27 +0200 Subject: [PATCH 4/5] small fixes --- core/core.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/core/core.go b/core/core.go index 63856c5c..70de0fe9 100644 --- a/core/core.go +++ b/core/core.go @@ -796,12 +796,10 @@ func getBazelCommitsBetween(goodCommit string, badCommit string) ([]string, erro break } - if page == 1 { - mergeBaseCommit := compareResponse.MergeBaseCommit.SHA - if compareResponse.MergeBaseCommit.SHA != goodCommit { - fmt.Printf("The good Bazel commit is not an ancestor of the bad Bazel commit, overriding the good Bazel commit to the merge base commit %s\n", mergeBaseCommit) - goodCommit = mergeBaseCommit - } + mergeBaseCommit := compareResponse.MergeBaseCommit.SHA + if compareResponse.MergeBaseCommit.SHA != goodCommit { + fmt.Printf("The good Bazel commit is not an ancestor of the bad Bazel commit, overriding the good Bazel commit to the merge base commit %s\n", mergeBaseCommit) + goodCommit = mergeBaseCommit } for _, commit := range compareResponse.Commits { From 39b37756b05b42615b7048696d583fcc4c62b865 Mon Sep 17 00:00:00 2001 From: Yun Peng <pcloudy@google.com> Date: Fri, 28 Apr 2023 10:45:54 +0200 Subject: [PATCH 5/5] Fix --strict --- core/core.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/core.go b/core/core.go index 70de0fe9..753f88f5 100644 --- a/core/core.go +++ b/core/core.go @@ -127,7 +127,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error } if args[0] == "--migrate" { migrate(bazelPath, args[1:], newFlags) - } else if args[0] == "--migrate" { + } else { // When --strict is present, it expands to the list of --incompatible_ flags // that should be enabled for the given Bazel version. args = insertArgs(args[1:], newFlags)