From f8180e6de473bb4b1008b76aa2ac63b8bcd104c0 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:06:31 +0100 Subject: [PATCH 01/14] feat(rollback): add --description flag to provide rollback reason Add a new --description flag to the helm rollback command that allows users to specify a custom description explaining why the rollback was performed. This description is stored in the release metadata. Changes: - Add Description field to the Rollback action struct - Add --description flag to the rollback CLI command - Add 512 character limit validation for the description - Default to 'Rollback to ' when no description is provided The description flag is optional and follows the same pattern used by the install and upgrade commands. Closes #XXXX Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/action/rollback.go | 10 +- pkg/action/rollback_test.go | 137 +++++++++++++++++- pkg/cmd/rollback.go | 9 ++ pkg/cmd/rollback_test.go | 84 +++++++++++ .../output/rollback-with-description.txt | 2 + 5 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 pkg/cmd/testdata/output/rollback-with-description.txt diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 6fc449c30..a4eb09643 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -59,6 +59,8 @@ type Rollback struct { ServerSideApply string CleanupOnFail bool MaxHistory int // MaxHistory limits the maximum number of revisions saved per release + // Description is the description of this rollback operation + Description string } // NewRollback creates a new Rollback object with the given configuration. @@ -169,6 +171,12 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele return nil, nil, false, err } + // Determine the description for this rollback + description := fmt.Sprintf("Rollback to %d", previousVersion) + if r.Description != "" { + description = r.Description + } + // Store a new release object with previous release's configuration targetRelease := &release.Release{ Name: name, @@ -183,7 +191,7 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele RollbackRevision: previousVersion, // Because we lose the reference to previous version elsewhere, we set the // message here, and only override it later if we experience failure. - Description: fmt.Sprintf("Rollback to %d", previousVersion), + Description: description, }, Version: currentRelease.Version + 1, Labels: previousRelease.Labels, diff --git a/pkg/action/rollback_test.go b/pkg/action/rollback_test.go index 7ffd90d25..f85d1a690 100644 --- a/pkg/action/rollback_test.go +++ b/pkg/action/rollback_test.go @@ -27,14 +27,26 @@ import ( "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" + "helm.sh/helm/v4/pkg/release/common" ) -func TestNewRollback(t *testing.T) { +func rollbackAction(t *testing.T) *Rollback { + t.Helper() config := actionConfigFixture(t) - client := NewRollback(config) + rollAction := NewRollback(config) + return rollAction +} - assert.NotNil(t, client) - assert.Equal(t, config, client.cfg) +func TestNewRollback(t *testing.T) { + is := assert.New(t) + config := actionConfigFixture(t) + + rollback := NewRollback(config) + + is.NotNil(rollback) + is.Equal(config, rollback.cfg) + is.Equal(DryRunNone, rollback.DryRunStrategy) + is.Empty(rollback.Description) } func TestRollbackRun_UnreachableKubeClient(t *testing.T) { @@ -131,3 +143,120 @@ func TestRollbackRevisionZeroForNonRollback(t *testing.T) { assert.Equal(t, 0, r.Info.RollbackRevision) } + +func TestRollback_WithDescription(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + rollAction := rollbackAction(t) + + // Create two releases - version 1 (superseded) and version 2 (deployed) + rel1 := releaseStub() + rel1.Name = "test-release" + rel1.Version = 1 + rel1.Info.Status = common.StatusSuperseded + rel1.ApplyMethod = "csa" // client-side apply + req.NoError(rollAction.cfg.Releases.Create(rel1)) + + rel2 := releaseStub() + rel2.Name = "test-release" + rel2.Version = 2 + rel2.Info.Status = common.StatusDeployed + rel2.ApplyMethod = "csa" // client-side apply + req.NoError(rollAction.cfg.Releases.Create(rel2)) + + // Set custom description + customDescription := "Rollback due to critical bug in version 2" + rollAction.Description = customDescription + rollAction.Version = 1 + rollAction.ServerSideApply = "false" // Disable server-side apply for testing + + err := rollAction.Run("test-release") + req.NoError(err) + + // Get the new release (version 3) + newReleasei, err := rollAction.cfg.Releases.Get("test-release", 3) + req.NoError(err) + newRelease, err := releaserToV1Release(newReleasei) + req.NoError(err) + + // Verify the custom description was set + is.Equal(customDescription, newRelease.Info.Description) +} + +func TestRollback_DefaultDescription(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + rollAction := rollbackAction(t) + + // Create two releases - version 1 (superseded) and version 2 (deployed) + rel1 := releaseStub() + rel1.Name = "test-release-default" + rel1.Version = 1 + rel1.Info.Status = common.StatusSuperseded + rel1.ApplyMethod = "csa" // client-side apply + req.NoError(rollAction.cfg.Releases.Create(rel1)) + + rel2 := releaseStub() + rel2.Name = "test-release-default" + rel2.Version = 2 + rel2.Info.Status = common.StatusDeployed + rel2.ApplyMethod = "csa" // client-side apply + req.NoError(rollAction.cfg.Releases.Create(rel2)) + + // Don't set a description, rely on default + rollAction.Version = 1 + rollAction.ServerSideApply = "false" // Disable server-side apply for testing + + err := rollAction.Run("test-release-default") + req.NoError(err) + + // Get the new release (version 3) + newReleasei, err := rollAction.cfg.Releases.Get("test-release-default", 3) + req.NoError(err) + newRelease, err := releaserToV1Release(newReleasei) + req.NoError(err) + + // Verify the default description was set + is.Equal("Rollback to 1", newRelease.Info.Description) +} + +func TestRollback_EmptyDescription(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + rollAction := rollbackAction(t) + + // Create two releases - version 1 (superseded) and version 2 (deployed) + rel1 := releaseStub() + rel1.Name = "test-release-empty" + rel1.Version = 1 + rel1.Info.Status = common.StatusSuperseded + rel1.ApplyMethod = "csa" // client-side apply + req.NoError(rollAction.cfg.Releases.Create(rel1)) + + rel2 := releaseStub() + rel2.Name = "test-release-empty" + rel2.Version = 2 + rel2.Info.Status = common.StatusDeployed + rel2.ApplyMethod = "csa" // client-side apply + req.NoError(rollAction.cfg.Releases.Create(rel2)) + + // Set empty description (should use default) + rollAction.Description = "" + rollAction.Version = 1 + rollAction.ServerSideApply = "false" // Disable server-side apply for testing + + err := rollAction.Run("test-release-empty") + req.NoError(err) + + // Get the new release (version 3) + newReleasei, err := rollAction.cfg.Releases.Get("test-release-empty", 3) + req.NoError(err) + newRelease, err := releaserToV1Release(newReleasei) + req.NoError(err) + + // Verify the default description was used for empty string + is.Equal("Rollback to 1", newRelease.Info.Description) +} diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index b716dae10..fb2b80c5c 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -38,6 +38,9 @@ second is a revision (version) number. If this argument is omitted or set to To see revision numbers, run 'helm history RELEASE'. ` +// maxDescriptionLength is the maximum length allowed for a rollback description +const maxDescriptionLength = 512 + func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewRollback(cfg) client.WaitOptions = append(client.WaitOptions, defaultCLIWaitOptions()...) @@ -67,6 +70,11 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client.Version = ver } + // Validate description length + if len(client.Description) > maxDescriptionLength { + return fmt.Errorf("description must be %d characters or less, got %d", maxDescriptionLength, len(client.Description)) + } + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) if err != nil { return err @@ -83,6 +91,7 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } f := cmd.Flags() + f.StringVar(&client.Description, "description", "", fmt.Sprintf("add a custom description for the rollback (max %d characters)", maxDescriptionLength)) f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") diff --git a/pkg/cmd/rollback_test.go b/pkg/cmd/rollback_test.go index 21d6a65af..f607f57b1 100644 --- a/pkg/cmd/rollback_test.go +++ b/pkg/cmd/rollback_test.go @@ -18,6 +18,7 @@ package cmd import ( "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -81,6 +82,11 @@ func TestRollbackCmd(t *testing.T) { golden: "output/rollback-no-args.txt", rels: rels, wantError: true, + }, { + name: "rollback a release with description", + cmd: "rollback funny-honey 1 --description 'Reverting due to bug in version 2'", + golden: "output/rollback-with-description.txt", + rels: rels, }} runTestCmd(t, tests) } @@ -127,6 +133,84 @@ func TestRollbackFileCompletion(t *testing.T) { checkFileCompletion(t, "rollback myrelease 1", false) } +func TestRollbackWithDescription(t *testing.T) { + releaseName := "funny-bunny-desc" + rels := []*release.Release{ + { + Name: releaseName, + Info: &release.Info{Status: common.StatusSuperseded}, + Chart: &chart.Chart{}, + Version: 1, + }, + { + Name: releaseName, + Info: &release.Info{Status: common.StatusDeployed}, + Chart: &chart.Chart{}, + Version: 2, + }, + } + storage := storageFixture() + for _, rel := range rels { + if err := storage.Create(rel); err != nil { + t.Fatal(err) + } + } + + customDescription := "Rollback due to critical bug in version 2" + _, _, err := executeActionCommandC(storage, fmt.Sprintf("rollback %s 1 --description '%s'", releaseName, customDescription)) + if err != nil { + t.Fatalf("unexpected error, got '%v'", err) + } + + // Verify the description was stored correctly + updatedReli, err := storage.Get(releaseName, 3) + if err != nil { + t.Fatalf("unexpected error getting release, got '%v'", err) + } + updatedRel, err := releaserToV1Release(updatedReli) + if err != nil { + t.Fatalf("unexpected error converting release, got '%v'", err) + } + + if updatedRel.Info.Description != customDescription { + t.Errorf("Expected description '%s', got '%s'", customDescription, updatedRel.Info.Description) + } +} + +func TestRollbackDescriptionTooLong(t *testing.T) { + releaseName := "funny-bunny-long-desc" + rels := []*release.Release{ + { + Name: releaseName, + Info: &release.Info{Status: common.StatusSuperseded}, + Chart: &chart.Chart{}, + Version: 1, + }, + { + Name: releaseName, + Info: &release.Info{Status: common.StatusDeployed}, + Chart: &chart.Chart{}, + Version: 2, + }, + } + storage := storageFixture() + for _, rel := range rels { + if err := storage.Create(rel); err != nil { + t.Fatal(err) + } + } + + // Create a description that exceeds the 512 character limit + longDescription := strings.Repeat("a", 513) + _, _, err := executeActionCommandC(storage, fmt.Sprintf("rollback %s 1 --description '%s'", releaseName, longDescription)) + if err == nil { + t.Error("expected error for description exceeding max length, got success") + } + if err != nil && !strings.Contains(err.Error(), "description must be 512 characters or less") { + t.Errorf("expected error about description length, got: %v", err) + } +} + func TestRollbackWithLabels(t *testing.T) { labels1 := map[string]string{"operation": "install", "firstLabel": "firstValue"} labels2 := map[string]string{"operation": "upgrade", "secondLabel": "secondValue"} diff --git a/pkg/cmd/testdata/output/rollback-with-description.txt b/pkg/cmd/testdata/output/rollback-with-description.txt new file mode 100644 index 000000000..a034dd2df --- /dev/null +++ b/pkg/cmd/testdata/output/rollback-with-description.txt @@ -0,0 +1,2 @@ +Rollback was a success! Happy Helming! + From b606955f095408270914c47eac8414b9061bfb49 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:50:56 +0100 Subject: [PATCH 02/14] Set 256 as limit for maxDescriptionLength. 256 comes from MAX value for key and value Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/rollback.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index fb2b80c5c..35257a779 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -39,7 +39,7 @@ To see revision numbers, run 'helm history RELEASE'. ` // maxDescriptionLength is the maximum length allowed for a rollback description -const maxDescriptionLength = 512 +const maxDescriptionLength = 256 func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewRollback(cfg) From e69e424ab1917f0d53eafe63c408b5280b39624a Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:17:34 +0100 Subject: [PATCH 03/14] Changed if construction Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/action/rollback.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index a4eb09643..5f4c5a8a9 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -172,9 +172,9 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele } // Determine the description for this rollback - description := fmt.Sprintf("Rollback to %d", previousVersion) - if r.Description != "" { - description = r.Description + description := r.Description + if description == "" { + description = fmt.Sprintf("Rollback to %d", previousVersion) } // Store a new release object with previous release's configuration From cb91040c447723a7338a513a6d1604e5daaa1e0b Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:40:44 +0100 Subject: [PATCH 04/14] Enhanced rollback description length Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/action/rollback.go | 6 ++++ pkg/action/rollback_test.go | 64 +++++++++++++++++++++++++++++++++++++ pkg/cmd/rollback.go | 9 ++---- pkg/cmd/rollback_test.go | 5 ++- 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 5f4c5a8a9..93a7ab194 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -31,6 +31,8 @@ import ( "helm.sh/helm/v4/pkg/storage/driver" ) +const MaxDescriptionLength = 256 + // Rollback is the action for rolling back to a given release. // // It provides the implementation of 'helm rollback'. @@ -118,6 +120,10 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele return nil, nil, false, errInvalidRevision } + if len(r.Description) > MaxDescriptionLength { + return nil, nil, false, fmt.Errorf("description must be %d characters or less, got %d", MaxDescriptionLength, len(r.Description)) + } + currentReleasei, err := r.cfg.Releases.Last(name) if err != nil { return nil, nil, false, err diff --git a/pkg/action/rollback_test.go b/pkg/action/rollback_test.go index f85d1a690..1d483a17e 100644 --- a/pkg/action/rollback_test.go +++ b/pkg/action/rollback_test.go @@ -20,6 +20,7 @@ import ( "context" "errors" "io" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -260,3 +261,66 @@ func TestRollback_EmptyDescription(t *testing.T) { // Verify the default description was used for empty string is.Equal("Rollback to 1", newRelease.Info.Description) } + +func TestRollback_DescriptionTooLong(t *testing.T) { + req := require.New(t) + + rollAction := rollbackAction(t) + + rel1 := releaseStub() + rel1.Name = "test-release-desc-long" + rel1.Version = 1 + rel1.Info.Status = common.StatusSuperseded + rel1.ApplyMethod = "csa" + req.NoError(rollAction.cfg.Releases.Create(rel1)) + + rel2 := releaseStub() + rel2.Name = "test-release-desc-long" + rel2.Version = 2 + rel2.Info.Status = common.StatusDeployed + rel2.ApplyMethod = "csa" + req.NoError(rollAction.cfg.Releases.Create(rel2)) + + rollAction.Description = strings.Repeat("a", MaxDescriptionLength+1) + rollAction.Version = 1 + rollAction.ServerSideApply = "false" + + err := rollAction.Run("test-release-desc-long") + req.Error(err) + req.Contains(err.Error(), "description must be") +} + +func TestRollback_DescriptionAtMaxLength(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + rollAction := rollbackAction(t) + + rel1 := releaseStub() + rel1.Name = "test-release-desc-max" + rel1.Version = 1 + rel1.Info.Status = common.StatusSuperseded + rel1.ApplyMethod = "csa" + req.NoError(rollAction.cfg.Releases.Create(rel1)) + + rel2 := releaseStub() + rel2.Name = "test-release-desc-max" + rel2.Version = 2 + rel2.Info.Status = common.StatusDeployed + rel2.ApplyMethod = "csa" + req.NoError(rollAction.cfg.Releases.Create(rel2)) + + rollAction.Description = strings.Repeat("a", MaxDescriptionLength) + rollAction.Version = 1 + rollAction.ServerSideApply = "false" + + err := rollAction.Run("test-release-desc-max") + req.NoError(err) + + newReleasei, err := rollAction.cfg.Releases.Get("test-release-desc-max", 3) + req.NoError(err) + newRelease, err := releaserToV1Release(newReleasei) + req.NoError(err) + + is.Equal(strings.Repeat("a", MaxDescriptionLength), newRelease.Info.Description) +} diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index 35257a779..1fbd04fc8 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -38,9 +38,6 @@ second is a revision (version) number. If this argument is omitted or set to To see revision numbers, run 'helm history RELEASE'. ` -// maxDescriptionLength is the maximum length allowed for a rollback description -const maxDescriptionLength = 256 - func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewRollback(cfg) client.WaitOptions = append(client.WaitOptions, defaultCLIWaitOptions()...) @@ -71,8 +68,8 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } // Validate description length - if len(client.Description) > maxDescriptionLength { - return fmt.Errorf("description must be %d characters or less, got %d", maxDescriptionLength, len(client.Description)) + if len(client.Description) > action.MaxDescriptionLength { + return fmt.Errorf("description must be %d characters or less, got %d", action.MaxDescriptionLength, len(client.Description)) } dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) @@ -91,7 +88,7 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } f := cmd.Flags() - f.StringVar(&client.Description, "description", "", fmt.Sprintf("add a custom description for the rollback (max %d characters)", maxDescriptionLength)) + f.StringVar(&client.Description, "description", "", fmt.Sprintf("add a custom description for the rollback (max %d characters)", action.MaxDescriptionLength)) f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") diff --git a/pkg/cmd/rollback_test.go b/pkg/cmd/rollback_test.go index f607f57b1..44119d9e7 100644 --- a/pkg/cmd/rollback_test.go +++ b/pkg/cmd/rollback_test.go @@ -200,13 +200,12 @@ func TestRollbackDescriptionTooLong(t *testing.T) { } } - // Create a description that exceeds the 512 character limit - longDescription := strings.Repeat("a", 513) + longDescription := strings.Repeat("a", action.MaxDescriptionLength+1) _, _, err := executeActionCommandC(storage, fmt.Sprintf("rollback %s 1 --description '%s'", releaseName, longDescription)) if err == nil { t.Error("expected error for description exceeding max length, got success") } - if err != nil && !strings.Contains(err.Error(), "description must be 512 characters or less") { + if err != nil && !strings.Contains(err.Error(), fmt.Sprintf("description must be %d characters or less", action.MaxDescriptionLength)) { t.Errorf("expected error about description length, got: %v", err) } } From 8a34f3b7b4d9ffa4e82c7893eb3d8572fd464198 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:45:48 +0100 Subject: [PATCH 05/14] Use rune count to count properly multi-byte UTF-8 characters Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/action/rollback.go | 5 +++-- pkg/action/rollback_test.go | 36 ++++++++++++++++++++++++++++++++++++ pkg/cmd/rollback.go | 5 +++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 93a7ab194..497842c3c 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "time" + "unicode/utf8" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -120,8 +121,8 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele return nil, nil, false, errInvalidRevision } - if len(r.Description) > MaxDescriptionLength { - return nil, nil, false, fmt.Errorf("description must be %d characters or less, got %d", MaxDescriptionLength, len(r.Description)) + if utf8.RuneCountInString(r.Description) > MaxDescriptionLength { + return nil, nil, false, fmt.Errorf("description must be %d characters or less, got %d", MaxDescriptionLength, utf8.RuneCountInString(r.Description)) } currentReleasei, err := r.cfg.Releases.Last(name) diff --git a/pkg/action/rollback_test.go b/pkg/action/rollback_test.go index 1d483a17e..af7fe6107 100644 --- a/pkg/action/rollback_test.go +++ b/pkg/action/rollback_test.go @@ -324,3 +324,39 @@ func TestRollback_DescriptionAtMaxLength(t *testing.T) { is.Equal(strings.Repeat("a", MaxDescriptionLength), newRelease.Info.Description) } + +func TestRollback_DescriptionMultiByteCharacters(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + rollAction := rollbackAction(t) + + rel1 := releaseStub() + rel1.Name = "test-release-desc-utf8" + rel1.Version = 1 + rel1.Info.Status = common.StatusSuperseded + rel1.ApplyMethod = "csa" + req.NoError(rollAction.cfg.Releases.Create(rel1)) + + rel2 := releaseStub() + rel2.Name = "test-release-desc-utf8" + rel2.Version = 2 + rel2.Info.Status = common.StatusDeployed + rel2.ApplyMethod = "csa" + req.NoError(rollAction.cfg.Releases.Create(rel2)) + + // "é" is 2 bytes in UTF-8 but 1 rune + rollAction.Description = strings.Repeat("é", MaxDescriptionLength) + rollAction.Version = 1 + rollAction.ServerSideApply = "false" + + err := rollAction.Run("test-release-desc-utf8") + req.NoError(err) + + newReleasei, err := rollAction.cfg.Releases.Get("test-release-desc-utf8", 3) + req.NoError(err) + newRelease, err := releaserToV1Release(newReleasei) + req.NoError(err) + + is.Equal(strings.Repeat("é", MaxDescriptionLength), newRelease.Info.Description) +} diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index 1fbd04fc8..bad1e3068 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -21,6 +21,7 @@ import ( "io" "strconv" "time" + "unicode/utf8" "github.com/spf13/cobra" @@ -68,8 +69,8 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } // Validate description length - if len(client.Description) > action.MaxDescriptionLength { - return fmt.Errorf("description must be %d characters or less, got %d", action.MaxDescriptionLength, len(client.Description)) + if utf8.RuneCountInString(client.Description) > action.MaxDescriptionLength { + return fmt.Errorf("description must be %d characters or less, got %d", action.MaxDescriptionLength, utf8.RuneCountInString(client.Description)) } dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) From a92611c20241be8e7dc32f9b229afd2a722a06f8 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:07:13 +0100 Subject: [PATCH 06/14] Enhance rune count in pkg/cmd/rollback.go and pkg/action/rollback.go Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/action/rollback.go | 4 ++-- pkg/cmd/rollback.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 497842c3c..9a5526e94 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -121,8 +121,8 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele return nil, nil, false, errInvalidRevision } - if utf8.RuneCountInString(r.Description) > MaxDescriptionLength { - return nil, nil, false, fmt.Errorf("description must be %d characters or less, got %d", MaxDescriptionLength, utf8.RuneCountInString(r.Description)) + if descLen := utf8.RuneCountInString(r.Description); descLen > MaxDescriptionLength { + return nil, nil, false, fmt.Errorf("description must be %d characters or less, got %d", MaxDescriptionLength, descLen) } currentReleasei, err := r.cfg.Releases.Last(name) diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index bad1e3068..62d0be4af 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -69,8 +69,8 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } // Validate description length - if utf8.RuneCountInString(client.Description) > action.MaxDescriptionLength { - return fmt.Errorf("description must be %d characters or less, got %d", action.MaxDescriptionLength, utf8.RuneCountInString(client.Description)) + if descLen := utf8.RuneCountInString(client.Description); descLen > action.MaxDescriptionLength { + return fmt.Errorf("description must be %d characters or less, got %d", action.MaxDescriptionLength, descLen) } dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) From b177a8e9060720e91c402cdba2224c6d58789fa3 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:45:01 +0100 Subject: [PATCH 07/14] Removed extra space in rollback-with-description.txt Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/testdata/output/rollback-with-description.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/testdata/output/rollback-with-description.txt b/pkg/cmd/testdata/output/rollback-with-description.txt index a034dd2df..ae3c6f1c4 100644 --- a/pkg/cmd/testdata/output/rollback-with-description.txt +++ b/pkg/cmd/testdata/output/rollback-with-description.txt @@ -1,2 +1 @@ Rollback was a success! Happy Helming! - From e73dfab5fb47c9f3a0f24cb3f3a7a4aa8b65143e Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:29:30 +0100 Subject: [PATCH 08/14] refactor(test): reuse existing rollback.txt golden file Remove duplicate rollback-with-description.txt fixture that had identical content to rollback.txt. Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/rollback_test.go | 2 +- pkg/cmd/testdata/output/rollback-with-description.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 pkg/cmd/testdata/output/rollback-with-description.txt diff --git a/pkg/cmd/rollback_test.go b/pkg/cmd/rollback_test.go index 44119d9e7..2e523a5f6 100644 --- a/pkg/cmd/rollback_test.go +++ b/pkg/cmd/rollback_test.go @@ -85,7 +85,7 @@ func TestRollbackCmd(t *testing.T) { }, { name: "rollback a release with description", cmd: "rollback funny-honey 1 --description 'Reverting due to bug in version 2'", - golden: "output/rollback-with-description.txt", + golden: "output/rollback.txt", rels: rels, }} runTestCmd(t, tests) diff --git a/pkg/cmd/testdata/output/rollback-with-description.txt b/pkg/cmd/testdata/output/rollback-with-description.txt deleted file mode 100644 index ae3c6f1c4..000000000 --- a/pkg/cmd/testdata/output/rollback-with-description.txt +++ /dev/null @@ -1 +0,0 @@ -Rollback was a success! Happy Helming! From 91520a78063582ab8265d9364815665f688deebc Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:32:41 +0100 Subject: [PATCH 09/14] refactor(rollback): validate description length before cluster reachability check Move description length validation to the beginning of Run(), before IsReachable(), so programmatic callers get an immediate validation error instead of a potentially misleading cluster reachability failure. Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/action/rollback.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 9a5526e94..52fdfbbf8 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -77,6 +77,10 @@ func NewRollback(cfg *Configuration) *Rollback { // Run executes 'helm rollback' against the given release. func (r *Rollback) Run(name string) error { + if descLen := utf8.RuneCountInString(r.Description); descLen > MaxDescriptionLength { + return fmt.Errorf("description must be %d characters or less, got %d", MaxDescriptionLength, descLen) + } + if err := r.cfg.KubeClient.IsReachable(); err != nil { return err } @@ -121,10 +125,6 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele return nil, nil, false, errInvalidRevision } - if descLen := utf8.RuneCountInString(r.Description); descLen > MaxDescriptionLength { - return nil, nil, false, fmt.Errorf("description must be %d characters or less, got %d", MaxDescriptionLength, descLen) - } - currentReleasei, err := r.cfg.Releases.Last(name) if err != nil { return nil, nil, false, err From 4fcc4e4b3db363654f3dd5240211b195e309970e Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 22 Jun 2026 08:25:49 +0200 Subject: [PATCH 10/14] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/action/rollback.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 52fdfbbf8..b24b4a83d 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -32,6 +32,8 @@ import ( "helm.sh/helm/v4/pkg/storage/driver" ) +// MaxDescriptionLength is the maximum length allowed for a rollback description, +// including values provided via the --description flag and Rollback.Description. const MaxDescriptionLength = 256 // Rollback is the action for rolling back to a given release. From f1ede7c9c114c745da2f5c6cef8d27e1eb342498 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:58:37 +0200 Subject: [PATCH 11/14] Potential fix for pull request finding If the CLI-side description validation is removed, the unicode/utf8 import becomes unused and should also be dropped. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/rollback.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index 62d0be4af..12f4b6b3c 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -21,7 +21,6 @@ import ( "io" "strconv" "time" - "unicode/utf8" "github.com/spf13/cobra" From 500f02d597628d8ca7be8b530edcbedbf397b676 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:22:55 +0200 Subject: [PATCH 12/14] Readded "unicode/utf8" Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/rollback.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index 12f4b6b3c..62d0be4af 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -21,6 +21,7 @@ import ( "io" "strconv" "time" + "unicode/utf8" "github.com/spf13/cobra" From 94a3315f4d699c6bb4140550d4c98b46bb4382d2 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:41:29 +0200 Subject: [PATCH 13/14] Added missing import Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/rollback_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/rollback_test.go b/pkg/cmd/rollback_test.go index 2e523a5f6..00b314a4e 100644 --- a/pkg/cmd/rollback_test.go +++ b/pkg/cmd/rollback_test.go @@ -23,6 +23,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "helm.sh/helm/v4/pkg/action" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/release/common" From f3edb83df48c505878ffd3ebb89ef6a809e14008 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:46:55 +0200 Subject: [PATCH 14/14] Make golangci-lint happy Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/rollback_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/rollback_test.go b/pkg/cmd/rollback_test.go index 00b314a4e..1a617b228 100644 --- a/pkg/cmd/rollback_test.go +++ b/pkg/cmd/rollback_test.go @@ -23,8 +23,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/action" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1"