From 11da2c497e480e580ab09e4e1f66b6b95a055712 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Fri, 6 Feb 2026 11:07:38 -0500 Subject: [PATCH] Automate updating plugin versions First step towards removing some of the work we do when updating plugin versions. This simply looks for the latest plugin version that already exists in the plugins directory, and bumps the `deps:` key to the latest version for plugins. I _don't_ think this handles the case where the update to a dep is in the same PR as the update to the consuming plugin (I think the syncer can handle that case?), so we could maybe extend this further to detect that situation. I think we can also go further and start to look for updates to certain deps (e.g., a gRPC runtime package) on registries. --- internal/cmd/fetcher/main.go | 111 ++++++++++++++++++++++- internal/cmd/fetcher/main_test.go | 140 ++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/fetcher/main_test.go diff --git a/internal/cmd/fetcher/main.go b/internal/cmd/fetcher/main.go index 05739943a..7278b1860 100644 --- a/internal/cmd/fetcher/main.go +++ b/internal/cmd/fetcher/main.go @@ -16,10 +16,13 @@ import ( "time" "buf.build/go/interrupt" + "github.com/bufbuild/buf/private/bufpkg/bufremoteplugin/bufremotepluginconfig" + "github.com/bufbuild/buf/private/pkg/encoding" "golang.org/x/mod/semver" "github.com/bufbuild/plugins/internal/docker" "github.com/bufbuild/plugins/internal/fetchclient" + "github.com/bufbuild/plugins/internal/plugin" "github.com/bufbuild/plugins/internal/source" ) @@ -221,6 +224,70 @@ func runPluginTests(ctx context.Context, plugins []createdPlugin) error { return cmd.Run() } +// updatePluginDeps updates plugin dependencies in a buf.plugin.yaml file to their latest versions. +// It parses the YAML content, finds any entries in the "deps:" section with "plugin:" fields, +// and updates them to use the latest available version from latestVersions map. +// For example, if the YAML contains: +// +// deps: +// - plugin: buf.build/protocolbuffers/go:v1.30.0 +// +// and latestVersions maps "buf.build/protocolbuffers/go" to "v1.36.11", +// the function will update it to: +// +// deps: +// - plugin: buf.build/protocolbuffers/go:v1.36.11 +// +// It returns the modified content with updated dependency versions. +func updatePluginDeps(content []byte, latestVersions map[string]string) ([]byte, error) { + var config bufremotepluginconfig.ExternalConfig + if err := encoding.UnmarshalJSONOrYAMLStrict(content, &config); err != nil { + return nil, fmt.Errorf("failed to parse buf.plugin.yaml: %w", err) + } + + // Check if there are any plugin dependencies + if len(config.Deps) == 0 { + // No deps, return original content + return content, nil + } + + modified := false + for i := range config.Deps { + dep := &config.Deps[i] + if dep.Plugin == "" { + continue + } + + // Parse the plugin reference: buf.build/owner/name:version + pluginName, currentVersion, ok := strings.Cut(dep.Plugin, ":") + if !ok { + continue + } + + // Look up the latest version for this plugin + if latestVersion, exists := latestVersions[pluginName]; exists && latestVersion != currentVersion { + oldPluginRef := dep.Plugin + newPluginRef := pluginName + ":" + latestVersion + dep.Plugin = newPluginRef + log.Printf("updating plugin dependency %s -> %s", oldPluginRef, newPluginRef) + modified = true + } + } + + if !modified { + // No changes made, return original content + return content, nil + } + + // Marshal back to YAML + updatedContent, err := encoding.MarshalYAML(&config) + if err != nil { + return nil, fmt.Errorf("failed to marshal updated YAML: %w", err) + } + + return updatedContent, nil +} + func run(ctx context.Context, root string) ([]createdPlugin, error) { now := time.Now() defer func() { @@ -234,6 +301,21 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) { if err != nil { return nil, err } + + // Load all existing plugins to determine latest versions for dependency bumping + pluginsDir := filepath.Join(root, "plugins") + allPlugins, err := plugin.FindAll(pluginsDir) + if err != nil { + return nil, fmt.Errorf("failed to load existing plugins: %w", err) + } + latestPluginVersions := make(map[string]string) + for _, p := range allPlugins { + current := latestPluginVersions[p.Name] + if current == "" || semver.Compare(current, p.PluginVersion) < 0 { + latestPluginVersions[p.Name] = p.PluginVersion + } + } + configs, err := source.GatherConfigs(root) if err != nil { return nil, err @@ -277,7 +359,7 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) { if err != nil { return nil, fmt.Errorf("failed to get latest known version from dir %s with error: %w", pluginDir, err) } - if err := createPluginDir(pluginDir, previousVersion, newVersion, latestBaseImageVersions); err != nil { + if err := createPluginDir(pluginDir, previousVersion, newVersion, latestBaseImageVersions, latestPluginVersions); err != nil { return nil, err } log.Printf("created %v/%v\n", pluginDir, newVersion) @@ -301,6 +383,7 @@ func copyDirectory( prevVersion string, newVersion string, latestBaseImages *docker.BaseImages, + latestPluginVersions map[string]string, ) (retErr error) { entries, err := os.ReadDir(source) if err != nil { @@ -324,6 +407,7 @@ func copyDirectory( prevVersion, newVersion, latestBaseImages, + latestPluginVersions, ); err != nil { return err } @@ -336,6 +420,7 @@ func createPluginDir( previousVersion string, newVersion string, latestBaseImages *docker.BaseImages, + latestPluginVersions map[string]string, ) (retErr error) { if err := os.Mkdir(filepath.Join(dir, newVersion), 0755); err != nil { return err @@ -351,6 +436,7 @@ func createPluginDir( previousVersion, newVersion, latestBaseImages, + latestPluginVersions, ) } @@ -360,6 +446,7 @@ func copyFile( prevVersion string, newVersion string, latestBaseImages *docker.BaseImages, + latestPluginVersions map[string]string, ) (retErr error) { srcFile, err := os.Open(src) if err != nil { @@ -386,6 +473,28 @@ func copyFile( } return nil } + + // Special handling for buf.plugin.yaml to update plugin dependencies + if filename == "buf.plugin.yaml" { + content, err := io.ReadAll(srcFile) + if err != nil { + return fmt.Errorf("failed to read buf.plugin.yaml: %w", err) + } + // Update plugin dependencies to latest versions + content, err = updatePluginDeps(content, latestPluginVersions) + if err != nil { + return fmt.Errorf("failed to update plugin deps: %w", err) + } + // Now do the version string replacement + prevVersionStripped := strings.TrimPrefix(prevVersion, "v") + newVersionStripped := strings.TrimPrefix(newVersion, "v") + content = []byte(strings.ReplaceAll(string(content), prevVersionStripped, newVersionStripped)) + if _, err := destFile.Write(content); err != nil { + return err + } + return nil + } + isDockerfile := strings.HasPrefix(filename, "Dockerfile") prevVersion = strings.TrimPrefix(prevVersion, "v") newVersion = strings.TrimPrefix(newVersion, "v") diff --git a/internal/cmd/fetcher/main_test.go b/internal/cmd/fetcher/main_test.go new file mode 100644 index 000000000..1463426f2 --- /dev/null +++ b/internal/cmd/fetcher/main_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "strings" + "testing" + + "github.com/bufbuild/buf/private/bufpkg/bufremoteplugin/bufremotepluginconfig" + "github.com/bufbuild/buf/private/pkg/encoding" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdatePluginDeps(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + latestVersions map[string]string + expectedUpdates map[string]string // plugin name -> expected version + wantErr bool + }{ + { + name: "updates single plugin dependency", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +deps: + - plugin: buf.build/protocolbuffers/go:v1.30.0 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + expectedUpdates: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + }, + { + name: "updates multiple plugin dependencies", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +deps: + - plugin: buf.build/protocolbuffers/go:v1.30.0 + - plugin: buf.build/protocolbuffers/python:v30.0 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + "buf.build/protocolbuffers/python": "v33.5", + }, + expectedUpdates: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + "buf.build/protocolbuffers/python": "v33.5", + }, + }, + { + name: "no updates when already at latest version", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +deps: + - plugin: buf.build/protocolbuffers/go:v1.36.11 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + expectedUpdates: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + }, + { + name: "handles missing plugin in latestVersions map", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +deps: + - plugin: buf.build/unknown/plugin:v1.0.0 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + expectedUpdates: map[string]string{ + "buf.build/unknown/plugin": "v1.0.0", // unchanged + }, + }, + { + name: "handles yaml without deps", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + expectedUpdates: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := updatePluginDeps([]byte(tt.input), tt.latestVersions) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + + // Parse the result to check if dependencies were updated correctly + var config bufremotepluginconfig.ExternalConfig + err = encoding.UnmarshalJSONOrYAMLStrict(result, &config) + require.NoError(t, err, "failed to parse result YAML") + + if tt.expectedUpdates == nil { + // No deps expected in result + assert.Empty(t, config.Deps, "expected no deps field, but got one") + return + } + + require.NotEmpty(t, config.Deps, "expected deps field in result") + + // Check each dependency + foundDeps := make(map[string]string) + for _, dep := range config.Deps { + if dep.Plugin == "" { + continue + } + pluginName, version, ok := strings.Cut(dep.Plugin, ":") + require.True(t, ok, "invalid plugin reference format: %s", dep.Plugin) + foundDeps[pluginName] = version + } + + // Verify all expected updates + for pluginName, expectedVersion := range tt.expectedUpdates { + foundVersion, ok := foundDeps[pluginName] + assert.True(t, ok, "missing plugin dependency: %s", pluginName) + assert.Equal(t, expectedVersion, foundVersion, "plugin %s version mismatch", pluginName) + } + }) + } +}