diff --git a/.golangci.yml b/.golangci.yml index f27e9f6bc..6e60fcd33 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -12,6 +12,7 @@ linters: - lll # don't want hard limits for line length - maintidx # covered by gocyclo - mnd # some unnamed constants are okay + - nilnil # (T, error) returning (nil, nil) is an acceptable "no result" signal - nlreturn # generous whitespace violates house style - noinlineerr # excess scope violates house style - rowserrcheck # no SQL code in plugins diff --git a/internal/cmd/fetcher/main.go b/internal/cmd/fetcher/main.go index 9d383524d..0f2812d1a 100644 --- a/internal/cmd/fetcher/main.go +++ b/internal/cmd/fetcher/main.go @@ -2,12 +2,14 @@ package main import ( "bufio" + "cmp" "context" "errors" "fmt" "io" "io/fs" "log/slog" + "net/http" "os" "os/exec" "path/filepath" @@ -20,6 +22,7 @@ import ( "github.com/bufbuild/buf/private/bufpkg/bufremoteplugin/bufremotepluginconfig" "github.com/bufbuild/buf/private/pkg/encoding" "github.com/spf13/pflag" + "golang.org/x/mod/modfile" "golang.org/x/mod/semver" "github.com/bufbuild/plugins/internal/docker" @@ -35,6 +38,9 @@ const ( communityOrg = "community" dockerfileImageName = "docker/dockerfile" dockerfileSyntaxPrefix = "# syntax=docker/dockerfile:" + // defaultGoModVersion is the Go version assumed for modules with no go directive. + defaultGoModVersion = "1.16" + goModProxyURL = "https://proxy.golang.org" ) var errNoVersions = errors.New("no versions found") @@ -108,7 +114,7 @@ func newRootCommand(name string) *appcmd.Command { if err != nil { return fmt.Errorf("failed to fetch versions: %w", err) } - if err := postProcessCreatedPlugins(ctx, container.Logger(), created); err != nil { + if err := postProcessCreatedPlugins(ctx, container.Logger(), http.DefaultClient, created); err != nil { return fmt.Errorf("failed to run post-processing on plugins: %w", err) } if err := writeGitHubOutput("pr_title", generatePRTitle(created)); err != nil { @@ -124,39 +130,52 @@ func newRootCommand(name string) *appcmd.Command { } } +type goMinVersionBump struct { + oldVersion string + newVersion string + module string + modVersion string +} + type createdPlugin struct { - org string - name string - pluginDir string - previousVersion string - newVersion string + org string + name string + pluginDir string + previousVersion string + newVersion string + goMinVersionBump *goMinVersionBump } func (p createdPlugin) String() string { return fmt.Sprintf("%s/%s:%s", p.org, p.name, p.newVersion) } -func postProcessCreatedPlugins(ctx context.Context, logger *slog.Logger, plugins []createdPlugin) error { +func postProcessCreatedPlugins(ctx context.Context, logger *slog.Logger, client *http.Client, plugins []createdPlugin) error { if len(plugins) == 0 { return nil } - for _, plugin := range plugins { - newPluginRef := plugin.String() - if err := regenerateMavenDeps(plugin); err != nil { + for i := range plugins { + newPluginRef := plugins[i].String() + if err := regenerateMavenDeps(plugins[i]); err != nil { return fmt.Errorf("failed to regenerate maven deps for %s: %w", newPluginRef, err) } - if err := regenerateNugetDeps(plugin); err != nil { + if err := regenerateNugetDeps(plugins[i]); err != nil { return fmt.Errorf("failed to regenerate nuget deps for %s: %w", newPluginRef, err) } - if err := runGoModTidy(ctx, logger, plugin); err != nil { + if err := runGoModTidy(ctx, logger, plugins[i]); err != nil { return fmt.Errorf("failed to run go mod tidy for %s: %w", newPluginRef, err) } - if err := recreateNPMPackageLock(ctx, logger, plugin); err != nil { + if err := recreateNPMPackageLock(ctx, logger, plugins[i]); err != nil { return fmt.Errorf("failed to recreate package-lock.json for %s: %w", newPluginRef, err) } - if err := recreateSwiftPackageResolved(ctx, logger, plugin); err != nil { + if err := recreateSwiftPackageResolved(ctx, logger, plugins[i]); err != nil { return fmt.Errorf("failed to resolve Swift package for %s: %w", newPluginRef, err) } + bump, err := updateGoRegistryMinVersion(ctx, logger, client, plugins[i]) + if err != nil { + return fmt.Errorf("failed to update go registry min version for %s: %w", newPluginRef, err) + } + plugins[i].goMinVersionBump = bump } if err := runPluginTests(ctx, logger, plugins); err != nil { return fmt.Errorf("failed to run plugin tests: %w", err) @@ -324,6 +343,112 @@ func runPluginTests(ctx context.Context, logger *slog.Logger, plugins []createdP return cmd.Run() } +func goModFileURL(module, version string) string { + return fmt.Sprintf("%s/%s/@v/%s.mod", goModProxyURL, module, version) +} + +func fetchGoModVersion(ctx context.Context, client *http.Client, module, version string) (string, error) { + reqURL := goModFileURL(module, version) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status %d fetching go.mod for %s@%s", resp.StatusCode, module, version) + } + content, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + modFile, err := modfile.ParseLax("go.mod", content, nil) + if err != nil { + return "", err + } + if modFile.Go == nil || modFile.Go.Version == "" { + return defaultGoModVersion, nil + } + // Normalize to major.minor (e.g. "1.25.0" → "1.25"). + normalized := strings.TrimPrefix(semver.MajorMinor("v"+modFile.Go.Version), "v") + if normalized == "" { + return defaultGoModVersion, nil + } + return normalized, nil +} + +func updateGoRegistryMinVersion(ctx context.Context, logger *slog.Logger, client *http.Client, plugin createdPlugin) (*goMinVersionBump, error) { + versionDir := filepath.Join(plugin.pluginDir, plugin.newVersion) + pluginYAMLPath := filepath.Join(versionDir, "buf.plugin.yaml") + + content, err := os.ReadFile(pluginYAMLPath) + if err != nil { + return nil, err + } + + var config bufremotepluginconfig.ExternalConfig + if err := encoding.UnmarshalJSONOrYAMLStrict(content, &config); err != nil { + return nil, fmt.Errorf("failed to parse buf.plugin.yaml: %w", err) + } + + if config.Registry.Go == nil || len(config.Registry.Go.Deps) == 0 { + return nil, nil + } + + currentMinVersion := cmp.Or(config.Registry.Go.MinVersion, defaultGoModVersion) + maxVersion := currentMinVersion + var maxModule, maxModVersion string + + for _, dep := range config.Registry.Go.Deps { + goVersion, err := fetchGoModVersion(ctx, client, dep.Module, dep.Version) + if err != nil { + return nil, fmt.Errorf("failed to fetch go.mod for %s@%s: %w", dep.Module, dep.Version, err) + } + if semver.Compare("v"+goVersion, "v"+maxVersion) > 0 { + maxVersion = goVersion + maxModule = dep.Module + maxModVersion = dep.Version + } + } + + if maxVersion == currentMinVersion { + return nil, nil + } + + // Use text replacement to preserve formatting and comments. + oldStr := fmt.Sprintf(`min_version: "%s"`, currentMinVersion) + newStr := fmt.Sprintf(`min_version: "%s"`, maxVersion) + newContent := strings.ReplaceAll(string(content), oldStr, newStr) + + if newContent == string(content) { + logger.WarnContext(ctx, "could not find min_version to update in buf.plugin.yaml", + slog.String("plugin", plugin.String()), + slog.String("current", currentMinVersion), + slog.String("required", maxVersion)) + return nil, nil + } + + if err := os.WriteFile(pluginYAMLPath, []byte(newContent), 0600); err != nil { + return nil, err + } + + logger.InfoContext(ctx, "updated registry.go.min_version", + slog.String("plugin", plugin.String()), + slog.String("old", currentMinVersion), + slog.String("new", maxVersion), + slog.String("dep", fmt.Sprintf("%s@%s", maxModule, maxModVersion))) + + return &goMinVersionBump{ + oldVersion: currentMinVersion, + newVersion: maxVersion, + module: maxModule, + modVersion: maxModVersion, + }, nil +} + // updatePluginDeps updates plugin dependencies in a buf.plugin.yaml file to their latest versions. // It parses the YAML content to find deps entries, then uses text replacement to update // version references in-place, preserving the original formatting and comments. @@ -913,6 +1038,13 @@ func generatePRBody(created []createdPlugin) string { } else { fmt.Fprintf(&sb, "- %s: %s → %s\n", p.name, p.previousVersion, p.newVersion) } + if p.goMinVersionBump != nil { + goModURL := goModFileURL(p.goMinVersionBump.module, p.goMinVersionBump.modVersion) + fmt.Fprintf(&sb, " - registry.go.min_version bumped: %s → %s (required by [%s@%s go.mod](%s))\n", + p.goMinVersionBump.oldVersion, p.goMinVersionBump.newVersion, + p.goMinVersionBump.module, p.goMinVersionBump.modVersion, + goModURL) + } } } return strings.TrimRight(sb.String(), "\n") diff --git a/internal/cmd/fetcher/main_test.go b/internal/cmd/fetcher/main_test.go index 278db9781..9a659cb3c 100644 --- a/internal/cmd/fetcher/main_test.go +++ b/internal/cmd/fetcher/main_test.go @@ -2,7 +2,9 @@ package main import ( "context" + "io" "log/slog" + "net/http" "os" "path/filepath" "strings" @@ -398,6 +400,202 @@ COPY --from=consumer /binary /usr/local/bin/protoc-gen-consumer )) } +// mockHTTPTransport is an http.RoundTripper that serves static responses for testing. +type mockHTTPTransport struct { + responses map[string]string // URL -> response body +} + +func (m *mockHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + body, ok := m.responses[req.URL.String()] + if !ok { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil +} + +func TestUpdateGoRegistryMinVersion(t *testing.T) { + t.Parallel() + tests := []struct { + name string + pluginYAML string + goModResponses map[string]string + wantBump *goMinVersionBump + wantYAMLContains string + }{ + { + name: "bumps min_version when dep requires newer go", + pluginYAML: `version: v1 +name: buf.build/grpc-ecosystem/gateway +plugin_version: v2.29.0 +registry: + go: + min_version: "1.24" + deps: + - module: github.com/grpc-ecosystem/grpc-gateway/v2 + version: v2.29.0 +`, + goModResponses: map[string]string{ + "https://proxy.golang.org/github.com/grpc-ecosystem/grpc-gateway/v2/@v/v2.29.0.mod": "module github.com/grpc-ecosystem/grpc-gateway/v2\n\ngo 1.25\n", + }, + wantBump: &goMinVersionBump{ + oldVersion: "1.24", + newVersion: "1.25", + module: "github.com/grpc-ecosystem/grpc-gateway/v2", + modVersion: "v2.29.0", + }, + wantYAMLContains: `min_version: "1.25"`, + }, + { + name: "bumps to max across multiple deps", + pluginYAML: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +registry: + go: + min_version: "1.22" + deps: + - module: example.com/lower + version: v1.0.0 + - module: example.com/higher + version: v1.0.0 +`, + goModResponses: map[string]string{ + "https://proxy.golang.org/example.com/lower/@v/v1.0.0.mod": "module example.com/lower\n\ngo 1.23\n", + "https://proxy.golang.org/example.com/higher/@v/v1.0.0.mod": "module example.com/higher\n\ngo 1.25\n", + }, + wantBump: &goMinVersionBump{ + oldVersion: "1.22", + newVersion: "1.25", + module: "example.com/higher", + modVersion: "v1.0.0", + }, + wantYAMLContains: `min_version: "1.25"`, + }, + { + name: "no bump when min_version already matches", + pluginYAML: `version: v1 +name: buf.build/grpc-ecosystem/gateway +plugin_version: v2.29.0 +registry: + go: + min_version: "1.25" + deps: + - module: github.com/grpc-ecosystem/grpc-gateway/v2 + version: v2.29.0 +`, + goModResponses: map[string]string{ + "https://proxy.golang.org/github.com/grpc-ecosystem/grpc-gateway/v2/@v/v2.29.0.mod": "module github.com/grpc-ecosystem/grpc-gateway/v2\n\ngo 1.25\n", + }, + wantBump: nil, + wantYAMLContains: `min_version: "1.25"`, + }, + { + name: "no bump when no registry.go deps", + pluginYAML: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +output_languages: + - go +`, + goModResponses: nil, + wantBump: nil, + wantYAMLContains: "", + }, + { + name: "normalizes patch version from go.mod", + pluginYAML: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +registry: + go: + min_version: "1.24" + deps: + - module: example.com/mod + version: v1.0.0 +`, + goModResponses: map[string]string{ + // Go 1.21+ uses go 1.25.0 format in go.mod + "https://proxy.golang.org/example.com/mod/@v/v1.0.0.mod": "module example.com/mod\n\ngo 1.25.0\n", + }, + wantBump: &goMinVersionBump{ + oldVersion: "1.24", + newVersion: "1.25", + module: "example.com/mod", + modVersion: "v1.0.0", + }, + wantYAMLContains: `min_version: "1.25"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + pluginDir := filepath.Join(tmpDir, "plugins", "test", "plugin") + versionDir := filepath.Join(pluginDir, "v2.29.0") + require.NoError(t, os.MkdirAll(versionDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(versionDir, "buf.plugin.yaml"), []byte(tt.pluginYAML), 0644)) + + httpClient := &http.Client{Transport: &mockHTTPTransport{responses: tt.goModResponses}} + logger := slog.New(slog.NewTextHandler(testWriter{t}, &slog.HandlerOptions{Level: slog.LevelDebug})) + + plugin := createdPlugin{ + pluginDir: pluginDir, + newVersion: "v2.29.0", + } + bump, err := updateGoRegistryMinVersion(t.Context(), logger, httpClient, plugin) + require.NoError(t, err) + + if tt.wantBump == nil { + assert.Nil(t, bump) + } else { + require.NotNil(t, bump) + assert.Equal(t, tt.wantBump.oldVersion, bump.oldVersion) + assert.Equal(t, tt.wantBump.newVersion, bump.newVersion) + assert.Equal(t, tt.wantBump.module, bump.module) + assert.Equal(t, tt.wantBump.modVersion, bump.modVersion) + } + + if tt.wantYAMLContains != "" { + content, err := os.ReadFile(filepath.Join(versionDir, "buf.plugin.yaml")) + require.NoError(t, err) + assert.Contains(t, string(content), tt.wantYAMLContains) + } + }) + } +} + +func TestGeneratePRBodyWithGoMinVersionBump(t *testing.T) { + t.Parallel() + created := []createdPlugin{ + { + org: "grpc-ecosystem", + name: "gateway", + previousVersion: "v2.28.0", + newVersion: "v2.29.0", + goMinVersionBump: &goMinVersionBump{ + oldVersion: "1.24", + newVersion: "1.25", + module: "github.com/grpc-ecosystem/grpc-gateway/v2", + modVersion: "v2.29.0", + }, + }, + } + body := generatePRBody(created) + assert.Contains(t, body, "registry.go.min_version bumped: 1.24 → 1.25") + assert.Contains(t, body, "github.com/grpc-ecosystem/grpc-gateway/v2@v2.29.0") + assert.Contains(t, body, "proxy.golang.org/github.com/grpc-ecosystem/grpc-gateway/v2/@v/v2.29.0.mod") +} + type testWriter struct { tb testing.TB }