Repository: bazelbuild/bazelisk
Pull request #224 (2021-03-19 19:39:33)

Support rolling releases
The current implementation accepts hardcoded versions of the format "5.0.0-pre.20210317[.1]", or the "rolling" literal which refers to the most recent rolling release.
Rolling releases are downloaded from GitHub, but we want to add GCS support soon, too.


Patch:
diff --git a/README.md b/README.md
index 884e8ee..4e53a19 100644
--- a/README.md
+++ b/README.md
@@ -36,10 +36,10 @@ The fork and version should be separated by slash: `<FORK>/<VERSION>`.
 Please see the next section for how to work with forks.
 
 Bazelisk currently understands the following formats for version labels:
-- `latest` means the latest stable version of Bazel as released on GitHub.
+- `latest` means the latest stable (LTS) version of Bazel as released on GitHub.
   Previous releases can be specified via `latest-1`, `latest-2` etc.
 - A version number like `0.17.2` means that exact version of Bazel.
-  It can also be a release candidate version like `0.20.0rc3`.
+  It can also be a release candidate version like `0.20.0rc3`, or a rolling release version like `5.0.0-pre.20210317.1`.
 - The hash of a Git commit. Please note that Bazel binaries are only available for commits that passed [Bazel CI](https://buildkite.com/bazel/bazel-bazel).
 
 Additionally, a few special version names are supported for our official releases only (these formats do not work when using a fork):
@@ -48,6 +48,7 @@ Additionally, a few special version names are supported for our official release
 - `last_downstream_green` points to the most recent Bazel binary that builds and tests all [downstream projects](https://buildkite.com/bazel/bazel-at-head-plus-downstream) successfully.
 - `last_rc` points to the most recent release candidate.
   If there is no active release candidate, Bazelisk uses the latest Bazel release instead.
+- `rolling` refers to the latest rolling release (even if there is a newer LTS release).
 
 ## Where does Bazelisk get Bazel from?
 
diff --git a/bazelisk.go b/bazelisk.go
index 599ed03..2e44790 100644
--- a/bazelisk.go
+++ b/bazelisk.go
@@ -26,8 +26,9 @@ import (
 func main() {
 	gcs := &repositories.GCSRepo{}
 	gitHub := repositories.CreateGitHubRepo(core.GetEnvOrConfig("BAZELISK_GITHUB_TOKEN"))
-	// Fetch releases, release candidates and Bazel-at-commits from GCS, forks from GitHub
-	repos := core.CreateRepositories(gcs, gcs, gitHub, gcs, true)
+	// Fetch LTS releases, release candidates and Bazel-at-commits from GCS, forks and rolling releases from GitHub
+	// TODO: get rolling releases from GCS, too
+	repos := core.CreateRepositories(gcs, gcs, gitHub, gcs, gitHub, true)
 
 	exitCode, err := core.RunBazelisk(os.Args[1:], repos)
 	if err != nil {
diff --git a/core/repositories.go b/core/repositories.go
index 0fb0a4d..c6753df 100644
--- a/core/repositories.go
+++ b/core/repositories.go
@@ -17,7 +17,7 @@ const (
 // DownloadFunc downloads a specific Bazel binary to the given location and returns the absolute path.
 type DownloadFunc func(destDir, destFile string) (string, error)
 
-// ReleaseRepo represents a repository that stores Bazel releases.
+// ReleaseRepo represents a repository that stores LTS Bazel releases.
 type ReleaseRepo interface {
 	// GetReleaseVersions returns a list of all available release versions. If lastN is smaller than 1, all available versions are being returned.
 	GetReleaseVersions(bazeliskHome string, lastN int) ([]string, error)
@@ -56,12 +56,22 @@ type CommitRepo interface {
 	DownloadAtCommit(commit, destDir, destFile string) (string, error)
 }
 
+// RollingRepo represents a repository that stores rolling Bazel releases.
+type RollingRepo interface {
+	// GetRollingVersions returns a list of all available rolling release versions.
+	GetRollingVersions(bazeliskHome string) ([]string, error)
+
+	// DownloadRolling downloads the given Bazel version into the specified location and returns the absolute path.
+	DownloadRolling(version, destDir, destFile string) (string, error)
+}
+
 // Repositories offers access to different types of Bazel repositories, mainly for finding and downloading the correct version of Bazel.
 type Repositories struct {
 	Releases        ReleaseRepo
 	Candidates      CandidateRepo
 	Fork            ForkRepo
 	Commits         CommitRepo
+	Rolling         RollingRepo
 	supportsBaseURL bool
 }
 
@@ -80,6 +90,8 @@ func (r *Repositories) ResolveVersion(bazeliskHome, fork, version string) (strin
 		return r.resolveCandidate(bazeliskHome, vi)
 	} else if vi.IsCommit {
 		return r.resolveCommit(bazeliskHome, vi)
+	} else if vi.IsRolling {
+		return r.resolveRolling(bazeliskHome, vi)
 	}
 
 	return "", nil, fmt.Errorf("Unsupported version identifier '%s'", version)
@@ -142,6 +154,20 @@ func (r *Repositories) resolveCommit(bazeliskHome string, vi *versions.Info) (st
 	return version, downloader, nil
 }
 
+func (r *Repositories) resolveRolling(bazeliskHome string, vi *versions.Info) (string, DownloadFunc, error) {
+	lister := func(bazeliskHome string) ([]string, error) {
+		return r.Rolling.GetRollingVersions(bazeliskHome)
+	}
+	version, err := resolvePotentiallyRelativeVersion(bazeliskHome, lister, vi)
+	if err != nil {
+		return "", nil, err
+	}
+	downloader := func(destDir, destFile string) (string, error) {
+		return r.Rolling.DownloadRolling(version, destDir, destFile)
+	}
+	return version, downloader, nil
+}
+
 type listVersionsFunc func(bazeliskHome string) ([]string, error)
 
 func resolvePotentiallyRelativeVersion(bazeliskHome string, lister listVersionsFunc, vi *versions.Info) (string, error) {
@@ -179,11 +205,11 @@ func (r *Repositories) DownloadFromBaseURL(baseURL, version, destDir, destFile s
 }
 
 // CreateRepositories creates a new Repositories instance with the given repositories. Any nil repository will be replaced by a dummy repository that raises an error whenever a download is attempted.
-func CreateRepositories(releases ReleaseRepo, candidates CandidateRepo, fork ForkRepo, commits CommitRepo, supportsBaseURL bool) *Repositories {
+func CreateRepositories(releases ReleaseRepo, candidates CandidateRepo, fork ForkRepo, commits CommitRepo, rolling RollingRepo, supportsBaseURL bool) *Repositories {
 	repos := &Repositories{supportsBaseURL: supportsBaseURL}
 
 	if releases == nil {
-		repos.Releases = &noReleaseRepo{errors.New("official Bazel releases are not supported")}
+		repos.Releases = &noReleaseRepo{errors.New("Bazel LTS releases are not supported")}
 	} else {
 		repos.Releases = releases
 	}
@@ -206,6 +232,12 @@ func CreateRepositories(releases ReleaseRepo, candidates CandidateRepo, fork For
 		repos.Commits = commits
 	}
 
+	if rolling == nil {
+		repos.Rolling = &noRollingRepo{errors.New("Bazel rolling releases are not supported")}
+	} else {
+		repos.Rolling = rolling
+	}
+
 	return repos
 }
 
@@ -259,3 +291,15 @@ func (nlgr *noCommitRepo) GetLastGreenCommit(bazeliskHome string, downstreamGree
 func (nlgr *noCommitRepo) DownloadAtCommit(commit, destDir, destFile string) (string, error) {
 	return "", nlgr.Error
 }
+
+type noRollingRepo struct {
+	Error error
+}
+
+func (nrr *noRollingRepo) GetRollingVersions(bazeliskHome string) ([]string, error) {
+	return []string{}, nrr.Error
+}
+
+func (nrr *noRollingRepo) DownloadRolling(version, destDir, destFile string) (string, error) {
+	return "", nrr.Error
+}
diff --git a/repositories/github.go b/repositories/github.go
index 0be03d6..4fab351 100644
--- a/repositories/github.go
+++ b/repositories/github.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/bazelbuild/bazelisk/httputil"
 	"github.com/bazelbuild/bazelisk/platforms"
+	"github.com/bazelbuild/bazelisk/versions"
 )
 
 const (
@@ -26,6 +27,10 @@ func CreateGitHubRepo(token string) *GitHubRepo {
 
 // GetVersions returns the versions of all available Bazel binaries in the given fork.
 func (gh *GitHubRepo) GetVersions(bazeliskHome, bazelFork string) ([]string, error) {
+	return gh.getFilteredVersions(bazeliskHome, bazelFork, false)
+}
+
+func (gh *GitHubRepo) getFilteredVersions(bazeliskHome, bazelFork string, wantPrerelease bool) ([]string, error) {
 	url := fmt.Sprintf("https://api.github.com/repos/%s/bazel/releases", bazelFork)
 	releasesJSON, err := httputil.MaybeDownload(bazeliskHome, url, bazelFork+"-releases.json", "list of Bazel releases from github.com/"+bazelFork, gh.token)
 	if err != nil {
@@ -39,7 +44,7 @@ func (gh *GitHubRepo) GetVersions(bazeliskHome, bazelFork string) ([]string, err
 
 	var tags []string
 	for _, release := range releases {
-		if release.Prerelease {
+		if release.Prerelease != wantPrerelease {
 			continue
 		}
 		tags = append(tags, release.TagName)
@@ -61,3 +66,14 @@ func (gh *GitHubRepo) DownloadVersion(fork, version, destDir, destFile string) (
 	url := fmt.Sprintf(urlPattern, fork, version, filename)
 	return httputil.DownloadBinary(url, destDir, destFile)
 }
+
+// GetRollingVersions returns a list of all available rolling release versions.
+func (gh *GitHubRepo) GetRollingVersions(bazeliskHome string) ([]string, error) {
+	// Release candidates are uploaded to GCS only, which means that all prerelease binaries on GitHub belong to rolling releases.
+	return gh.getFilteredVersions(bazeliskHome, versions.BazelUpstream, true)
+}
+
+// DownloadRolling downloads the given Bazel version into the specified location and returns the absolute path.
+func (gh *GitHubRepo) DownloadRolling(version, destDir, destFile string) (string, error) {
+	return gh.DownloadVersion(versions.BazelUpstream, version, destDir, destFile)
+}
diff --git a/versions/versions.go b/versions/versions.go
index 2ed9152..acb3f87 100644
--- a/versions/versions.go
+++ b/versions/versions.go
@@ -19,13 +19,14 @@ const (
 var (
 	releasePattern       = regexp.MustCompile(`^(\d+\.\d+\.\d+)$`)
 	candidatePattern     = regexp.MustCompile(`^(\d+\.\d+\.\d+)rc(\d+)$`)
+	rollingPattern       = regexp.MustCompile(`\d+\.0\.0-pre\.\d{6}(\.\d+)?`)
 	latestReleasePattern = regexp.MustCompile(`^latest(?:-(?P<offset>\d+))?$`)
 	commitPattern        = regexp.MustCompile(`^[a-z0-9]{40}$`)
 )
 
 // Info represents a structured Bazel version identifier.
 type Info struct {
-	IsRelease, IsCandidate, IsCommit, IsFork, IsRelative, IsDownstream bool
+	IsRelease, IsCandidate, IsCommit, IsFork, IsRolling, IsRelative, IsDownstream bool
 	Fork, Value                                                        string
 	LatestOffset                                                       int
 }
@@ -60,6 +61,11 @@ func Parse(fork, version string) (*Info, error) {
 		vi.IsCommit = true
 		vi.IsRelative = true
 		vi.IsDownstream = true
+	} else if rollingPattern.MatchString(version) {
+		vi.IsRolling = true
+	} else if version == "rolling" {
+		vi.IsRolling = true
+		vi.IsRelative = true
 	} else {
 		return nil, fmt.Errorf("Invalid version '%s'", version)
 	}


Test patch:
diff --git a/bazelisk_version_test.go b/bazelisk_version_test.go
index 620aabe..fdefeeb 100644
--- a/bazelisk_version_test.go
+++ b/bazelisk_version_test.go
@@ -47,7 +47,7 @@ func TestResolveLatestRcVersion(t *testing.T) {
 	s.Finish()
 
 	gcs := &repositories.GCSRepo{}
-	repos := core.CreateRepositories(nil, gcs, nil, nil, false)
+	repos := core.CreateRepositories(nil, gcs, nil, nil, nil, false)
 	version, _, err := repos.ResolveVersion(tmpDir, versions.BazelUpstream, "last_rc")
 
 	if err != nil {
@@ -65,7 +65,7 @@ func TestResolveLatestRcVersion_WithFullRelease(t *testing.T) {
 	s.Finish()
 
 	gcs := &repositories.GCSRepo{}
-	repos := core.CreateRepositories(nil, gcs, nil, nil, false)
+	repos := core.CreateRepositories(nil, gcs, nil, nil, nil, false)
 	version, _, err := repos.ResolveVersion(tmpDir, versions.BazelUpstream, "last_rc")
 
 	if err != nil {
@@ -85,7 +85,7 @@ func TestResolveLatestVersion_TwoLatestVersionsDoNotHaveAReleaseYet(t *testing.T
 	s.Finish()
 
 	gcs := &repositories.GCSRepo{}
-	repos := core.CreateRepositories(gcs, nil, nil, nil, false)
+	repos := core.CreateRepositories(gcs, nil, nil, nil, nil, false)
 	version, _, err := repos.ResolveVersion(tmpDir, versions.BazelUpstream, "latest")
 
 	if err != nil {
@@ -106,7 +106,7 @@ func TestResolveLatestVersion_FilterReleaseCandidates(t *testing.T) {
 	s.Finish()
 
 	gcs := &repositories.GCSRepo{}
-	repos := core.CreateRepositories(gcs, nil, nil, nil, false)
+	repos := core.CreateRepositories(gcs, nil, nil, nil, nil, false)
 	version, _, err := repos.ResolveVersion(tmpDir, versions.BazelUpstream, "latest-1")
 
 	if err != nil {
@@ -125,7 +125,7 @@ func TestResolveLatestVersion_ShouldFailIfNotEnoughReleases(t *testing.T) {
 	s.Finish()
 
 	gcs := &repositories.GCSRepo{}
-	repos := core.CreateRepositories(gcs, nil, nil, nil, false)
+	repos := core.CreateRepositories(gcs, nil, nil, nil, nil, false)
 	_, _, err := repos.ResolveVersion(tmpDir, versions.BazelUpstream, "latest-1")
 
 	if err == nil {
@@ -143,7 +143,7 @@ func TestResolveLatestVersion_GCSIsDown(t *testing.T) {
 	transport.AddResponse("https://www.googleapis.com/storage/v1/b/bazel/o?delimiter=/", 500, "")
 
 	gcs := &repositories.GCSRepo{}
-	repos := core.CreateRepositories(gcs, nil, nil, nil, false)
+	repos := core.CreateRepositories(gcs, nil, nil, nil, nil, false)
 	_, _, err := repos.ResolveVersion(tmpDir, versions.BazelUpstream, "latest")
 
 	if err == nil {
@@ -159,7 +159,7 @@ func TestResolveLatestVersion_GitHubIsDown(t *testing.T) {
 	transport.AddResponse("https://api.github.com/repos/bazelbuild/bazel/releases", 500, "")
 
 	gh := repositories.CreateGitHubRepo("test_token")
-	repos := core.CreateRepositories(nil, nil, gh, nil, false)
+	repos := core.CreateRepositories(nil, nil, gh, nil, nil, false)
 
 	_, _, err := repos.ResolveVersion(tmpDir, "some_fork", "latest")
 
@@ -172,6 +172,62 @@ func TestResolveLatestVersion_GitHubIsDown(t *testing.T) {
 	}
 }
 
+func TestAcceptRollingReleaseName(t *testing.T) {
+	gh := repositories.CreateGitHubRepo("test_token")
+	repos := core.CreateRepositories(nil, nil, nil, nil, gh, false)
+
+	version := "10.0.0-pre.20201103.4"
+
+	resolvedVersion, _, err := repos.ResolveVersion(tmpDir, "", version)
+
+	if err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if resolvedVersion != version {
+		t.Fatalf("Expected version '%s', but got '%s'", version, resolvedVersion)
+	}
+}
+
+func TestResolveLatestRollingRelease(t *testing.T) {
+	text := `
+	[
+	  {
+		"tag_name": "4.0.0",
+		"prerelease": false
+	  },
+	  {
+		"tag_name": "5.0.0-pre.20210319",
+		"prerelease": true
+	  },
+	  {
+		"tag_name": "5.0.0-pre.20210322.4",
+		"prerelease": true
+	  },
+	  {
+		"tag_name": "5.0.0",
+		"prerelease": false
+	  }
+	]
+	`
+
+	transport.AddResponse("https://api.github.com/repos/bazelbuild/bazel/releases", 200, text)
+
+	gh := repositories.CreateGitHubRepo("test_token")
+	repos := core.CreateRepositories(nil, nil, nil, nil, gh, false)
+
+	version, _, err := repos.ResolveVersion(tmpDir, "", "rolling")
+
+	if err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	want := "5.0.0-pre.20210322.4"
+	if version != want {
+		t.Fatalf("Expected version '%s', but got '%s'", want, version)
+	}
+}
+
 type gcsSetup struct {
 	baseURL         string
 	versionPrefixes []string