From cb4c2ce25f50822e394e34740f8cf49d4a5389d1 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 | 10 +-- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index 69a797ad9..df5556b3f 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 7fe627b59..a679bb153 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) } @@ -187,3 +188,61 @@ func TestVCSInstallerUpdate(t *testing.T) { } } + +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 83ef35107..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,7 +72,7 @@ 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 { + 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 915013e5b1d1c193ef8e78594bc9efa8201a2335 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 38751930fd2e6d540f90a2651965da4263e8c90e 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 df5556b3f..8a968cc6d 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 7ccf12925d7ddbc3d8b8f13404c796b4a91784a9 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 cdb4c8fac82e8479f4236f288f6bd16ed71949af 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 ccf02f366e2c46f11adf5bceadaee879f628aed1 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 8a968cc6d..5d2a6169c 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 8441036b077c8e8fd0b45f0933cd9772c205f974 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 757980296e279dbb0edb8308c41e16bb10bace3d 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 b13f5de34d5b551a4d9327828bbdb18c5597d42d 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 a679bb153..e40cb4243 100644 --- a/internal/plugin/installer/vcs_installer_test.go +++ b/internal/plugin/installer/vcs_installer_test.go @@ -220,8 +220,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) } @@ -229,7 +229,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 ffec80f50565f2ef628f536b397aa29918b437a7 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 d097385cf32ed27321b920fc2c3e91cc24bd51ad 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 3ed4d4ff019edd59e54da474211da5836445906b 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 9b2e58322332d6be7de9be8c8c0cba4f5ead3262 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 86e544842f9683a9ee96dc405d92a2e2568ef8e8 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 {