Skip to content

Commit 1386614

Browse files
committed
feat(version-checker): enhance CheckLatestVersion for Go module support
Updated the VersionChecker interface and its implementation to support Go module paths, allowing version checks from the Go proxy API in addition to GitHub releases. This change improves version resolution for Go tools and enhances compatibility with Go modules.
1 parent 9fe3aa4 commit 1386614

1 file changed

Lines changed: 114 additions & 39 deletions

File tree

magefiles/version_checker.go

Lines changed: 114 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ var (
2626
ErrGitHubAPI = errors.New("GitHub API error")
2727
)
2828

29-
// VersionChecker defines the interface for checking latest tool versions from GitHub.
29+
// VersionChecker defines the interface for checking latest tool versions from GitHub or Go proxy.
3030
type VersionChecker interface {
31-
CheckLatestVersion(ctx context.Context, repoURL string) (string, error)
31+
CheckLatestVersion(ctx context.Context, repoURL, goModulePath string) (string, error)
3232
}
3333

3434
// FileUpdater defines the interface for file operations.
@@ -47,10 +47,11 @@ type VersionLogger interface {
4747

4848
// ToolInfo represents a tool with its version configuration.
4949
type ToolInfo struct {
50-
EnvVars []string // Multiple env vars may use the same tool
51-
RepoURL string // GitHub repository URL
52-
RepoOwner string // GitHub owner
53-
RepoName string // GitHub repository name
50+
EnvVars []string // Multiple env vars may use the same tool
51+
RepoURL string // GitHub repository URL
52+
RepoOwner string // GitHub owner
53+
RepoName string // GitHub repository name
54+
GoModulePath string // Go module path for proxy.golang.org lookup (optional, takes precedence over GitHub)
5455
}
5556

5657
// CheckResult represents the result of a version check.
@@ -75,6 +76,12 @@ type GoRelease struct {
7576
Stable bool `json:"stable"`
7677
}
7778

79+
// GoProxyInfo represents a Go proxy API response for module version lookup.
80+
type GoProxyInfo struct {
81+
Version string `json:"Version"`
82+
Time string `json:"Time"`
83+
}
84+
7885
// realVersionChecker implements VersionChecker using GitHub API.
7986
type realVersionChecker struct {
8087
httpClient *http.Client
@@ -92,14 +99,19 @@ func NewVersionChecker(useGHCLI bool) VersionChecker {
9299
// GoDevAPIURL is the URL for the official Go download API.
93100
const GoDevAPIURL = "https://go.dev/dl/?mode=json"
94101

95-
// CheckLatestVersion checks the latest version from GitHub releases.
96-
func (r *realVersionChecker) CheckLatestVersion(ctx context.Context, repoURL string) (string, error) {
97-
// Special case for Go itself - use go.dev API
102+
// CheckLatestVersion checks the latest version from GitHub releases or Go proxy.
103+
func (r *realVersionChecker) CheckLatestVersion(ctx context.Context, repoURL, goModulePath string) (string, error) {
104+
// Priority 1: Go proxy API for tools with GoModulePath
105+
if goModulePath != "" {
106+
return r.checkGoProxyVersion(ctx, goModulePath)
107+
}
108+
109+
// Priority 2: go.dev API for Go itself
98110
if repoURL == "https://go.dev" || repoURL == "https://github.com/golang/go" {
99111
return r.checkGoVersion(ctx)
100112
}
101113

102-
// Try gh CLI first if available and preferred
114+
// Priority 3: gh CLI if available and preferred
103115
if r.useGHCLI {
104116
version, err := r.checkViaGHCLI(ctx, repoURL)
105117
if err == nil {
@@ -108,7 +120,7 @@ func (r *realVersionChecker) CheckLatestVersion(ctx context.Context, repoURL str
108120
// Fall through to API if gh CLI fails
109121
}
110122

111-
// Use GitHub API
123+
// Priority 4: GitHub API
112124
return r.checkViaAPI(ctx, repoURL)
113125
}
114126

@@ -176,6 +188,12 @@ func (r *realVersionChecker) checkViaAPI(ctx context.Context, repoURL string) (s
176188
// ErrGoDevAPI is returned when the go.dev API fails.
177189
var ErrGoDevAPI = errors.New("go.dev API error")
178190

191+
// ErrGoProxyAPI is returned when the Go proxy API fails.
192+
var ErrGoProxyAPI = errors.New("go proxy API error")
193+
194+
// GoProxyAPIURL is the base URL for the Go proxy API.
195+
const GoProxyAPIURL = "https://proxy.golang.org"
196+
179197
// checkGoVersion uses the official go.dev API to check the latest stable Go version.
180198
func (r *realVersionChecker) checkGoVersion(ctx context.Context) (string, error) {
181199
req, err := http.NewRequestWithContext(ctx, "GET", GoDevAPIURL, nil)
@@ -215,6 +233,45 @@ func (r *realVersionChecker) checkGoVersion(ctx context.Context) (string, error)
215233
return "", fmt.Errorf("%w: no stable releases found", ErrGoDevAPI)
216234
}
217235

236+
// checkGoProxyVersion uses the Go proxy API to check the latest version of a Go module.
237+
func (r *realVersionChecker) checkGoProxyVersion(ctx context.Context, modulePath string) (string, error) {
238+
// Build the proxy URL: https://proxy.golang.org/{module}/@latest
239+
apiURL := fmt.Sprintf("%s/%s/@latest", GoProxyAPIURL, modulePath)
240+
241+
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
242+
if err != nil {
243+
return "", err
244+
}
245+
246+
req.Header.Set("Accept", "application/json")
247+
248+
resp, err := r.httpClient.Do(req)
249+
if err != nil {
250+
return "", err
251+
}
252+
defer func() {
253+
if closeErr := resp.Body.Close(); closeErr != nil {
254+
_ = closeErr
255+
}
256+
}()
257+
258+
if resp.StatusCode != http.StatusOK {
259+
body, _ := io.ReadAll(resp.Body)
260+
return "", fmt.Errorf("%w: status %d: %s", ErrGoProxyAPI, resp.StatusCode, string(body))
261+
}
262+
263+
var info GoProxyInfo
264+
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
265+
return "", fmt.Errorf("failed to parse Go proxy JSON: %w", err)
266+
}
267+
268+
if info.Version == "" {
269+
return "", fmt.Errorf("%w: empty version in response", ErrGoProxyAPI)
270+
}
271+
272+
return info.Version, nil
273+
}
274+
218275
// realFileUpdater implements FileUpdater using os package.
219276
type realFileUpdater struct{}
220277

@@ -295,32 +352,40 @@ func GetToolDefinitions() map[string]*ToolInfo {
295352

296353
// Define unique tools with their GitHub repos (from .env.base comments)
297354
definitions := []struct {
298-
key string
299-
envVars []string
300-
repoOwner string
301-
repoName string
355+
key string
356+
envVars []string
357+
repoOwner string
358+
repoName string
359+
goModulePath string // Go module path for proxy.golang.org lookup (optional)
302360
}{
303-
{"go-coverage", []string{"GO_COVERAGE_VERSION"}, "mrz1836", "go-coverage"},
304-
{"mage-x", []string{"MAGE_X_VERSION"}, "mrz1836", "mage-x"},
305-
{"gitleaks", []string{"MAGE_X_GITLEAKS_VERSION", "GITLEAKS_VERSION", "GO_PRE_COMMIT_GITLEAKS_VERSION"}, "gitleaks", "gitleaks"},
306-
{"gofumpt", []string{"MAGE_X_GOFUMPT_VERSION", "GO_PRE_COMMIT_FUMPT_VERSION"}, "mvdan", "gofumpt"},
307-
{"golangci-lint", []string{"MAGE_X_GOLANGCI_LINT_VERSION", "GO_PRE_COMMIT_GOLANGCI_LINT_VERSION"}, "golangci", "golangci-lint"},
308-
{"goreleaser", []string{"MAGE_X_GORELEASER_VERSION"}, "goreleaser", "goreleaser"},
309-
{"govulncheck", []string{"MAGE_X_GOVULNCHECK_VERSION", "GOVULNCHECK_VERSION"}, "golang", "vuln"},
310-
{"mockgen", []string{"MAGE_X_MOCKGEN_VERSION"}, "uber-go", "mock"},
311-
{"nancy", []string{"MAGE_X_NANCY_VERSION", "NANCY_VERSION"}, "sonatype-nexus-community", "nancy"},
312-
{"staticcheck", []string{"MAGE_X_STATICCHECK_VERSION"}, "dominikh", "go-tools"},
313-
{"swag", []string{"MAGE_X_SWAG_VERSION"}, "swaggo", "swag"},
314-
{"yamlfmt", []string{"MAGE_X_YAMLFMT_VERSION"}, "google", "yamlfmt"},
315-
{"go-pre-commit", []string{"GO_PRE_COMMIT_VERSION"}, "mrz1836", "go-pre-commit"},
361+
{"go-coverage", []string{"GO_COVERAGE_VERSION"}, "mrz1836", "go-coverage", ""},
362+
{"mage-x", []string{"MAGE_X_VERSION"}, "mrz1836", "mage-x", ""},
363+
{"gitleaks", []string{"MAGE_X_GITLEAKS_VERSION", "GITLEAKS_VERSION", "GO_PRE_COMMIT_GITLEAKS_VERSION"}, "gitleaks", "gitleaks", ""},
364+
{"gofumpt", []string{"MAGE_X_GOFUMPT_VERSION", "GO_PRE_COMMIT_FUMPT_VERSION"}, "mvdan", "gofumpt", ""},
365+
{"golangci-lint", []string{"MAGE_X_GOLANGCI_LINT_VERSION", "GO_PRE_COMMIT_GOLANGCI_LINT_VERSION"}, "golangci", "golangci-lint", ""},
366+
{"goreleaser", []string{"MAGE_X_GORELEASER_VERSION"}, "goreleaser", "goreleaser", ""},
367+
{"govulncheck", []string{"MAGE_X_GOVULNCHECK_VERSION", "GOVULNCHECK_VERSION"}, "golang", "vuln", ""},
368+
{"mockgen", []string{"MAGE_X_MOCKGEN_VERSION"}, "uber-go", "mock", ""},
369+
{"nancy", []string{"MAGE_X_NANCY_VERSION", "NANCY_VERSION"}, "sonatype-nexus-community", "nancy", ""},
370+
{"staticcheck", []string{"MAGE_X_STATICCHECK_VERSION"}, "dominikh", "go-tools", ""},
371+
{"swag", []string{"MAGE_X_SWAG_VERSION"}, "swaggo", "swag", ""},
372+
{"yamlfmt", []string{"MAGE_X_YAMLFMT_VERSION"}, "google", "yamlfmt", ""},
373+
{"go-pre-commit", []string{"GO_PRE_COMMIT_VERSION"}, "mrz1836", "go-pre-commit", ""},
374+
// Go proxy-based tools (use pseudo-versions like v0.0.0-YYYYMMDDHHMMSS-commitSHA)
375+
{"benchstat", []string{"MAGE_X_BENCHSTAT_VERSION"}, "", "", "golang.org/x/perf"},
316376
}
317377

318378
for _, def := range definitions {
379+
var repoURL string
380+
if def.repoOwner != "" && def.repoName != "" {
381+
repoURL = fmt.Sprintf("https://github.com/%s/%s", def.repoOwner, def.repoName)
382+
}
319383
tools[def.key] = &ToolInfo{
320-
EnvVars: def.envVars,
321-
RepoURL: fmt.Sprintf("https://github.com/%s/%s", def.repoOwner, def.repoName),
322-
RepoOwner: def.repoOwner,
323-
RepoName: def.repoName,
384+
EnvVars: def.envVars,
385+
RepoURL: repoURL,
386+
RepoOwner: def.repoOwner,
387+
RepoName: def.repoName,
388+
GoModulePath: def.goModulePath,
324389
}
325390
}
326391

@@ -430,7 +495,7 @@ func (s *VersionUpdateService) checkVersions(ctx context.Context, tools map[stri
430495
}
431496

432497
currentVersion := currentVersions[toolKey]
433-
latestVersion, err := s.checker.CheckLatestVersion(ctx, tool.RepoURL)
498+
latestVersion, err := s.checker.CheckLatestVersion(ctx, tool.RepoURL, tool.GoModulePath)
434499

435500
result := CheckResult{
436501
Tool: toolKey,
@@ -442,6 +507,9 @@ func (s *VersionUpdateService) checkVersions(ctx context.Context, tools map[stri
442507
if err != nil {
443508
result.Status = "error"
444509
result.Error = err
510+
} else if currentVersion == "latest" {
511+
// Special case: "latest" resolves to actual version - recommend pinning for reproducibility
512+
result.Status = "pin-recommended"
445513
} else if s.normalizeVersion(currentVersion) == s.normalizeVersion(latestVersion) {
446514
result.Status = "up-to-date"
447515
} else {
@@ -466,13 +534,14 @@ func (s *VersionUpdateService) normalizeVersion(version string) string {
466534
// displayResults displays the check results in a formatted table.
467535
func (s *VersionUpdateService) displayResults(results []CheckResult) {
468536
// Print header
469-
header := fmt.Sprintf("%-25s %-15s %-15s %s\n", "Tool", "Current", "Latest", "Status")
537+
header := fmt.Sprintf("%-25s %-15s %-45s %s\n", "Tool", "Current", "Latest", "Status")
470538
_, _ = os.Stdout.WriteString(header)
471-
_, _ = os.Stdout.WriteString(strings.Repeat("─", 80) + "\n")
539+
_, _ = os.Stdout.WriteString(strings.Repeat("─", 110) + "\n")
472540

473541
// Track statistics
474542
upToDate := 0
475543
updates := 0
544+
pinRecommended := 0
476545
errors := 0
477546

478547
// Print results
@@ -485,12 +554,15 @@ func (s *VersionUpdateService) displayResults(results []CheckResult) {
485554
case "update-available":
486555
statusIcon = "⬆ Update available"
487556
updates++
557+
case "pin-recommended":
558+
statusIcon = "📌 Pin recommended"
559+
pinRecommended++
488560
case "error":
489561
statusIcon = fmt.Sprintf("✗ Error: %v", result.Error)
490562
errors++
491563
}
492564

493-
line := fmt.Sprintf("%-25s %-15s %-15s %s\n",
565+
line := fmt.Sprintf("%-25s %-15s %-45s %s\n",
494566
result.Tool,
495567
result.CurrentVersion,
496568
result.LatestVersion,
@@ -504,18 +576,21 @@ func (s *VersionUpdateService) displayResults(results []CheckResult) {
504576
_, _ = os.Stdout.WriteString("Summary:\n")
505577
_, _ = fmt.Fprintf(os.Stdout, "✓ %d tools up to date\n", upToDate)
506578
_, _ = fmt.Fprintf(os.Stdout, "⬆ %d tools with updates available\n", updates)
579+
if pinRecommended > 0 {
580+
_, _ = fmt.Fprintf(os.Stdout, "📌 %d tools recommend version pinning\n", pinRecommended)
581+
}
507582
_, _ = fmt.Fprintf(os.Stdout, "✗ %d tools failed to check\n", errors)
508583
_, _ = os.Stdout.WriteString("\n")
509584

510-
if s.dryRun && updates > 0 {
585+
if s.dryRun && (updates > 0 || pinRecommended > 0) {
511586
s.logger.Info("[DRY RUN] No changes made. Set UPDATE_VERSIONS=true to apply updates.")
512587
}
513588
}
514589

515-
// hasUpdates checks if any updates are available.
590+
// hasUpdates checks if any updates are available or pinning is recommended.
516591
func (s *VersionUpdateService) hasUpdates(results []CheckResult) bool {
517592
for _, result := range results {
518-
if result.Status == "update-available" {
593+
if result.Status == "update-available" || result.Status == "pin-recommended" {
519594
return true
520595
}
521596
}

0 commit comments

Comments
 (0)