tools/bump: resolve go.mod targets before calling go get

`go get -u` takes the highest semver the proxy offers: a fork's stray tag
sorting above its real branch, or a module that has moved and kept
tagging under the old path. Resolving first refuses both, and names the
compare link for every version that does move.
This commit is contained in:
Kristoffer Dalby
2026-09-23 07:28:01 +00:00
parent d59c1ed209
commit 823db85509
6 changed files with 467 additions and 19 deletions
+154 -13
View File
@@ -7,17 +7,21 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)
// proxyBase is the module mirror. It is deliberately the same source the go
// command already trusts: immutable per version, so the go.mod read back here
// is the one that will actually be resolved, not whatever a branch tip happens
// to hold at fetch time.
const proxyBase = "https://proxy.golang.org"
// It is a var only so the tests can point it at a fake proxy; nothing changes
// it at runtime.
var proxyBase = "https://proxy.golang.org"
const proxyTimeout = 30 * time.Second
@@ -26,6 +30,14 @@ const proxyTimeout = 30 * time.Second
// exact breakage the lockstep rule exists to prevent.
var errNoLockstepSource = errors.New("lockstep source unavailable")
// errStrayTag means @latest sorts above the pinned version but was committed
// no later than it.
var errStrayTag = errors.New("newest tag is older than the pinned version")
// errModuleMoved means the newest version under this path declares a different
// module path.
var errModuleMoved = errors.New("module has moved to a new path")
// fetch performs a GET and returns the body, or an error naming the status.
func fetch(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -55,30 +67,106 @@ func fetch(ctx context.Context, url string) ([]byte, error) {
var errHTTPStatus = errors.New("unexpected status")
// latestVersion resolves a module's newest version through the proxy.
func latestVersion(ctx context.Context, path string) (string, error) {
// versionInfo is the proxy's @latest and @v/<version>.info response.
type versionInfo struct {
Version string
Time time.Time
}
// fetchInfo reads one of the proxy's info endpoints. ref is "@latest" or
// "@v/<escaped version>.info".
func fetchInfo(ctx context.Context, path, ref string) (versionInfo, error) {
esc, err := module.EscapePath(path)
if err != nil {
return "", fmt.Errorf("escaping %s: %w", path, err)
return versionInfo{}, fmt.Errorf("escaping %s: %w", path, err)
}
body, err := fetch(ctx, proxyBase+"/"+esc+"/@latest")
body, err := fetch(ctx, proxyBase+"/"+esc+"/"+ref)
if err != nil {
return versionInfo{}, err
}
var info versionInfo
if err := json.Unmarshal(body, &info); err != nil { //nolint:noinlineerr
return versionInfo{}, fmt.Errorf("decoding %s for %s: %w", ref, path, err)
}
return info, nil
}
// versionTime is when version of path was committed. Pseudo-versions carry it
// in their own name, so only tagged versions cost a round trip.
func versionTime(ctx context.Context, path, version string) (time.Time, error) {
if module.IsPseudoVersion(version) {
return module.PseudoVersionTime(version)
}
esc, err := module.EscapeVersion(version)
if err != nil {
return time.Time{}, fmt.Errorf("escaping version %s: %w", version, err)
}
info, err := fetchInfo(ctx, path, "@v/"+esc+".info")
return info.Time, err
}
// latestVersion resolves a module's newest usable version through the proxy.
// current is the version already in use, or "" when the module is not required
// yet. When nothing newer exists it returns current unchanged.
//
// Two things the proxy will hand back that the go command does not protect
// against, both of which have to be refused rather than committed:
//
// - A stray tag. Forks carry tags that sort above the branch the real work
// happens on, and @latest reports them. Semver says newer; the commit date
// says otherwise, and the commit date is the one telling the truth.
// - A module that has moved. Releases keep being tagged under the new path,
// the proxy still serves them under the old one, and go get then refuses
// the result with an error that does not say why.
func latestVersion(ctx context.Context, path, current string) (string, error) {
info, err := fetchInfo(ctx, path, "@latest")
if err != nil {
return "", err
}
var info struct {
Version string `json:"Version"`
}
if err := json.Unmarshal(body, &info); err != nil { //nolint:noinlineerr
return "", fmt.Errorf("decoding @latest for %s: %w", path, err)
}
if info.Version == "" {
return "", fmt.Errorf("%w: empty @latest for %s", errNoLockstepSource, path)
}
if !semver.IsValid(info.Version) {
return "", fmt.Errorf("%w: proxy returned %q for %s", errStrayTag, info.Version, path)
}
if current != "" && semver.Compare(info.Version, current) <= 0 {
return current, nil
}
f, err := modFileOf(ctx, path, info.Version)
if err != nil {
return "", err
}
if f.Module != nil && f.Module.Mod.Path != path {
return "", fmt.Errorf("%w: %s@%s declares %s; change the import path by hand",
errModuleMoved, path, info.Version, f.Module.Mod.Path)
}
if current == "" {
return info.Version, nil
}
curTime, err := versionTime(ctx, path, current)
if err != nil {
return "", err
}
if !info.Time.After(curTime) {
return "", fmt.Errorf("%w: %s@%s is dated %s, the pinned %s is dated %s",
errStrayTag, path, info.Version, info.Time.Format(time.DateOnly),
current, curTime.Format(time.DateOnly))
}
return info.Version, nil
}
@@ -134,3 +222,56 @@ func partnerVersion(ctx context.Context, owner, ownerVersion, dep string) (strin
return v, nil
}
// describeChange renders one module's move, as a compare link when the path
// names a repository a link can be built for. Reviewing fifty version numbers
// means opening fifty tabs otherwise.
func describeChange(path, from, to string) string {
link := compareLink(path, from, to)
if link == "" {
return fmt.Sprintf("%s %s -> %s", path, from, to)
}
return fmt.Sprintf("%s %s -> [%s](%s)", path, from, to, link)
}
// compareLink is the GitHub compare URL between two versions of a module, or
// "" when one cannot be built. The repository is the first three path
// segments; anything past that is a subdirectory with its own tag prefix.
func compareLink(path, from, to string) string {
if !strings.HasPrefix(path, "github.com/") {
return ""
}
trimmed, _, ok := module.SplitPathVersion(path)
if !ok {
return ""
}
parts := strings.Split(trimmed, "/")
if len(parts) < 3 {
return ""
}
sub := strings.Join(parts[3:], "/")
return fmt.Sprintf("https://%s/compare/%s...%s",
strings.Join(parts[:3], "/"), gitRef(sub, from), gitRef(sub, to))
}
// gitRef is the tag or commit one module version points at. A pseudo-version
// names a commit; a tagged version inside a subdirectory carries that
// subdirectory as a prefix.
func gitRef(sub, version string) string {
if module.IsPseudoVersion(version) {
if rev, err := module.PseudoVersionRev(version); err == nil { //nolint:noinlineerr
return rev
}
}
if sub != "" {
return sub + "/" + version
}
return version
}