From f3ae1544a4e6d170be0dcd504d19cbfec8d6f0b4 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:24:50 +0100 Subject: [PATCH 01/14] feat(plugin): add --version flag to plugin update command Add support for specifying a version constraint when updating plugins, matching the existing behavior of helm plugin install. Changes: - Add --version flag to plugin update command - Update VCSInstaller.Update() to resolve and checkout specified version - Update FindSource() to accept version parameter - Add TestVCSInstallerUpdateWithVersion test for version support Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- internal/plugin/installer/installer.go | 4 +- internal/plugin/installer/vcs_installer.go | 18 +++++- .../plugin/installer/vcs_installer_test.go | 63 ++++++++++++++++++- pkg/cmd/plugin_update.go | 12 ++-- 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index a7ca35ad4..566482f44 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -156,8 +156,8 @@ func NewForSource(source, version string) (installer Installer, err error) { } // FindSource determines the correct Installer for the given source. -func FindSource(location string) (Installer, error) { - installer, err := existingVCSRepo(location) +func FindSource(location string, version string) (Installer, error) { + installer, err := existingVCSRepo(location, version) if err != nil && err.Error() == "Cannot detect VCS" { slog.Warn( "cannot get information about plugin source", diff --git a/internal/plugin/installer/vcs_installer.go b/internal/plugin/installer/vcs_installer.go index 3601ec7a8..1e858c9ce 100644 --- a/internal/plugin/installer/vcs_installer.go +++ b/internal/plugin/installer/vcs_installer.go @@ -38,14 +38,15 @@ type VCSInstaller struct { base } -func existingVCSRepo(location string) (Installer, error) { +func existingVCSRepo(location string, version string) (Installer, error) { repo, err := vcs.NewRepo("", location) if err != nil { return nil, err } i := &VCSInstaller{ - Repo: repo, - base: newBase(repo.Remote()), + Repo: repo, + Version: version, + base: newBase(repo.Remote()), } return i, nil } @@ -104,6 +105,17 @@ func (i *VCSInstaller) Update() error { if err := i.Repo.Update(); err != nil { return err } + + ref, err := i.solveVersion(i.Repo) + if err != nil { + return err + } + if ref != "" { + if err := i.setVersion(i.Repo, ref); err != nil { + return err + } + } + if !isPlugin(i.Repo.LocalPath()) { return ErrMissingMetadata } diff --git a/internal/plugin/installer/vcs_installer_test.go b/internal/plugin/installer/vcs_installer_test.go index 54f94c724..26fe12a38 100644 --- a/internal/plugin/installer/vcs_installer_test.go +++ b/internal/plugin/installer/vcs_installer_test.go @@ -48,6 +48,7 @@ func (r *testRepo) UpdateVersion(version string) error { r.current = version return r.err } +func (r *testRepo) IsDirty() bool { return false } func TestVCSInstaller(t *testing.T) { ensure.HelmHome(t) @@ -96,7 +97,7 @@ func TestVCSInstaller(t *testing.T) { } // Testing FindSource method, expect error because plugin code is not a cloned repository - if _, err := FindSource(i.Path()); err == nil { + if _, err := FindSource(i.Path(), ""); err == nil { t.Fatal("expected error for inability to find plugin source, got none") } else if err.Error() != "cannot get information about plugin source" { t.Fatalf("expected error for inability to find plugin source, got (%v)", err) @@ -158,7 +159,7 @@ func TestVCSInstallerUpdate(t *testing.T) { } // Test FindSource method for positive result - pluginInfo, err := FindSource(i.Path()) + pluginInfo, err := FindSource(i.Path(), "") if err != nil { t.Fatal(err) } @@ -186,3 +187,61 @@ func TestVCSInstallerUpdate(t *testing.T) { t.Fatalf("expected error for plugin modified, got (%v)", err) } } + +func TestVCSInstallerUpdateWithVersion(t *testing.T) { + ensure.HelmHome(t) + + if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil { + t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err) + } + + source := "https://github.com/adamreese/helm-env" + testRepoPath, _ := filepath.Abs("../testdata/plugdir/good/echo-v1") + repo := &testRepo{ + local: testRepoPath, + remote: source, + tags: []string{"0.1.0", "0.1.1", "0.2.0"}, + } + + // First install without version + i, err := NewForSource(source, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + vcsInstaller, ok := i.(*VCSInstaller) + if !ok { + t.Fatal("expected a VCSInstaller") + } + vcsInstaller.Repo = repo + + if err := Install(i); err != nil { + t.Fatal(err) + } + + // Now test update with specific version constraint + vcsInstaller.Version = "~0.1.0" + if err := Update(vcsInstaller); err != nil { + t.Fatal(err) + } + if repo.current != "0.1.1" { + t.Fatalf("expected version '0.1.1', got %q", repo.current) + } + + // Test update with different version constraint + vcsInstaller.Version = "0.2.0" + if err := Update(vcsInstaller); err != nil { + t.Fatal(err) + } + if repo.current != "0.2.0" { + t.Fatalf("expected version '0.2.0', got %q", repo.current) + } + + // Test update with non-existent version + vcsInstaller.Version = "0.3.0" + if err := Update(vcsInstaller); err == nil { + t.Fatal("expected error for version does not exist, got none") + } else if err.Error() != fmt.Sprintf("requested version %q does not exist for plugin %q", "0.3.0", source) { + t.Fatalf("expected error for version does not exist, got (%v)", err) + } +} diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 1cba7ffa9..3d6aecc94 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -29,7 +29,8 @@ import ( ) type pluginUpdateOptions struct { - names []string + names []string + version string } func newPluginUpdateCmd(out io.Writer) *cobra.Command { @@ -49,6 +50,7 @@ func newPluginUpdateCmd(out io.Writer) *cobra.Command { return o.run(out) }, } + cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the latest version is installed") return cmd } @@ -70,8 +72,8 @@ func (o *pluginUpdateOptions) run(out io.Writer) error { for _, name := range o.names { if found := findPlugin(plugins, name); found != nil { - if err := updatePlugin(found); err != nil { - errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%w)", name, err)) + if err := updatePlugin(found, o.version); err != nil { + errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%v)", name, err)) } else { fmt.Fprintf(out, "Updated plugin: %s\n", name) } @@ -85,7 +87,7 @@ func (o *pluginUpdateOptions) run(out io.Writer) error { return nil } -func updatePlugin(p plugin.Plugin) error { +func updatePlugin(p plugin.Plugin, version string) error { exactLocation, err := filepath.EvalSymlinks(p.Dir()) if err != nil { return err @@ -95,7 +97,7 @@ func updatePlugin(p plugin.Plugin) error { return err } - i, err := installer.FindSource(absExactLocation) + i, err := installer.FindSource(absExactLocation, version) if err != nil { return err } From f006e3bb55fcd6a138a1a93e1ab0170f9431499a Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:45:06 +0100 Subject: [PATCH 02/14] Applied feedback from copilot pt. 1 Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 3d6aecc94..8c038b8a9 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -50,7 +50,7 @@ func newPluginUpdateCmd(out io.Writer) *cobra.Command { return o.run(out) }, } - cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the latest version is installed") + cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the plugin is updated to the latest version") return cmd } From 284ce16a4f641f099d2284d12193d2659218cd70 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:32:54 +0100 Subject: [PATCH 03/14] Applied feedback from review pt. 2 Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- internal/plugin/installer/installer.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index 566482f44..22c44fece 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -156,8 +156,13 @@ func NewForSource(source, version string) (installer Installer, err error) { } // FindSource determines the correct Installer for the given source. -func FindSource(location string, version string) (Installer, error) { - installer, err := existingVCSRepo(location, version) +// Version is optional; if not provided, it defaults to empty string. +func FindSource(location string, version ...string) (Installer, error) { + v := "" + if len(version) > 0 { + v = version[0] + } + installer, err := existingVCSRepo(location, v) if err != nil && err.Error() == "Cannot detect VCS" { slog.Warn( "cannot get information about plugin source", From 47b0132fde2dc4600fd29350a8c040c660409ad4 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:59:59 +0100 Subject: [PATCH 04/14] Drop --version flag and support the @ pattern Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update.go | 56 +++++++++++-- pkg/cmd/plugin_update_test.go | 154 ++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 pkg/cmd/plugin_update_test.go diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 8c038b8a9..8f1fd30d0 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -21,7 +21,9 @@ import ( "io" "log/slog" "path/filepath" + "strings" + "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" "helm.sh/helm/v4/internal/plugin" @@ -29,17 +31,29 @@ import ( ) type pluginUpdateOptions struct { - names []string - version string + plugins map[string]string } +const pluginUpdateDesc = `Update one or more Helm plugins. + +An exact version can be supplied per-plugin using the @version syntax: + + helm plugin update myplugin@1.2.3 otherplugin@2.0.0 + helm plugin update myplugin@v1.0.0 + +If no version is given for a plugin it is updated to the latest version: + + helm plugin update myplugin otherplugin +` + func newPluginUpdateCmd(out io.Writer) *cobra.Command { o := &pluginUpdateOptions{} cmd := &cobra.Command{ - Use: "update ...", + Use: "update ...", Aliases: []string{"up"}, Short: "update one or more Helm plugins", + Long: pluginUpdateDesc, ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return compListPlugins(toComplete, args), cobra.ShellCompDirectiveNoFileComp }, @@ -50,7 +64,6 @@ func newPluginUpdateCmd(out io.Writer) *cobra.Command { return o.run(out) }, } - cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the plugin is updated to the latest version") return cmd } @@ -58,21 +71,39 @@ func (o *pluginUpdateOptions) complete(args []string) error { if len(args) == 0 { return errors.New("please provide plugin name to update") } - o.names = args + + o.plugins = make(map[string]string, len(args)) + + for _, arg := range args { + name, version := parsePluginVersion(arg) + if name == "" { + return fmt.Errorf("invalid plugin reference %q: plugin name must not be empty", arg) + } + if _, exists := o.plugins[name]; exists { + return fmt.Errorf("plugin %q specified more than once", name) + } + if version != "" { + if _, err := semver.NewVersion(version); err != nil { + return fmt.Errorf("invalid version %q for plugin %q: must be an exact version (e.g. 1.2.3 or v1.2.3)", version, name) + } + } + o.plugins[name] = version + } + return nil } func (o *pluginUpdateOptions) run(out io.Writer) error { slog.Debug("loading installed plugins", "path", settings.PluginsDirectory) - plugins, err := plugin.LoadAllDir(settings.PluginsDirectory, plugin.LogIgnorePluginLoadErrorFilterFunc) + installed, err := plugin.LoadAllDir(settings.PluginsDirectory, plugin.LogIgnorePluginLoadErrorFilterFunc) if err != nil { return err } var errorPlugins []error - for _, name := range o.names { - if found := findPlugin(plugins, name); found != nil { - if err := updatePlugin(found, o.version); err != nil { + for name, version := range o.plugins { + if found := findPlugin(installed, name); found != nil { + if err := updatePlugin(found, version); err != nil { errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%v)", name, err)) } else { fmt.Fprintf(out, "Updated plugin: %s\n", name) @@ -87,6 +118,13 @@ func (o *pluginUpdateOptions) run(out io.Writer) error { return nil } +func parsePluginVersion(arg string) (name, version string) { + if i := strings.LastIndex(arg, "@"); i >= 0 { + return arg[:i], arg[i+1:] + } + return arg, "" +} + func updatePlugin(p plugin.Plugin, version string) error { exactLocation, err := filepath.EvalSymlinks(p.Dir()) if err != nil { diff --git a/pkg/cmd/plugin_update_test.go b/pkg/cmd/plugin_update_test.go new file mode 100644 index 000000000..5c72074ab --- /dev/null +++ b/pkg/cmd/plugin_update_test.go @@ -0,0 +1,154 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePluginVersion(t *testing.T) { + tests := []struct { + arg string + wantName string + wantVersion string + }{ + {"myplugin", "myplugin", ""}, + {"myplugin@1.2.3", "myplugin", "1.2.3"}, + {"myplugin@v1.2.3", "myplugin", "v1.2.3"}, + {"myplugin@", "myplugin", ""}, + {"@version", "", "version"}, + {"", "", ""}, + // LastIndex ensures the last @ is used as delimiter + {"weird@name@1.0", "weird@name", "1.0"}, + } + for _, tt := range tests { + t.Run(tt.arg, func(t *testing.T) { + name, version := parsePluginVersion(tt.arg) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantVersion, version) + }) + } +} + +func TestPluginUpdateComplete(t *testing.T) { + tests := []struct { + name string + args []string + wantPlugins map[string]string + wantErr string + }{ + { + name: "no args", + args: []string{}, + wantErr: "please provide plugin name to update", + }, + { + name: "single plugin no version", + args: []string{"myplugin"}, + wantPlugins: map[string]string{"myplugin": ""}, + }, + { + name: "single plugin with inline version", + args: []string{"myplugin@1.2.3"}, + wantPlugins: map[string]string{"myplugin": "1.2.3"}, + }, + { + name: "multiple plugins no versions", + args: []string{"plugin-a", "plugin-b"}, + wantPlugins: map[string]string{"plugin-a": "", "plugin-b": ""}, + }, + { + name: "multiple plugins with inline versions", + args: []string{"plugin-a@1.0.0", "plugin-b@2.0.0"}, + wantPlugins: map[string]string{"plugin-a": "1.0.0", "plugin-b": "2.0.0"}, + }, + { + name: "multiple plugins each with different exact versions", + args: []string{"plugin-a@v1.2.3", "plugin-b@2.0.0", "plugin-c@3.0.0"}, + wantPlugins: map[string]string{"plugin-a": "v1.2.3", "plugin-b": "2.0.0", "plugin-c": "3.0.0"}, + }, + { + name: "multiple plugins mixed versions", + args: []string{"plugin-a@1.0.0", "plugin-b"}, + wantPlugins: map[string]string{"plugin-a": "1.0.0", "plugin-b": ""}, + }, + { + name: "multiple plugins mixed with latest in the middle", + args: []string{"plugin-a@1.0.0", "plugin-b", "plugin-c@3.0.0"}, + wantPlugins: map[string]string{"plugin-a": "1.0.0", "plugin-b": "", "plugin-c": "3.0.0"}, + }, + { + name: "duplicate plugin name errors", + args: []string{"myplugin@1.0.0", "myplugin@2.0.0"}, + wantErr: `plugin "myplugin" specified more than once`, + }, + { + name: "empty plugin name errors", + args: []string{"@1.0.0"}, + wantErr: `invalid plugin reference "@1.0.0": plugin name must not be empty`, + }, + { + name: "tilde range version rejected", + args: []string{"myplugin@~1.2"}, + wantErr: `invalid version "~1.2" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + }, + { + name: "caret range version rejected", + args: []string{"myplugin@^1.2.3"}, + wantErr: `invalid version "^1.2.3" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + }, + { + name: "gte constraint rejected", + args: []string{"myplugin@>=1.0.0"}, + wantErr: `invalid version ">=1.0.0" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + }, + { + name: "wildcard version rejected", + args: []string{"myplugin@1.x"}, + wantErr: `invalid version "1.x" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + }, + { + name: "range constraint rejected", + args: []string{"myplugin@>=1.0.0, <2.0.0"}, + wantErr: `invalid version ">=1.0.0, <2.0.0" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + }, + { + name: "garbage version rejected", + args: []string{"myplugin@notaversion"}, + wantErr: `invalid version "notaversion" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + }, + { + name: "range rejected among multiple plugins", + args: []string{"plugin-a@1.0.0", "plugin-b@~2.0"}, + wantErr: `invalid version "~2.0" for plugin "plugin-b": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &pluginUpdateOptions{} + err := o.complete(tt.args) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantPlugins, o.plugins) + }) + } +} From 7efce1b419ccec4a70a61ec3ac8a93afe4a60d32 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:09:52 +0100 Subject: [PATCH 05/14] Updated pluginUpdateDesc Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 8f1fd30d0..f85978a3b 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -36,11 +36,13 @@ type pluginUpdateOptions struct { const pluginUpdateDesc = `Update one or more Helm plugins. -An exact version can be supplied per-plugin using the @version syntax: +An exact semver version can be supplied per-plugin using the @version syntax: helm plugin update myplugin@1.2.3 otherplugin@2.0.0 helm plugin update myplugin@v1.0.0 +Range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported. + If no version is given for a plugin it is updated to the latest version: helm plugin update myplugin otherplugin From 16f97951cf8c1905a4c3bdc8575348556deb7010 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:03:20 +0100 Subject: [PATCH 06/14] Updated FindSource signature Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- internal/plugin/installer/installer.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index 22c44fece..88e7dab27 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -156,13 +156,8 @@ func NewForSource(source, version string) (installer Installer, err error) { } // FindSource determines the correct Installer for the given source. -// Version is optional; if not provided, it defaults to empty string. -func FindSource(location string, version ...string) (Installer, error) { - v := "" - if len(version) > 0 { - v = version[0] - } - installer, err := existingVCSRepo(location, v) +func FindSource(location, version string) (Installer, error) { + installer, err := existingVCSRepo(location, version) if err != nil && err.Error() == "Cannot detect VCS" { slog.Warn( "cannot get information about plugin source", From 4f6d72ac8bbcbe45c2722d6c232be4c8bdd75943 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:04:21 +0100 Subject: [PATCH 07/14] Reject v-versions Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update.go | 6 +++--- pkg/cmd/plugin_update_test.go | 23 ++++++++++++++--------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index f85978a3b..488c8f8ee 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -41,7 +41,7 @@ An exact semver version can be supplied per-plugin using the @version syntax: helm plugin update myplugin@1.2.3 otherplugin@2.0.0 helm plugin update myplugin@v1.0.0 -Range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported. +Range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0, v1.0.0) are not supported. If no version is given for a plugin it is updated to the latest version: @@ -85,8 +85,8 @@ func (o *pluginUpdateOptions) complete(args []string) error { return fmt.Errorf("plugin %q specified more than once", name) } if version != "" { - if _, err := semver.NewVersion(version); err != nil { - return fmt.Errorf("invalid version %q for plugin %q: must be an exact version (e.g. 1.2.3 or v1.2.3)", version, name) + if _, err := semver.StrictNewVersion(version); err != nil { + return fmt.Errorf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3); the \"v\" prefix is not allowed", version, name) } } o.plugins[name] = version diff --git a/pkg/cmd/plugin_update_test.go b/pkg/cmd/plugin_update_test.go index 5c72074ab..ec7256d3a 100644 --- a/pkg/cmd/plugin_update_test.go +++ b/pkg/cmd/plugin_update_test.go @@ -80,8 +80,8 @@ func TestPluginUpdateComplete(t *testing.T) { }, { name: "multiple plugins each with different exact versions", - args: []string{"plugin-a@v1.2.3", "plugin-b@2.0.0", "plugin-c@3.0.0"}, - wantPlugins: map[string]string{"plugin-a": "v1.2.3", "plugin-b": "2.0.0", "plugin-c": "3.0.0"}, + args: []string{"plugin-a@1.2.3", "plugin-b@2.0.0", "plugin-c@3.0.0"}, + wantPlugins: map[string]string{"plugin-a": "1.2.3", "plugin-b": "2.0.0", "plugin-c": "3.0.0"}, }, { name: "multiple plugins mixed versions", @@ -103,40 +103,45 @@ func TestPluginUpdateComplete(t *testing.T) { args: []string{"@1.0.0"}, wantErr: `invalid plugin reference "@1.0.0": plugin name must not be empty`, }, + { + name: "v-prefixed version rejected", + args: []string{"myplugin@v1.2.3"}, + wantErr: `invalid version "v1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, + }, { name: "tilde range version rejected", args: []string{"myplugin@~1.2"}, - wantErr: `invalid version "~1.2" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + wantErr: `invalid version "~1.2" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, }, { name: "caret range version rejected", args: []string{"myplugin@^1.2.3"}, - wantErr: `invalid version "^1.2.3" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + wantErr: `invalid version "^1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, }, { name: "gte constraint rejected", args: []string{"myplugin@>=1.0.0"}, - wantErr: `invalid version ">=1.0.0" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + wantErr: `invalid version ">=1.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, }, { name: "wildcard version rejected", args: []string{"myplugin@1.x"}, - wantErr: `invalid version "1.x" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + wantErr: `invalid version "1.x" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, }, { name: "range constraint rejected", args: []string{"myplugin@>=1.0.0, <2.0.0"}, - wantErr: `invalid version ">=1.0.0, <2.0.0" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + wantErr: `invalid version ">=1.0.0, <2.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, }, { name: "garbage version rejected", args: []string{"myplugin@notaversion"}, - wantErr: `invalid version "notaversion" for plugin "myplugin": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + wantErr: `invalid version "notaversion" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, }, { name: "range rejected among multiple plugins", args: []string{"plugin-a@1.0.0", "plugin-b@~2.0"}, - wantErr: `invalid version "~2.0" for plugin "plugin-b": must be an exact version (e.g. 1.2.3 or v1.2.3)`, + wantErr: `invalid version "~2.0" for plugin "plugin-b": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, }, } for _, tt := range tests { From 772a4d66d34911d734da24daebd498934ca1d767 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:05:59 +0100 Subject: [PATCH 08/14] Updated message Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 488c8f8ee..b8c43dcb0 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -86,7 +86,7 @@ func (o *pluginUpdateOptions) complete(args []string) error { } if version != "" { if _, err := semver.StrictNewVersion(version); err != nil { - return fmt.Errorf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3); the \"v\" prefix is not allowed", version, name) + return fmt.Errorf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3)", version, name) } } o.plugins[name] = version From 8713257d2071aa96c0fd3becc18cc8e0cc163000 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:51:31 +0100 Subject: [PATCH 09/14] Fixed descriptions and tests Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- internal/plugin/installer/vcs_installer_test.go | 6 +++--- pkg/cmd/plugin_update.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/plugin/installer/vcs_installer_test.go b/internal/plugin/installer/vcs_installer_test.go index 26fe12a38..8846452d6 100644 --- a/internal/plugin/installer/vcs_installer_test.go +++ b/internal/plugin/installer/vcs_installer_test.go @@ -219,8 +219,8 @@ func TestVCSInstallerUpdateWithVersion(t *testing.T) { t.Fatal(err) } - // Now test update with specific version constraint - vcsInstaller.Version = "~0.1.0" + // Now test update with specific version + vcsInstaller.Version = "0.1.1" if err := Update(vcsInstaller); err != nil { t.Fatal(err) } @@ -228,7 +228,7 @@ func TestVCSInstallerUpdateWithVersion(t *testing.T) { t.Fatalf("expected version '0.1.1', got %q", repo.current) } - // Test update with different version constraint + // Test update with different version vcsInstaller.Version = "0.2.0" if err := Update(vcsInstaller); err != nil { t.Fatal(err) diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index b8c43dcb0..6bffe1658 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -39,7 +39,7 @@ const pluginUpdateDesc = `Update one or more Helm plugins. An exact semver version can be supplied per-plugin using the @version syntax: helm plugin update myplugin@1.2.3 otherplugin@2.0.0 - helm plugin update myplugin@v1.0.0 + helm plugin update myplugin@1.0.0 Range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0, v1.0.0) are not supported. @@ -86,7 +86,7 @@ func (o *pluginUpdateOptions) complete(args []string) error { } if version != "" { if _, err := semver.StrictNewVersion(version); err != nil { - return fmt.Errorf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3)", version, name) + return fmt.Errorf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3); the 'v' prefix is not allowed", version, name) } } o.plugins[name] = version From e77809cc9255830edeabbb055e98d962d268fd5a Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:44:47 +0100 Subject: [PATCH 10/14] Update pkg/cmd/plugin_update.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 6bffe1658..bbb12f0d4 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -57,7 +57,12 @@ func newPluginUpdateCmd(out io.Writer) *cobra.Command { Short: "update one or more Helm plugins", Long: pluginUpdateDesc, ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return compListPlugins(toComplete, args), cobra.ShellCompDirectiveNoFileComp + ignoredNames := make([]string, len(args)) + for i, arg := range args { + name, _ := parsePluginVersion(arg) + ignoredNames[i] = name + } + return compListPlugins(toComplete, ignoredNames), cobra.ShellCompDirectiveNoFileComp }, PreRunE: func(_ *cobra.Command, args []string) error { return o.complete(args) From cbf43929e2e1ffabe1711c4a718ee6796c3c0cbd Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:18:23 +0100 Subject: [PATCH 11/14] Let's make copilot happy again Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index bbb12f0d4..3bb2a5c6c 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -36,13 +36,14 @@ type pluginUpdateOptions struct { const pluginUpdateDesc = `Update one or more Helm plugins. -An exact semver version can be supplied per-plugin using the @version syntax: +An exact semver version can be pinned per-plugin using the @version syntax. +Only exact versions (e.g. 1.2.3) are accepted; the "v" prefix and semver range +constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported for updates. +This ensures a deterministic, reproducible update to a known version: helm plugin update myplugin@1.2.3 otherplugin@2.0.0 helm plugin update myplugin@1.0.0 -Range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0, v1.0.0) are not supported. - If no version is given for a plugin it is updated to the latest version: helm plugin update myplugin otherplugin @@ -91,7 +92,7 @@ func (o *pluginUpdateOptions) complete(args []string) error { } if version != "" { if _, err := semver.StrictNewVersion(version); err != nil { - return fmt.Errorf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3); the 'v' prefix is not allowed", version, name) + return fmt.Errorf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3); the \"v\" prefix is not allowed", version, name) } } o.plugins[name] = version From febb5daf5de2a60a6187f91fab66755e6e4b325e Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:49:11 +0100 Subject: [PATCH 12/14] Update pkg/cmd/plugin_update.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 3bb2a5c6c..b9b64f118 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -92,7 +92,11 @@ func (o *pluginUpdateOptions) complete(args []string) error { } if version != "" { if _, err := semver.StrictNewVersion(version); err != nil { - return fmt.Errorf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3); the \"v\" prefix is not allowed", version, name) + errMsg := fmt.Sprintf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported", version, name) + if strings.HasPrefix(version, "v") { + errMsg += `; the "v" prefix is not allowed` + } + return fmt.Errorf("%s", errMsg) } } o.plugins[name] = version From 2bdffdcad60e6a536655d76634135b864a4a66de Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:31:13 +0100 Subject: [PATCH 13/14] fix(plugin): align test error expectations with context-sensitive validation messages Update plugin_update_test.go so expected errors match the implementation: non-v-prefixed versions no longer incorrectly expect the 'v' prefix warning, and v-prefixed versions now include the full message with both the range constraint notice and the 'v' prefix warning. Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/plugin_update_test.go b/pkg/cmd/plugin_update_test.go index ec7256d3a..78b5b1374 100644 --- a/pkg/cmd/plugin_update_test.go +++ b/pkg/cmd/plugin_update_test.go @@ -106,42 +106,42 @@ func TestPluginUpdateComplete(t *testing.T) { { name: "v-prefixed version rejected", args: []string{"myplugin@v1.2.3"}, - wantErr: `invalid version "v1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, + wantErr: `invalid version "v1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported; the "v" prefix is not allowed`, }, { name: "tilde range version rejected", args: []string{"myplugin@~1.2"}, - wantErr: `invalid version "~1.2" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, + wantErr: `invalid version "~1.2" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, }, { name: "caret range version rejected", args: []string{"myplugin@^1.2.3"}, - wantErr: `invalid version "^1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, + wantErr: `invalid version "^1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, }, { name: "gte constraint rejected", args: []string{"myplugin@>=1.0.0"}, - wantErr: `invalid version ">=1.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, + wantErr: `invalid version ">=1.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, }, { name: "wildcard version rejected", args: []string{"myplugin@1.x"}, - wantErr: `invalid version "1.x" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, + wantErr: `invalid version "1.x" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, }, { name: "range constraint rejected", args: []string{"myplugin@>=1.0.0, <2.0.0"}, - wantErr: `invalid version ">=1.0.0, <2.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, + wantErr: `invalid version ">=1.0.0, <2.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, }, { name: "garbage version rejected", args: []string{"myplugin@notaversion"}, - wantErr: `invalid version "notaversion" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, + wantErr: `invalid version "notaversion" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, }, { name: "range rejected among multiple plugins", args: []string{"plugin-a@1.0.0", "plugin-b@~2.0"}, - wantErr: `invalid version "~2.0" for plugin "plugin-b": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, + wantErr: `invalid version "~2.0" for plugin "plugin-b": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, }, } for _, tt := range tests { From ccfa38aa36f98c431002dddf66ed91ba32188e80 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:17:26 +0100 Subject: [PATCH 14/14] fix(plugin): simplify version validation error and align test expectations Remove verbose 'semver range constraints' suffix from the error message. The 'v' prefix hint is still appended only when the version starts with 'v'. Update test expectations to match the simplified messages. Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/plugin_update.go | 2 +- pkg/cmd/plugin_update_test.go | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index b9b64f118..d63d63213 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -92,7 +92,7 @@ func (o *pluginUpdateOptions) complete(args []string) error { } if version != "" { if _, err := semver.StrictNewVersion(version); err != nil { - errMsg := fmt.Sprintf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported", version, name) + errMsg := fmt.Sprintf("invalid version %q for plugin %q: must be an exact semver version (e.g. 1.2.3)", version, name) if strings.HasPrefix(version, "v") { errMsg += `; the "v" prefix is not allowed` } diff --git a/pkg/cmd/plugin_update_test.go b/pkg/cmd/plugin_update_test.go index 78b5b1374..4458457d2 100644 --- a/pkg/cmd/plugin_update_test.go +++ b/pkg/cmd/plugin_update_test.go @@ -106,42 +106,42 @@ func TestPluginUpdateComplete(t *testing.T) { { name: "v-prefixed version rejected", args: []string{"myplugin@v1.2.3"}, - wantErr: `invalid version "v1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported; the "v" prefix is not allowed`, + wantErr: `invalid version "v1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); the "v" prefix is not allowed`, }, { name: "tilde range version rejected", args: []string{"myplugin@~1.2"}, - wantErr: `invalid version "~1.2" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, + wantErr: `invalid version "~1.2" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`, }, { name: "caret range version rejected", args: []string{"myplugin@^1.2.3"}, - wantErr: `invalid version "^1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, + wantErr: `invalid version "^1.2.3" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`, }, { name: "gte constraint rejected", args: []string{"myplugin@>=1.0.0"}, - wantErr: `invalid version ">=1.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, + wantErr: `invalid version ">=1.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`, }, { name: "wildcard version rejected", args: []string{"myplugin@1.x"}, - wantErr: `invalid version "1.x" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, + wantErr: `invalid version "1.x" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`, }, { name: "range constraint rejected", args: []string{"myplugin@>=1.0.0, <2.0.0"}, - wantErr: `invalid version ">=1.0.0, <2.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, + wantErr: `invalid version ">=1.0.0, <2.0.0" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`, }, { name: "garbage version rejected", args: []string{"myplugin@notaversion"}, - wantErr: `invalid version "notaversion" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, + wantErr: `invalid version "notaversion" for plugin "myplugin": must be an exact semver version (e.g. 1.2.3)`, }, { name: "range rejected among multiple plugins", args: []string{"plugin-a@1.0.0", "plugin-b@~2.0"}, - wantErr: `invalid version "~2.0" for plugin "plugin-b": must be an exact semver version (e.g. 1.2.3); semver range constraints (e.g. ~1.2, ^1.0.0, >=1.0.0) are not supported`, + wantErr: `invalid version "~2.0" for plugin "plugin-b": must be an exact semver version (e.g. 1.2.3)`, }, } for _, tt := range tests {