internal/task: add version bump strategy selection for gopls release

A local relui screenshot is at https://go.dev/issue/57643#issuecomment-2326945383

For golang/go#57643

Change-Id: I8168c59552ebcdbc4626b28e8fd456816dd8bf7c
Reviewed-on: https://go-review.googlesource.com/c/build/+/609078
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Hongxiang Jiang <hxjiang@golang.org>
Reviewed-by: Robert Findley <rfindley@google.com>
diff --git a/internal/task/releasegopls.go b/internal/task/releasegopls.go
index 08d5167..0143d7e 100644
--- a/internal/task/releasegopls.go
+++ b/internal/task/releasegopls.go
@@ -33,8 +33,8 @@
 func (r *ReleaseGoplsTasks) NewPrereleaseDefinition() *wf.Definition {
 	wd := wf.New(wf.ACL{Groups: []string{groups.ToolsTeam}})
 
-	// versionBumpStrategy specifies the desired release type: next minor or next
-	// patch.
+	// versionBumpStrategy specifies the desired release type: next minor, next
+	// patch or use explicit version.
 	// This should be the default choice for most releases.
 	versionBumpStrategy := wf.Param(wd, nextVersionParam)
 	// inputVersion allows manual override of the version, bypassing the version
@@ -43,23 +43,23 @@
 	inputVersion := wf.Param(wd, wf.ParamDef[string]{Name: "explicit version (optional)"})
 	reviewers := wf.Param(wd, reviewersParam)
 
-	semv := wf.Task2(wd, "determine the version", r.determineVersion, inputVersion, versionBumpStrategy)
-	prerelease := wf.Task1(wd, "find the pre-release version", r.nextPrereleaseVersion, semv)
-	approved := wf.Action2(wd, "wait for release coordinator approval", r.approveVersion, semv, prerelease)
+	release := wf.Task2(wd, "determine the version", r.determineReleaseVersion, inputVersion, versionBumpStrategy)
+	prerelease := wf.Task1(wd, "find the next pre-release version", r.nextPrereleaseVersion, release)
+	approved := wf.Action2(wd, "wait for release coordinator approval", r.approveVersion, release, prerelease)
 
-	issue := wf.Task2(wd, "create release git issue", r.findOrCreateGitHubIssue, semv, wf.Const(true), wf.After(approved))
-	branchCreated := wf.Action1(wd, "create new branch if minor release", r.createBranchIfMinor, semv, wf.After(issue))
+	issue := wf.Task2(wd, "create release git issue", r.findOrCreateGitHubIssue, release, wf.Const(true), wf.After(approved))
+	branchCreated := wf.Action1(wd, "create new branch if minor release", r.createBranchIfMinor, release, wf.After(issue))
 
-	configChangeID := wf.Task3(wd, "update branch's codereview.cfg", r.updateCodeReviewConfig, semv, reviewers, issue, wf.After(branchCreated))
+	configChangeID := wf.Task3(wd, "update branch's codereview.cfg", r.updateCodeReviewConfig, release, reviewers, issue, wf.After(branchCreated))
 	configCommit := wf.Task1(wd, "await config CL submission", clAwaiter{r.Gerrit}.awaitSubmission, configChangeID)
 
-	dependencyChangeID := wf.Task4(wd, "update gopls' x/tools dependency", r.updateXToolsDependency, semv, prerelease, reviewers, issue, wf.After(configCommit))
+	dependencyChangeID := wf.Task4(wd, "update gopls' x/tools dependency", r.updateXToolsDependency, release, prerelease, reviewers, issue, wf.After(configCommit))
 	dependencyCommit := wf.Task1(wd, "await gopls' x/tools dependency CL submission", clAwaiter{r.Gerrit}.awaitSubmission, dependencyChangeID)
 
 	verified := wf.Action1(wd, "verify installing latest gopls using release branch dependency commit", r.verifyGoplsInstallation, dependencyCommit)
-	prereleaseVersion := wf.Task3(wd, "tag pre-release", r.tagPrerelease, semv, dependencyCommit, prerelease, wf.After(verified))
+	prereleaseVersion := wf.Task3(wd, "tag pre-release", r.tagPrerelease, release, dependencyCommit, prerelease, wf.After(verified))
 	prereleaseVerified := wf.Action1(wd, "verify installing latest gopls using release branch pre-release version", r.verifyGoplsInstallation, prereleaseVersion)
-	wf.Action4(wd, "mail announcement", r.mailAnnouncement, semv, prereleaseVersion, dependencyCommit, issue, wf.After(prereleaseVerified))
+	wf.Action4(wd, "mail announcement", r.mailAnnouncement, release, prereleaseVersion, dependencyCommit, issue, wf.After(prereleaseVerified))
 
 	vscodeGoChange := wf.Task4(wd, "update gopls version in vscode-go project", r.updateGoplsVersionInVSCodeGo, reviewers, issue, prereleaseVersion, wf.Const("master"), wf.After(prereleaseVerified))
 	_ = wf.Task1(wd, "await gopls version update CL submission in vscode-go project", clAwaiter{r.Gerrit}.awaitSubmission, vscodeGoChange)
@@ -69,11 +69,11 @@
 	return wd
 }
 
-// determineVersion returns the release version based on coordinator inputs.
+// determineReleaseVersion returns the release version based on coordinator inputs.
 //
 // Returns the specified input version if provided; otherwise, interpret a new
 // version based on the version bumping strategy.
-func (r *ReleaseGoplsTasks) determineVersion(ctx *wf.TaskContext, inputVersion, versionBumpStrategy string) (semversion, error) {
+func (r *ReleaseGoplsTasks) determineReleaseVersion(ctx *wf.TaskContext, inputVersion, versionBumpStrategy string) (semversion, error) {
 	switch versionBumpStrategy {
 	case "use explicit version":
 		if inputVersion == "" {
@@ -299,11 +299,27 @@
 // nextPrereleaseVersion inspects the tags in tools repo that match with the given
 // version and finds the next prerelease version.
 func (r *ReleaseGoplsTasks) nextPrereleaseVersion(ctx *wf.TaskContext, semv semversion) (string, error) {
-	cur, err := currentGoplsPrerelease(ctx, r.Gerrit, semv)
+	tags, err := r.Gerrit.ListTags(ctx, "tools")
 	if err != nil {
 		return "", err
 	}
-	return fmt.Sprintf("pre.%v", cur+1), nil
+
+	var versions []string
+	for _, tag := range tags {
+		if v, ok := strings.CutPrefix(tag, "gopls/"); ok {
+			versions = append(versions, v)
+		}
+	}
+
+	rc := latestVersion(versions, isSameMajorMinorPatch(semv), isPrereleaseMatchRegex(`^pre\.\d+$`))
+	if rc == (semversion{}) {
+		return "pre.1", nil
+	}
+	pre, err := rc.prereleaseVersion()
+	if err != nil {
+		return "", err
+	}
+	return fmt.Sprintf("pre.%v", pre+1), nil
 }
 
 // currentGoplsPrerelease inspects the tags in tools repo that match with the
@@ -635,97 +651,52 @@
 func (r *ReleaseGoplsTasks) NewReleaseDefinition() *wf.Definition {
 	wd := wf.New(wf.ACL{Groups: []string{groups.ToolsTeam}})
 
-	version := wf.Param(wd, wf.ParamDef[string]{Name: "pre-release version"})
+	// versionBumpStrategy specifies the desired release type: next minor, next
+	// patch or use explicit version.
+	// This should be the default choice for most releases.
+	versionBumpStrategy := wf.Param(wd, nextVersionParam)
+	// inputVersion allows manual override of the version, bypassing the version
+	// bump strategy.
+	// Use with caution.
+	inputVersion := wf.Param(wd, wf.ParamDef[string]{Name: "explicit pre-release version (optional)"})
 	reviewers := wf.Param(wd, reviewersParam)
 
-	semv := wf.Task1(wd, "validating input version", r.isValidPrereleaseVersion, version)
-	tagged := wf.Action1(wd, "tag the release", r.tagRelease, semv)
+	release := wf.Task2(wd, "determine the release version", r.determineReleaseVersion, versionBumpStrategy, inputVersion)
+	prerelease := wf.Task1(wd, "find the latest pre-release version", r.latestPrerelease, release)
+	tagged := wf.Action2(wd, "tag the release", r.tagRelease, release, prerelease)
 
-	issue := wf.Task2(wd, "find release git issue", r.findOrCreateGitHubIssue, semv, wf.Const(false))
-	changeID := wf.Task3(wd, "updating x/tools dependency in master branch in gopls sub dir", r.updateDependencyIfMinor, reviewers, semv, issue, wf.After(tagged))
+	issue := wf.Task2(wd, "find release git issue", r.findOrCreateGitHubIssue, release, wf.Const(false))
+	changeID := wf.Task3(wd, "updating x/tools dependency in master branch in gopls sub dir", r.updateDependencyIfMinor, reviewers, release, issue, wf.After(tagged))
 	_ = wf.Task1(wd, "await x/tools gopls dependency CL submission in gopls sub dir", clAwaiter{r.Gerrit}.awaitSubmission, changeID)
 
 	return wd
 }
 
-// isValidPrereleaseVersion verifies the input version satisfies the following
-// conditions:
-// - It is the latest pre-release version for its Major.Minor.Patch.
-// - A corresponding "gopls/vX.Y.Z-pre.V" tag exists in the x/tools repo,
-// pointing to the release branch's head commit.
-// - The corresponding release tag "gopls/vX.Y.Z" does not exist in the repo.
-//
-// Returns the parsed semantic pre-release version from the input pre-release
-// version string.
-func (r *ReleaseGoplsTasks) isValidPrereleaseVersion(ctx *wf.TaskContext, version string) (semversion, error) {
-	if !semver.IsValid(version) {
-		return semversion{}, fmt.Errorf("the input %q version does not follow semantic version schema", version)
-	}
-
-	semv, ok := parseSemver(version)
-	if !ok {
-		return semversion{}, fmt.Errorf("invalid semantic version %q", version)
-	}
-	if semv.Pre == "" {
-		return semversion{}, fmt.Errorf("the input %q version does not contain any pre-release version.", version)
-	}
-
-	pre, err := semv.prereleaseVersion()
-	if err != nil {
-		return semversion{}, err
-	}
-
-	// Verify the release tag is absent.
+func (r *ReleaseGoplsTasks) latestPrerelease(ctx *wf.TaskContext, semv semversion) (string, error) {
 	tags, err := r.Gerrit.ListTags(ctx, "tools")
 	if err != nil {
-		return semversion{}, err
+		return "", err
 	}
 
-	releaseTag := fmt.Sprintf("gopls/v%v.%v.%v", semv.Major, semv.Minor, semv.Patch)
+	var versions []string
 	for _, tag := range tags {
-		if tag == releaseTag {
-			return semversion{}, fmt.Errorf("this version has been released, the release tag %s already exists.", releaseTag)
+		if v, ok := strings.CutPrefix(tag, "gopls/"); ok {
+			versions = append(versions, v)
 		}
 	}
 
-	// Check if the provided pre-release is the latest among all existing
-	// pre-release versions.
-	current, err := currentGoplsPrerelease(ctx, r.Gerrit, semv)
-	if err != nil {
-		return semversion{}, fmt.Errorf("failed to figure out the current prerelease version for gopls v%v.%v.%v", semv.Major, semv.Minor, semv.Patch)
+	rc := latestVersion(versions, isSameMajorMinorPatch(semv), isPrereleaseMatchRegex(`^pre\.\d+$`))
+	if rc == (semversion{}) {
+		return "", fmt.Errorf("could not find any release candidate for v%v.%v.%v", semv.Major, semv.Minor, semv.Patch)
 	}
 
-	if current > pre {
-		return semversion{}, fmt.Errorf("there is a newer pre-release version available: %v.%v.%v-pre.%v", semv.Major, semv.Minor, semv.Patch, current)
-	}
-
-	if current < pre {
-		return semversion{}, fmt.Errorf("input pre-release version %s is not yet available. Latest available is %v.%v.%v-pre.%v", version, semv.Major, semv.Minor, semv.Patch, current)
-	}
-
-	// Check if the provided pre-release is the head of the branch.
-	branchName := goplsReleaseBranchName(semv)
-	head, err := r.Gerrit.ReadBranchHead(ctx, "tools", branchName)
-	if err != nil {
-		return semversion{}, err
-	}
-
-	tagInfo, err := r.Gerrit.GetTag(ctx, "tools", fmt.Sprintf("gopls/%s", version))
-	if err != nil {
-		return semversion{}, err
-	}
-
-	if tagInfo.Revision != head {
-		return semversion{}, fmt.Errorf("x/tools branch %s's head commit is %s, but tag %s points to revision %s", branchName, head, fmt.Sprintf("gopls/%s", version), tagInfo.Revision)
-	}
-
-	return semv, nil
+	return rc.Pre, nil
 }
 
 // tagRelease locates the commit associated with the pre-release version and
 // applies the official release tag in form of "gopls/vX.Y.Z" to the same commit.
-func (r *ReleaseGoplsTasks) tagRelease(ctx *wf.TaskContext, semv semversion) error {
-	info, err := r.Gerrit.GetTag(ctx, "tools", fmt.Sprintf("gopls/v%v.%v.%v-%s", semv.Major, semv.Minor, semv.Patch, semv.Pre))
+func (r *ReleaseGoplsTasks) tagRelease(ctx *wf.TaskContext, semv semversion, prerelease string) error {
+	info, err := r.Gerrit.GetTag(ctx, "tools", fmt.Sprintf("gopls/v%v.%v.%v-%s", semv.Major, semv.Minor, semv.Patch, prerelease))
 	if err != nil {
 		return err
 	}
diff --git a/internal/task/releasegopls_test.go b/internal/task/releasegopls_test.go
index 3a43245..61f765c 100644
--- a/internal/task/releasegopls_test.go
+++ b/internal/task/releasegopls_test.go
@@ -845,89 +845,6 @@
 	}
 }
 
-func TestIsValidPrerelease(t *testing.T) {
-	testcases := []struct {
-		name string
-		// commitTags is a slice of string slice. For each inner slice, it creates a
-		// commit on the release branch and tags it with the strings in that slice.
-		// An empty inner slice results in a commit with no tags.
-		commitTags [][]string
-		version    string
-		wantErr    bool
-	}{
-		{
-			name:       "error if the gopls tag does not exist",
-			commitTags: [][]string{},
-			version:    "v0.16.2-pre.1",
-			wantErr:    true,
-		},
-		{
-			name:       "error if the tools only have older pre-release versions",
-			commitTags: [][]string{{"v0.16.2-pre.1"}, {"v0.16.2-pre.2"}, {}},
-			version:    "v0.16.2-pre.3",
-			wantErr:    true,
-		},
-		{
-			name:       "error if the tools have newer pre-release version",
-			commitTags: [][]string{{"gopls/v0.16.2-pre.1", "gopls/v0.16.2-pre.2"}},
-			version:    "v0.16.2-pre.1",
-			wantErr:    true,
-		},
-		{
-			name:       "error if the version is not pointing to head of the branch",
-			commitTags: [][]string{{"gopls/v0.16.2-pre.1", "gopls/v0.16.2-pre.2"}, {}},
-			version:    "v0.16.2-pre.2",
-			wantErr:    true,
-		},
-		{
-			name:       "error if the release tag already exist",
-			commitTags: [][]string{{"gopls/v0.16.2-pre.1", "gopls/v0.16.2-pre.2", "gopls/v0.16.2"}},
-			version:    "v0.16.2-pre.2",
-			wantErr:    true,
-		},
-		{
-			name:       "valid if the version is the latest and pointing to the head of branch",
-			commitTags: [][]string{{"gopls/v0.16.2-pre.1"}, {"gopls/v0.16.2-pre.2"}},
-			version:    "v0.16.2-pre.2",
-			wantErr:    false,
-		},
-	}
-
-	for _, tc := range testcases {
-		t.Run(tc.name, func(t *testing.T) {
-			semv, _ := parseSemver(tc.version)
-
-			tools := NewFakeRepo(t, "tools")
-			masterHead := tools.Commit(map[string]string{
-				"go.mod": "module golang.org/x/tools\n",
-				"go.sum": "\n",
-			})
-
-			tools.Branch(goplsReleaseBranchName(semv), masterHead)
-
-			for i, tags := range tc.commitTags {
-				commit := tools.CommitOnBranch(goplsReleaseBranchName(semv), map[string]string{
-					"README.md": fmt.Sprintf("THIS IS READ ME FOR %v.", i),
-				})
-				for _, tag := range tags {
-					tools.Tag(tag, commit)
-				}
-			}
-
-			tasks := &ReleaseGoplsTasks{
-				Gerrit: NewFakeGerrit(t, tools),
-			}
-
-			_, err := tasks.isValidPrereleaseVersion(&workflow.TaskContext{Context: context.Background(), Logger: &testLogger{t, ""}}, tc.version)
-			if tc.wantErr && err == nil {
-				t.Errorf("isValidPrereleaseVersion() should return error but return nil")
-			} else if !tc.wantErr && err != nil {
-				t.Errorf("isValidPrereleaseVersion() should return nil but return err: %v", err)
-			}
-		})
-	}
-}
-
 func TestTagRelease(t *testing.T) {
 	ctx := context.Background()
 	testcases := []struct {
@@ -986,7 +903,7 @@
 
 			semv, _ := parseSemver(tc.version)
 
-			err := tasks.tagRelease(&workflow.TaskContext{Context: context.Background(), Logger: &testLogger{t, ""}}, semv)
+			err := tasks.tagRelease(&workflow.TaskContext{Context: context.Background(), Logger: &testLogger{t, ""}}, semv, semv.Pre)
 
 			if tc.wantErr && err == nil {
 				t.Errorf("tagRelease(%q) should return error but return nil", tc.version)
diff --git a/internal/task/releasevscodego.go b/internal/task/releasevscodego.go
index b96d958..c24fe7f 100644
--- a/internal/task/releasevscodego.go
+++ b/internal/task/releasevscodego.go
@@ -8,6 +8,7 @@
 	_ "embed"
 	"errors"
 	"fmt"
+	"regexp"
 	"strconv"
 	"strings"
 
@@ -16,6 +17,7 @@
 	"golang.org/x/build/internal/relui/groups"
 	"golang.org/x/build/internal/workflow"
 	wf "golang.org/x/build/internal/workflow"
+	"golang.org/x/mod/semver"
 )
 
 // VSCode extensions and semantic versioning have different understandings of
@@ -294,11 +296,33 @@
 	return semv.Pre != ""
 }
 
+// isPrereleaseMatchRegex reports whether the pre-release string of the input
+// version matches the regex expression.
+func isPrereleaseMatchRegex(regex string) func(semversion) bool {
+	return func(semv semversion) bool {
+		if semv.Pre == "" {
+			return false
+		}
+		matched, err := regexp.MatchString(regex, semv.Pre)
+		if err != nil {
+			return false
+		}
+		return matched
+	}
+}
+
+func isSameMajorMinorPatch(want semversion) func(semversion) bool {
+	return func(got semversion) bool {
+		return got.Major == want.Major && got.Minor == want.Minor && got.Patch == want.Patch
+	}
+}
+
 // latestVersion returns the latest version in the provided version list,
 // considering only versions that match all the specified filters.
 // Strings not following semantic versioning are ignored.
 func latestVersion(versions []string, filters ...func(semversion) bool) semversion {
-	latest := semversion{}
+	latest := ""
+	latestSemv := semversion{}
 	for _, v := range versions {
 		semv, ok := parseSemver(v)
 		if !ok {
@@ -317,16 +341,13 @@
 			continue
 		}
 
-		if semv.Minor > latest.Minor {
-			latest = semv
-		}
-
-		if semv.Minor == latest.Minor && semv.Patch > latest.Patch {
-			latest = semv
+		if semver.Compare(v, latest) == 1 {
+			latest = v
+			latestSemv = semv
 		}
 	}
 
-	return latest
+	return latestSemv
 }
 
 func (r *ReleaseVSCodeGoTasks) approveVersion(ctx *wf.TaskContext, semv semversion) error {
diff --git a/internal/task/releasevscodego_test.go b/internal/task/releasevscodego_test.go
index 72aab41..5f79e1b 100644
--- a/internal/task/releasevscodego_test.go
+++ b/internal/task/releasevscodego_test.go
@@ -13,6 +13,65 @@
 	"golang.org/x/build/internal/workflow"
 )
 
+func TestLatestVersion(t *testing.T) {
+	testcases := []struct {
+		name    string
+		input   []string
+		filters []func(semversion) bool
+		want    semversion
+	}{
+		{
+			name:  "choose the latest version v2.1.0",
+			input: []string{"v1.0.0", "v2.0.0", "v2.1.0"},
+			want:  semversion{Major: 2, Minor: 1, Patch: 0},
+		},
+		{
+			name:  "choose the latest version v2.2.0-pre.1",
+			input: []string{"v1.0.0", "v2.0.0", "v2.1.0", "v2.2.0-pre.1"},
+			want:  semversion{Major: 2, Minor: 2, Patch: 0, Pre: "pre.1"},
+		},
+		{
+			name:    "choose the latest pre-release version v2.2.0-pre.1",
+			input:   []string{"v1.0.0", "v2.0.0", "v2.1.0", "v2.2.0-pre.1", "v2.3.0"},
+			filters: []func(semversion) bool{isPrereleaseVersion},
+			want:    semversion{Major: 2, Minor: 2, Patch: 0, Pre: "pre.1"},
+		},
+		{
+			name:    "choose the latest release version v2.1.0",
+			input:   []string{"v1.0.0", "v2.0.0", "v2.1.0", "v2.2.0-pre.1"},
+			filters: []func(semversion) bool{isReleaseVersion},
+			want:    semversion{Major: 2, Minor: 1, Patch: 0},
+		},
+		{
+			name:    "choose the latest version among v2.2.0",
+			input:   []string{"v1.0.0", "v2.0.0", "v2.1.0", "v2.2.0-pre.3", "v2.2.0-pre.2", "v2.2.0-pre.1", "v2.3.0"},
+			filters: []func(semversion) bool{isSameMajorMinorPatch(semversion{Major: 2, Minor: 2, Patch: 0})},
+			want:    semversion{Major: 2, Minor: 2, Patch: 0, Pre: "pre.3"},
+		},
+		{
+			name:    "release version is consider newer than prerelease version",
+			input:   []string{"v1.0.0", "v2.0.0", "v2.1.0", "v2.2.0", "v2.2.0-pre.2", "v2.2.0-pre.3", "v2.2.0-pre.1", "v2.3.0"},
+			filters: []func(semversion) bool{isSameMajorMinorPatch(semversion{Major: 2, Minor: 2, Patch: 0})},
+			want:    semversion{Major: 2, Minor: 2, Patch: 0},
+		},
+		{
+			name:    "choose the latest pre-release version among v2.2.0",
+			input:   []string{"v1.0.0", "v2.0.0", "v2.1.0", "v2.2.0", "v2.2.0-pre.2", "v2.2.0-pre.3", "v2.2.0-pre.1", "v2.3.0"},
+			filters: []func(semversion) bool{isPrereleaseVersion, isSameMajorMinorPatch(semversion{Major: 2, Minor: 2, Patch: 0})},
+			want:    semversion{Major: 2, Minor: 2, Patch: 0, Pre: "pre.3"},
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := latestVersion(tc.input, tc.filters...)
+			if got != tc.want {
+				t.Errorf("latestVersion() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
 func TestCreateReleaseMilestoneAndIssue(t *testing.T) {
 	testcases := []struct {
 		name          string