package modules import ( "bufio" "bytes" "fmt" "io" "net/http" "os" "path" "strconv" "strings" "golang.org/x/mod/module" "golang.org/x/mod/semver" "golang.org/x/sync/errgroup" ) // Module contains the module path and versions type Module struct { Path string Versions []string } // MaxVersion returns the latest version. // If there are no versions, the empty string is returned. // Prefix can be used to filter the versions based on a prefix. // If pre is false, pre-release versions will are excluded. func (m *Module) MaxVersion(prefix string, pre bool) string { var max string for _, v := range m.Versions { if !semver.IsValid(v) || !strings.HasPrefix(v, prefix) { continue } if !pre && semver.Prerelease(v) != "" { continue } max = MaxVersion(v, max) } return max } // IsNewerVersion returns true if newversion is greater than oldversion in terms of semver. // If major is true, then newversion must be a major version ahead of oldversion to be considered newer. func IsNewerVersion(oldversion, newversion string, major bool) bool { if major { return semver.Compare(semver.Major(oldversion), semver.Major(newversion)) < 0 } return semver.Compare(oldversion, newversion) < 0 } // MaxVersion returns the larger of two versions according to semantic version precedence. // Incompatible versions are considered lower than non-incompatible ones. // Invalid versions are considered lower than valid ones. // If both versions are invalid, the empty string is returned. func MaxVersion(v, w string) string { // sort by validity vValid := semver.IsValid(v) wValid := semver.IsValid(w) if !vValid && !wValid { return "" } if vValid != wValid { if vValid { return v } return w } // sort by compatibility vIncompatible := strings.HasSuffix(semver.Build(v), "+incompatible") wIncompatible := strings.HasSuffix(semver.Build(w), "+incompatible") if vIncompatible != wIncompatible { if wIncompatible { return v } return w } // sort by semver if semver.Compare(v, w) == 1 { return v } return w } // NextMajor returns the next major version after the provided version func NextMajor(version string) (string, error) { major, err := strconv.Atoi(strings.TrimPrefix(semver.Major(version), "v")) if err != nil { return "", err } major++ return fmt.Sprintf("v%d", major), nil } // WithMajorPath returns the module path for the provided version func (m *Module) WithMajorPath(version string) string { prefix := ModPrefix(m.Path) return JoinPath(prefix, version, "") } // NextMajorPath returns the module path of the next major version func (m *Module) NextMajorPath() (string, bool) { latest := m.MaxVersion("", true) if latest == "" { return "", false } if semver.Major(latest) == "v0" { return "", false } next, err := NextMajor(latest) if err != nil { return "", false } return m.WithMajorPath(next), true } // Query the module proxy for all versions of a module. // If the module does not exist, the second return parameter will be false // cached sets the Disable-Module-Fetch: true header func Query(modpath string, cached bool) (*Module, bool, error) { escaped, err := module.EscapePath(modpath) if err != nil { return nil, false, err } url := fmt.Sprintf("https://proxy.golang.org/%s/@v/list", escaped) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, false, err } req.Header.Set("User-Agent", "GoMajor/1.0") if cached { req.Header.Set("Disable-Module-Fetch", "true") } res, err := http.DefaultClient.Do(req) if err != nil { return nil, false, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(res.Body) if res.StatusCode == http.StatusNotFound && bytes.HasPrefix(body, []byte("not found:")) { return nil, false, nil } msg := string(body) if msg == "" { msg = res.Status } return nil, false, fmt.Error("proxy: %s", msg) } var mod Module mod.Path = modpath sc := bufio.NewScanner(res.Body) for sc.Scan() { mod.Versions = append(mod.Versions, sc.Text()) } if err := sc.Err(); err != nil { return nil, false, err } return &mod, true, nil } // Latest finds the latest major version of a module // cached sets the Disable-Module-Fetch: true header func Latest(modpath string, cached bool) (*Module, error) { latest, ok, err := Query(modpath, cached) if err != nil { return nil, err } if !ok { return nil, fmt.Error("module not found: %s", modpath) } for i := 0; i < 100; i++ { nextpath, ok := latest.NextMajorPath() if !ok { return latest, nil } next, ok, err := Query(nextpath, cached) if err != nil { return nil, err } if !ok { // handle the case where a project switched to modules // without incrementing the major version version := latest.MaxVersion("", true) if semver.Build(version) == "+incompatible" { nextpath = latest.WithMajorPath(semver.Major(version)) if nextpath != latest.Path { next, ok, err = Query(nextpath, cached) if err != nil { return nil, err } } } } if !ok { return latest, nil } latest = next } return nil, fmt.Error("request limit exceeded") } // QueryPackage tries to find the module path for the provided package path // it does so by repeatedly chopping off the last path element and trying to // use it as a path. func QueryPackage(pkgpath string, cached bool) (*Module, error) { prefix := pkgpath for prefix != "" { if module.CheckPath(prefix) == nil { mod, ok, err := Query(prefix, cached) if err != nil { return nil, err } if ok { modprefix := ModPrefix(mod.Path) if modpath, pkgdir, ok := SplitPath(modprefix, pkgpath); ok && modpath != mod.Path { if major, ok := ModMajor(modpath); ok { if v := mod.MaxVersion(major, false); v != "" { spec := JoinPath(modprefix, "", pkgdir) + "@" + v return nil, fmt.Error("%s doesn't support import versioning; use %s", major, spec) } return nil, fmt.Error("failed to find module for package: %s", pkgpath) } } return mod, nil } } remaining, last := path.Split(prefix) if last == "" { break } prefix = strings.TrimSuffix(remaining, "/") } return nil, fmt.Error("failed to find module for package: %s", pkgpath) } // Update reports a newer version of a module. // The Err field will be set if an error occured. type Update struct { Err error Module module.Version Version string } // UpdateOptions specifies a set of modules to check for updates. // The OnUpdate callback will be invoked with any updates found. type UpdateOptions struct { OnUpdate func(Update) Modules []module.Version Pre bool Cached bool Major bool // Major true compare only major UpMajor bool // UpMajor module up with major } // Updates finds updates for a set of specified modules. func Updates(opt UpdateOptions) { ch := make(chan Update) go func() { defer close(ch) private := os.Getenv("GOPRIVATE") var group errgroup.Group if opt.Cached { group.SetLimit(3) } else { group.SetLimit(1) } for _, m := range opt.Modules { m := m if module.MatchPrefixPatterns(private, m.Path) { continue } group.Go(func() error { mod, err := Latest(m.Path, opt.Cached) if err != nil { ch <- Update{Module: m, Err: err} return nil } major := semver.Major(m.Version) var v string switch opt.UpMajor { case true: v = mod.MaxVersion("", opt.Pre) case false: v = mod.MaxVersion(major, opt.Pre) } if IsNewerVersion(m.Version, v, opt.Major) { ch <- Update{Module: m, Version: v} } return nil }) } _ = group.Wait() }() for u := range ch { if opt.OnUpdate != nil { opt.OnUpdate(u) } } }