cmd/gorelease: accept version queries for -base

This allows -base to be something like "latest" or "master".

Fixes golang/go#37410
Fixes golang/go#37412

Change-Id: I1bdf26e6524518079298a977c7de702db11ed286
Reviewed-on: https://go-review.googlesource.com/c/exp/+/236598
Run-TryBot: Jay Conrod <jayconrod@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Bryan C. Mills <bcmills@google.com>
Reviewed-by: Michael Matloob <matloob@golang.org>
diff --git a/cmd/gorelease/gorelease.go b/cmd/gorelease/gorelease.go
index 5f26b7f..4a2dbed 100644
--- a/cmd/gorelease/gorelease.go
+++ b/cmd/gorelease/gorelease.go
@@ -38,8 +38,8 @@
 // gorelease accepts the following flags:
 //
 // -base=version: The version that the current version of the module will be
-// compared against. The version must be a semantic version (for example,
-// "v2.3.4") or "none". If the version is "none", gorelease will not compare the
+// compared against. This may be a version like "v1.5.2", a version query like
+// "latest", or "none". If the version is "none", gorelease will not compare the
 // current version against any previous version; it will only validate the
 // current version. This is useful for checking the first release of a new major
 // version. If -base is not specified, gorelease will attempt to infer a base
@@ -104,33 +104,12 @@
 //   the APIs are still compatible, just with a different module split).
 
 // TODO(jayconrod):
-// * Allow -base to be an arbitrary revision name that resolves to a version
-//   or pseudo-version.
-// * Don't accept -version that increments minor or patch version by more than 1
-//   or increments the minor version without zeroing the patch, compared with
-//   existing versions. Note that -base may be distant from -version,
-//   for example, when reverting an incompatible change accidentally released.
-// * Report errors when packages can't be loaded without replace / exclude.
 // * Clean up overuse of fmt.Errorf.
-// * Support -json output.
-// * Don't suggest a release tag that already exists.
-// * Suggest a minor release if dependency has been bumped by minor version.
-// * Updating go version, either in the main module or in a dependency that
-//   provides packages transitively imported by non-internal, non-test packages
-//   in the main module, should require a minor version bump.
 // * Support migration to modules after v2.x.y+incompatible. Requires comparing
 //   packages with different module paths.
 // * Error when packages import from earlier major version of same module.
 //   (this may be intentional; look for real examples first).
-// * Check that proposed prerelease will not sort below pseudo-versions.
-// * Error messages point to HTML documentation.
-// * Positional arguments should specify which packages to check. Without
-//   these, we check all non-internal packages in the module.
 // * Mechanism to suppress error messages.
-// * Check that the main module does not transitively require a newer version
-//   of itself.
-// * Invalid file names and import paths should be reported sensibly.
-//   golang.org/x/mod/zip should return structured errors for this.
 
 func main() {
 	log.SetFlags(0)
@@ -172,17 +151,12 @@
 	if len(fs.Args()) > 0 {
 		return false, usageErrorf("no arguments allowed")
 	}
-	if baseVersion != "" && baseVersion != "none" {
-		if c := semver.Canonical(baseVersion); c != baseVersion {
-			return false, usageErrorf("base version %q is not a canonical semantic version", baseVersion)
-		}
-	}
 	if releaseVersion != "" {
 		if c := semver.Canonical(releaseVersion); c != releaseVersion {
 			return false, usageErrorf("release version %q is not a canonical semantic version", releaseVersion)
 		}
 	}
-	if baseVersion != "" && baseVersion != "none" && releaseVersion != "" {
+	if baseVersion != "" && semver.Canonical(baseVersion) == baseVersion && releaseVersion != "" {
 		if cmp := semver.Compare(baseVersion, releaseVersion); cmp == 0 {
 			return false, usageErrorf("-base and -version must be different")
 		} else if cmp > 0 {
@@ -262,11 +236,24 @@
 		panic(fmt.Sprintf("could not find version suffix in module path %q", modPath))
 	}
 
+	var baseVersionQuery string
 	baseVersionInferred := baseVersion == ""
 	if baseVersionInferred {
 		if baseVersion, err = inferBaseVersion(modPath, releaseVersion); err != nil {
 			return report{}, err
 		}
+	} else if baseVersion != "none" && baseVersion != module.CanonicalVersion(baseVersion) {
+		baseVersionQuery = baseVersion
+		if baseVersion, err = queryVersion(modPath, baseVersionQuery); err != nil {
+			return report{}, err
+		}
+		if baseVersion != "none" && releaseVersion != "" && semver.Compare(baseVersion, releaseVersion) >= 0 {
+			// TODO(jayconrod): reconsider this comparison for pseudo-versions in
+			// general. A query might match different pseudo-versions over time,
+			// depending on ancestor versions, so this might start failing with
+			// no local change.
+			return report{}, fmt.Errorf("base version %s (%s) must be lower than release version %s", baseVersion, baseVersionQuery, releaseVersion)
+		}
 	}
 	if baseVersion != "none" {
 		if err := module.Check(modPath, baseVersion); err != nil {
@@ -392,6 +379,7 @@
 		modulePath:          modPath,
 		baseVersion:         baseVersion,
 		baseVersionInferred: baseVersionInferred,
+		baseVersionQuery:    baseVersionQuery,
 		releaseVersion:      releaseVersion,
 		tagPrefix:           tagPrefix,
 		diagnostics:         diagnostics,
@@ -549,6 +537,37 @@
 	return "", fmt.Errorf("no versions found lower than %s", releaseVersion)
 }
 
+// queryVersion returns the canonical version for a given module version query.
+func queryVersion(modPath, query string) (resolved string, err error) {
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf("could not resolve version %s@%s: %w", modPath, query, err)
+		}
+	}()
+	if query == "upgrade" || query == "patch" {
+		return "", errors.New("query is based on requirements in main go.mod file")
+	}
+
+	tmpDir, err := ioutil.TempDir("", "")
+	if err != nil {
+		return "", err
+	}
+	defer func() {
+		if rerr := os.Remove(tmpDir); rerr != nil && err == nil {
+			err = rerr
+		}
+	}()
+	arg := modPath + "@" + query
+	cmd := exec.Command("go", "list", "-m", "-f", "{{.Version}}", "--", arg)
+	cmd.Dir = tmpDir
+	cmd.Env = append(os.Environ(), "GO111MODULE=on")
+	out, err := cmd.Output()
+	if err != nil {
+		return "", cleanCmdError(err)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
 // loadVersions loads the list of versions for the given module using
 // 'go list -m -versions'. The returned versions are sorted in ascending
 // semver order.
@@ -557,7 +576,11 @@
 	if err != nil {
 		return nil, err
 	}
-	defer os.Remove(tmpDir)
+	defer func() {
+		if rerr := os.Remove(tmpDir); rerr != nil && err == nil {
+			err = rerr
+		}
+	}()
 	cmd := exec.Command("go", "list", "-m", "-versions", "--", modPath)
 	cmd.Dir = tmpDir
 	cmd.Env = append(os.Environ(), "GO111MODULE=on")
diff --git a/cmd/gorelease/gorelease_test.go b/cmd/gorelease/gorelease_test.go
index 3e94866..a29f438 100644
--- a/cmd/gorelease/gorelease_test.go
+++ b/cmd/gorelease/gorelease_test.go
@@ -104,7 +104,7 @@
 	// to gorelease.
 	baseVersion string
 
-	// releaseVersion (set with version=...) is the value of the -version flag
+	// releaseVersion (set with release=...) is the value of the -version flag
 	// to pass to gorelease.
 	releaseVersion string
 
diff --git a/cmd/gorelease/report.go b/cmd/gorelease/report.go
index e26f153..1a1218f 100644
--- a/cmd/gorelease/report.go
+++ b/cmd/gorelease/report.go
@@ -31,6 +31,9 @@
 	// automatically (not specified with -base).
 	baseVersionInferred bool
 
+	// baseVersionQuery is set if -base was a version query (like "latest").
+	baseVersionQuery string
+
 	// releaseVersion is the version of the module to release, either
 	// proposed with -version or inferred with suggestVersion.
 	releaseVersion string
@@ -87,6 +90,8 @@
 
 	if r.baseVersionInferred {
 		fmt.Fprintf(buf, "Inferred base version: %s\n", r.baseVersion)
+	} else if r.baseVersionQuery != "" {
+		fmt.Fprintf(buf, "Base version: %s (%s)\n", r.baseVersion, r.baseVersionQuery)
 	}
 
 	if len(r.diagnostics) > 0 {
diff --git a/cmd/gorelease/testdata/basic/v1_querybase_higher.test b/cmd/gorelease/testdata/basic/v1_querybase_higher.test
new file mode 100644
index 0000000..7bf77bc
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_querybase_higher.test
@@ -0,0 +1,8 @@
+mod=example.com/basic
+version=v1.0.1
+release=v1.0.1
+base=>v1.0.1
+error=true
+
+-- want --
+base version v1.1.0 (>v1.0.1) must be lower than release version v1.0.1
diff --git a/cmd/gorelease/testdata/basic/v1_querybase_suggest.test b/cmd/gorelease/testdata/basic/v1_querybase_suggest.test
new file mode 100644
index 0000000..6bfc95c
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_querybase_suggest.test
@@ -0,0 +1,6 @@
+mod=example.com/basic
+version=v1.0.1
+base=version-1.0.1
+-- want --
+Base version: v1.0.1 (version-1.0.1)
+Suggested version: v1.0.2
diff --git a/cmd/gorelease/testdata/basic/v1_querybase_verify.test b/cmd/gorelease/testdata/basic/v1_querybase_verify.test
new file mode 100644
index 0000000..f4f6d10
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_querybase_verify.test
@@ -0,0 +1,7 @@
+mod=example.com/basic
+version=v1.0.1
+base=version-1.0.1
+release=v1.0.2
+-- want --
+Base version: v1.0.1 (version-1.0.1)
+v1.0.2 is a valid semantic version for this release.
diff --git a/cmd/gorelease/testdata/errors/bad_base.test b/cmd/gorelease/testdata/errors/bad_base.test
deleted file mode 100644
index 6147929..0000000
--- a/cmd/gorelease/testdata/errors/bad_base.test
+++ /dev/null
@@ -1,8 +0,0 @@
-mod=example.com/errors
-base=master
-error=true
-
--- want --
-usage: gorelease [-base=version] [-version=version]
-base version "master" is not a canonical semantic version
-For more information, run go doc golang.org/x/exp/cmd/gorelease
diff --git a/cmd/gorelease/testdata/errors/upgrade_base.test b/cmd/gorelease/testdata/errors/upgrade_base.test
new file mode 100644
index 0000000..d09b68a
--- /dev/null
+++ b/cmd/gorelease/testdata/errors/upgrade_base.test
@@ -0,0 +1,7 @@
+mod=example.com/errors
+version=v0.1.0
+base=upgrade
+error=true
+
+-- want --
+could not resolve version example.com/errors@upgrade: query is based on requirements in main go.mod file
diff --git a/cmd/gorelease/testdata/mod/example.com_basic_version-1.0.1.txt b/cmd/gorelease/testdata/mod/example.com_basic_version-1.0.1.txt
new file mode 100644
index 0000000..34e769e
--- /dev/null
+++ b/cmd/gorelease/testdata/mod/example.com_basic_version-1.0.1.txt
@@ -0,0 +1,2 @@
+-- .info --
+{"Version":"v1.0.1"}
diff --git a/cmd/gorelease/testdata/mod/example.com_errors_master.txt b/cmd/gorelease/testdata/mod/example.com_errors_master.txt
deleted file mode 100644
index c4c52ed..0000000
--- a/cmd/gorelease/testdata/mod/example.com_errors_master.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-Non-canonical version, referenced in errors/bad_base.test.
-For now, it's an error to use a non-canonical -base. It won't be in the future.
--- .info --
-{"Version":"v0.2.0"}
diff --git a/go.mod b/go.mod
index 7e25677..65cda84 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,6 @@
 	golang.org/x/image v0.0.0-20190802002840-cff245a6509b
 	golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028
 	golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b
-	golang.org/x/sync v0.0.0-20190423024810-112230192c58
 	golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24
 	golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa
 	golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898