cmd/gorelease: fix handling of circular module dependency

When inferring a release version, gorelease currently defines
a versioned replace directive in the temporary go.mod created
for analysis. If the module being analyzed has an indirect
dependency on itself the replace directive is not matched
by the version selected during MVS, resulting in the analysis
being performed against the indirect dependency version
rather than the current branch.

Fixes golang/go#80497

Change-Id: I8fa0a99272f1b63b6e06af68537c54edba4c285d
Reviewed-on: https://go-review.googlesource.com/c/exp/+/806400
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Sean Liao <sean@liao.dev>
Reviewed-by: David Chase <drchase@google.com>
diff --git a/cmd/gorelease/gorelease.go b/cmd/gorelease/gorelease.go
index 47cedc5..80bf195 100644
--- a/cmd/gorelease/gorelease.go
+++ b/cmd/gorelease/gorelease.go
@@ -1092,7 +1092,18 @@
 	f.AddModuleStmt("gorelease-load-module")
 	f.AddRequire(modPath, version)
 	if !cached {
-		f.AddReplace(modPath, version, modRoot, "")
+		// Replace all versions of modPath (not just the specific version
+		// required above) with the local module directory. A version-scoped
+		// replace only takes effect when minimal version selection picks that
+		// exact version. If the module appears in its own build graph as a
+		// transitive dependency at a higher version (an import cycle across
+		// modules, e.g. a -> b -> a), MVS selects that higher version, the
+		// version-scoped replace is silently ignored, and the published
+		// module is loaded from the cache instead of the local directory.
+		// That makes the local module's API look like the older published
+		// version and reports spurious changes. A wildcard replace pins the
+		// local directory regardless of the selected version.
+		f.AddReplace(modPath, "", modRoot, "")
 	}
 	if modFile != nil {
 		if modFile.Go != nil {
diff --git a/cmd/gorelease/testdata/mod/example.com_transitive_v1.0.0.txt b/cmd/gorelease/testdata/mod/example.com_transitive_v1.0.0.txt
new file mode 100644
index 0000000..7245be9
--- /dev/null
+++ b/cmd/gorelease/testdata/mod/example.com_transitive_v1.0.0.txt
@@ -0,0 +1,19 @@
+-- go.mod --
+module example.com/transitive
+
+go 1.25.0
+
+require example.com/transitivedep v1.0.0
+-- go.sum --
+example.com/transitivedep v1.0.0 h1:Ak5Wu1MyvLk7hssGej2xlrSZQcBQUGZTEkfQQ6Saxcg=
+example.com/transitivedep v1.0.0/go.mod h1:2hwwDmbWo8gHf50CkEkNIWqFIDNdhb1qGdU/AgCehf0=
+-- transitive.go --
+package transitive
+
+import "example.com/transitivedep"
+
+func Foo() string { return transitivedep.Helper() }
+-- p/constants.go --
+package p
+
+const Help = "help"
diff --git a/cmd/gorelease/testdata/mod/example.com_transitive_v1.1.0.txt b/cmd/gorelease/testdata/mod/example.com_transitive_v1.1.0.txt
new file mode 100644
index 0000000..81aa37d
--- /dev/null
+++ b/cmd/gorelease/testdata/mod/example.com_transitive_v1.1.0.txt
@@ -0,0 +1,20 @@
+-- go.mod --
+module example.com/transitive
+
+go 1.25.0
+
+require example.com/transitivedep v1.0.0
+-- go.sum --
+example.com/transitivedep v1.0.0 h1:Ak5Wu1MyvLk7hssGej2xlrSZQcBQUGZTEkfQQ6Saxcg=
+example.com/transitivedep v1.0.0/go.mod h1:2hwwDmbWo8gHf50CkEkNIWqFIDNdhb1qGdU/AgCehf0=
+-- transitive.go --
+package transitive
+
+import "example.com/transitivedep"
+
+func Foo() string { return transitivedep.Helper() }
+func Bar() string { return "bar" }
+-- p/constants.go --
+package p
+
+const Help = "help"
diff --git a/cmd/gorelease/testdata/mod/example.com_transitivedep_v1.0.0.txt b/cmd/gorelease/testdata/mod/example.com_transitivedep_v1.0.0.txt
new file mode 100644
index 0000000..288a615
--- /dev/null
+++ b/cmd/gorelease/testdata/mod/example.com_transitivedep_v1.0.0.txt
@@ -0,0 +1,12 @@
+-- go.mod --
+module example.com/transitivedep
+
+go 1.25.0
+
+require example.com/transitive v1.0.0
+-- mod.go --
+package transitivedep
+
+import "example.com/transitive/p"
+
+func Helper() string { return p.Help }
diff --git a/cmd/gorelease/testdata/transitive/README.md b/cmd/gorelease/testdata/transitive/README.md
new file mode 100644
index 0000000..a27e449
--- /dev/null
+++ b/cmd/gorelease/testdata/transitive/README.md
@@ -0,0 +1,2 @@
+Module example.com/transitive tests a module that has a transitive
+dependency on an earlier version of itself.
diff --git a/cmd/gorelease/testdata/transitive/defined_release.test b/cmd/gorelease/testdata/transitive/defined_release.test
new file mode 100644
index 0000000..61dbc4f
--- /dev/null
+++ b/cmd/gorelease/testdata/transitive/defined_release.test
@@ -0,0 +1,7 @@
+mod=example.com/transitive
+version=v1.1.0
+base=v1.1.0
+release=v1.1.1
+-- want --
+# summary
+v1.1.1 is a valid semantic version for this release.
diff --git a/cmd/gorelease/testdata/transitive/inferred_all.test b/cmd/gorelease/testdata/transitive/inferred_all.test
new file mode 100644
index 0000000..3fce2eb
--- /dev/null
+++ b/cmd/gorelease/testdata/transitive/inferred_all.test
@@ -0,0 +1,6 @@
+mod=example.com/transitive
+version=v1.1.0
+-- want --
+# summary
+Inferred base version: v1.1.0
+Suggested version: v1.1.1
diff --git a/cmd/gorelease/testdata/transitive/inferred_release.test b/cmd/gorelease/testdata/transitive/inferred_release.test
new file mode 100644
index 0000000..00acaf0
--- /dev/null
+++ b/cmd/gorelease/testdata/transitive/inferred_release.test
@@ -0,0 +1,6 @@
+mod=example.com/transitive
+version=v1.1.0
+base=v1.1.0
+-- want --
+# summary
+Suggested version: v1.1.1