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 .
3030type 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.
4949type 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.
7986type 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.
93100const 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.
177189var 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.
180198func (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.
219276type 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.
467535func (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 .
516591func (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