From ae7c8b9e53e705580b9ebb107d0ee8d9954dd2a1 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 1/9] 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 | 135 +++++++++++++++++- pkg/cmd/rollback.go | 9 ++ pkg/cmd/rollback_test.go | 84 +++++++++++ .../output/rollback-with-description.txt | 2 + 5 files changed, 236 insertions(+), 4 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 03150532e..64e36262d 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. @@ -168,6 +170,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, @@ -181,7 +189,7 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele Notes: previousRelease.Info.Notes, // 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 deb6c7c80..9c968997e 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 rollbackAction(t *testing.T) *Rollback { + t.Helper() + config := actionConfigFixture(t) + rollAction := NewRollback(config) + return rollAction +} + func TestNewRollback(t *testing.T) { + is := assert.New(t) config := actionConfigFixture(t) - client := NewRollback(config) - assert.NotNil(t, client) - assert.Equal(t, config, client.cfg) + 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) { @@ -83,3 +95,120 @@ func TestRollback_WaitOptionsPassedDownstream(t *testing.T) { // Verify that WaitOptions were passed to GetWaiter is.NotEmpty(failer.RecordedWaitOptions, "WaitOptions should be passed to GetWaiter") } + +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 00a2725bc..f402ac536 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) @@ -66,6 +69,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 @@ -82,6 +90,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 116e158fd..adcb07284 100644 --- a/pkg/cmd/rollback_test.go +++ b/pkg/cmd/rollback_test.go @@ -19,6 +19,7 @@ package cmd import ( "fmt" "reflect" + "strings" "testing" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -79,6 +80,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) } @@ -125,6 +131,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 17ba1819da1dad067aa9405fe3f27789d69b88ec 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 2/9] 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 f402ac536..f7812343e 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 37ce15730a3629675432281c8a983e6655a9f63f 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 3/9] 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 aad0308e1..939a0d16b 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 9fee687b40f5beb7d0123cbef541331ebaaf92ac 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 4/9] 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 | 6 ++-- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 939a0d16b..ccbada9fe 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 9c968997e..3ad7b6b4e 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" @@ -212,3 +213,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 2c9ca1076..d5965feb4 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) @@ -70,8 +67,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) @@ -90,7 +87,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 adcb07284..bc512bb26 100644 --- a/pkg/cmd/rollback_test.go +++ b/pkg/cmd/rollback_test.go @@ -22,6 +22,7 @@ import ( "strings" "testing" + "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" @@ -198,13 +199,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 ff54bb328f9c509442f87cf7a0f1e5981c926e04 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 5/9] 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 ccbada9fe..bfb5c87a3 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 3ad7b6b4e..6c275a258 100644 --- a/pkg/action/rollback_test.go +++ b/pkg/action/rollback_test.go @@ -276,3 +276,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 d5965feb4..90343afb9 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" @@ -67,8 +68,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 53d3bedd26d38a1a1ea9c627ac934975b78449d2 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 6/9] 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 bfb5c87a3..fc599b81c 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 90343afb9..574bba5c9 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -68,8 +68,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 89ad53ac294210cfc09bdf4ce94a481c75b4f2d0 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 7/9] 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 3e248537ebd7290798fda38eb741e7cb90ae03d5 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 8/9] 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 bc512bb26..e437958e1 100644 --- a/pkg/cmd/rollback_test.go +++ b/pkg/cmd/rollback_test.go @@ -84,7 +84,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 e8b66f40275e1fa4508413846af1295806ae92b1 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 9/9] 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 fc599b81c..8416ae35e 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