231 lines
6.1 KiB
Go
231 lines
6.1 KiB
Go
// Package mod provides a small helper to extract a module's version
|
|
// from [debug.BuildInfo] without depending on [golang.org/x/mod].
|
|
//
|
|
// [golang.org/x/mod]: https://pkg.go.dev/golang.org/x/mod
|
|
package mod
|
|
|
|
import (
|
|
"fmt"
|
|
"runtime/debug"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
var readBuildInfo = sync.OnceValues(debug.ReadBuildInfo)
|
|
|
|
// Version returns a best-effort version string for the given module path,
|
|
// similar to [mod.Version] in the daemon.
|
|
//
|
|
// If the module is present in [debug.BuildInfo] dependencies, its version
|
|
// is returned. Tagged versions are returned as-is (with "+incompatible"
|
|
// stripped). [Pseudo-versions] are normalized to:
|
|
//
|
|
// <base>+<revision>[+meta...][+dirty]
|
|
//
|
|
// Where "<base>" matches the behavior of [module.PseudoVersionBase] (i.e.,
|
|
// downgrade to the previous tag for non-prerelease Pseudo-versions).
|
|
//
|
|
// If the module is replaced (for example via go.work or replace directives),
|
|
// or no usable version information is available, Version returns an empty string.
|
|
//
|
|
// The returned value is intended for display purposes (e.g., in a default
|
|
// User-Agent), not for version comparison.
|
|
//
|
|
// [mod.Version]: https://pkg.go.dev/github.com/moby/moby/v2@v2.0.0-beta.7/daemon/internal/builder-next/worker/mod#Version
|
|
// [module.PseudoVersionBase]: https://pkg.go.dev/golang.org/x/mod@v0.34.0/module#PseudoVersionBase
|
|
// [Pseudo-versions]: https://cs.opensource.google/go/x/mod/+/refs/tags/v0.34.0:module/pseudo.go;l=5-33
|
|
func Version(name string) string {
|
|
bi, ok := readBuildInfo()
|
|
if !ok || bi == nil {
|
|
return ""
|
|
}
|
|
return moduleVersion(name, bi)
|
|
}
|
|
|
|
func moduleVersion(name string, bi *debug.BuildInfo) (modVersion string) {
|
|
if bi == nil {
|
|
return ""
|
|
}
|
|
|
|
// Check if we're the main module.
|
|
if v, ok := getVersion(name, &bi.Main); ok {
|
|
return v
|
|
}
|
|
|
|
// iterate over all dependencies and find name
|
|
for _, dep := range bi.Deps {
|
|
if v, ok := getVersion(name, dep); ok {
|
|
return v
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func getVersion(name string, dep *debug.Module) (string, bool) {
|
|
if dep == nil || dep.Path != name {
|
|
return "", false
|
|
}
|
|
|
|
v := dep.Version
|
|
if dep.Replace != nil && dep.Replace.Version != "" {
|
|
v = dep.Replace.Version
|
|
}
|
|
if v == "" || v == "(devel)" {
|
|
return "", true
|
|
}
|
|
|
|
return normalize(v), true
|
|
}
|
|
|
|
// normalize converts a Go module version into a display-friendly form:
|
|
//
|
|
// - strips "+incompatible" unconditionally
|
|
// - if pseudo: vX.Y.Z[-pre][+rev][+meta...][+dirty]
|
|
// - if tagged: vX.Y.Z[-pre][+meta...][+dirty]
|
|
func normalize(v string) string {
|
|
base, metas, dirty := splitMetadata(v)
|
|
|
|
var out strings.Builder
|
|
if base2, rev, undoPatch, ok := splitPseudo(base); ok {
|
|
if undoPatch {
|
|
// Downgrade the patch version that was raised by pseudo-versions:
|
|
//
|
|
// (2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456
|
|
if major, minor, patch, ok := parseSemVer(base2); ok && patch > 0 {
|
|
patch--
|
|
base2 = fmt.Sprintf("v%d.%d.%d", major, minor, patch)
|
|
}
|
|
}
|
|
// Go pseudo rev is typically 12, but be defensive.
|
|
if len(rev) > 12 {
|
|
rev = rev[:12]
|
|
}
|
|
out.WriteString(base2)
|
|
out.WriteByte('+')
|
|
out.WriteString(rev)
|
|
} else {
|
|
out.WriteString(base)
|
|
}
|
|
|
|
// Preserve other metadata (except for "+incompatible").
|
|
for _, m := range metas {
|
|
out.WriteString(m)
|
|
}
|
|
if dirty {
|
|
// +dirty goes last
|
|
out.WriteString("+dirty")
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func splitMetadata(v string) (base string, metas []string, dirty bool) {
|
|
base, meta, ok := strings.Cut(v, "+")
|
|
if !ok || meta == "" {
|
|
return base, nil, false
|
|
}
|
|
for m := range strings.SplitSeq(meta, "+") {
|
|
// drop incompatible, extract dirty, preserve everything else.
|
|
switch m {
|
|
case "incompatible", "":
|
|
// drop "+incompatible" and empty strings
|
|
case "dirty":
|
|
dirty = true
|
|
default:
|
|
metas = append(metas, "+"+m)
|
|
}
|
|
}
|
|
|
|
return base, metas, dirty
|
|
}
|
|
|
|
// splitPseudo splits a pseudo-version into base + revision, and reports whether
|
|
// it is a (Z+1) pseudo that needs patch undo.
|
|
//
|
|
// Supported (after stripping +incompatible/+dirty metadata):
|
|
//
|
|
// (1) vX.0.0-yyyymmddhhmmss-abcdef123456
|
|
// (2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456
|
|
// (4) vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456
|
|
func splitPseudo(v string) (base, rev string, undoPatch bool, ok bool) {
|
|
// Split off revision at the last '-'.
|
|
last := strings.LastIndexByte(v, '-')
|
|
if last < 0 || last+1 >= len(v) {
|
|
return "", "", false, false
|
|
}
|
|
rev = v[last+1:]
|
|
left := v[:last]
|
|
|
|
// First try the dot-joined timestamp forms:
|
|
// ...-0.<ts> (release pseudo; undoPatch)
|
|
// ....0.<ts> (prerelease pseudo; preserve prerelease)
|
|
if dot := strings.LastIndexByte(left, '.'); dot > 0 && dot+1 < len(left) {
|
|
ts := left[dot+1:]
|
|
if isTimestamp(ts) {
|
|
prefix := left[:dot] // ends with "-0" or ".0" for forms (2)/(4)
|
|
switch {
|
|
case strings.HasSuffix(prefix, "-0"):
|
|
// (2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456
|
|
return prefix[:len(prefix)-2], rev, true, true
|
|
case strings.HasSuffix(prefix, ".0"):
|
|
// (4) vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456
|
|
return prefix[:len(prefix)-2], rev, false, true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to form (1): ...-<ts>-<rev>
|
|
//
|
|
// (1) vX.0.0-yyyymmddhhmmss-abcdef123456
|
|
if dash := strings.LastIndexByte(left, '-'); dash > 0 && dash+1 < len(left) {
|
|
ts := left[dash+1:]
|
|
if isTimestamp(ts) {
|
|
return left[:dash], rev, false, true
|
|
}
|
|
}
|
|
|
|
return "", "", false, false
|
|
}
|
|
|
|
// isTimestamp checks whether s is a timestamp ("yyyymmddhhmmss")
|
|
// component in a module version (vX.0.0-yyyymmddhhmmss-abcdef123456).
|
|
func isTimestamp(s string) bool {
|
|
if len(s) != 14 {
|
|
return false
|
|
}
|
|
for i := range len(s) {
|
|
c := s[i]
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// parseSemVer parses "vX.Y.Z" into numeric components.
|
|
// It intentionally handles only the strict three-segment core form.
|
|
func parseSemVer(v string) (major, minor, patch int, ok bool) {
|
|
if len(v) < 2 || v[0] != 'v' {
|
|
return 0, 0, 0, false
|
|
}
|
|
parts := strings.Split(v[1:], ".")
|
|
if len(parts) != 3 {
|
|
return 0, 0, 0, false
|
|
}
|
|
var err error
|
|
major, err = strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return 0, 0, 0, false
|
|
}
|
|
minor, err = strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return 0, 0, 0, false
|
|
}
|
|
patch, err = strconv.Atoi(parts[2])
|
|
if err != nil {
|
|
return 0, 0, 0, false
|
|
}
|
|
return major, minor, patch, true
|
|
}
|