diff --git a/version/version.go b/version/version.go index 830f5f5..2674c43 100644 --- a/version/version.go +++ b/version/version.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "strings" "time" "github.com/goccy/go-json" @@ -70,16 +71,29 @@ func getLatestVersion() (string, error) { return getLatestVersionWithTimeout(updateCheckTimeout) } +func latestVersionCommand(ctx context.Context) *exec.Cmd { + // Keep update checks independent of the caller's module and workspace + cmd := exec.CommandContext(ctx, "go", "list", "-m", "-mod=mod", "-json", modulePath+"@latest") + cmd.Dir = os.TempDir() + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=") + return cmd +} + func getLatestVersionWithTimeout(timeout time.Duration) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", modulePath+"@latest") + cmd := latestVersionCommand(ctx) output, err := cmd.Output() if ctxErr := ctx.Err(); ctxErr != nil { return "", fmt.Errorf("latest version check failed: %w", ctxErr) } if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if detail := strings.TrimSpace(string(exitErr.Stderr)); detail != "" { + return "", fmt.Errorf("failed to query latest version: %w: %s", err, detail) + } + } return "", fmt.Errorf("failed to query latest version: %w", err) } diff --git a/version/version_test.go b/version/version_test.go index 3a8d69a..1185d87 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "runtime" + "slices" + "strings" "testing" "time" ) @@ -31,3 +33,57 @@ func TestGetLatestVersionHasOverallTimeout(t *testing.T) { t.Fatalf("version lookup exceeded timeout allowance: %s", elapsed) } } + +func TestLatestVersionCommandIsIsolated(t *testing.T) { + t.Setenv("GOFLAGS", "-mod=vendor") + t.Setenv("GOWORK", filepath.Join(t.TempDir(), "go.work")) + t.Setenv("GOPROXY", "https://proxy.example.com") + + cmd := latestVersionCommand(context.Background()) + if cmd.Dir != os.TempDir() { + t.Fatalf("command directory = %q, want %q", cmd.Dir, os.TempDir()) + } + if args := strings.Join(cmd.Args, " "); !strings.Contains(args, " -mod=mod ") { + t.Fatalf("command arguments %q do not force module mode", args) + } + if got := lastEnvValue(cmd.Env, "GOWORK"); got != "off" { + t.Fatalf("GOWORK = %q, want off", got) + } + if got := lastEnvValue(cmd.Env, "GOFLAGS"); got != "" { + t.Fatalf("GOFLAGS = %q, want empty", got) + } + if got := lastEnvValue(cmd.Env, "GOPROXY"); got != "https://proxy.example.com" { + t.Fatalf("GOPROXY = %q, want inherited value", got) + } +} + +func lastEnvValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range slices.Backward(env) { + if after, ok := strings.CutPrefix(entry, prefix); ok { + return after + } + } + return "" +} + +func TestGetLatestVersionIncludesGoDiagnostic(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test uses a POSIX executable script") + } + + dir := t.TempDir() + fakeGo := filepath.Join(dir, "go") + if err := os.WriteFile(fakeGo, []byte("#!/bin/sh\necho 'go: module lookup disabled by GOPROXY=off' >&2\nexit 1\n"), 0o755); err != nil { + t.Fatalf("create fake go command: %v", err) + } + t.Setenv("PATH", dir) + + _, err := getLatestVersionWithTimeout(time.Second) + if err == nil { + t.Fatal("expected latest version lookup to fail") + } + if !strings.Contains(err.Error(), "module lookup disabled by GOPROXY=off") { + t.Fatalf("error %q does not include go command diagnostic", err) + } +}