From 300f71b1ebb1b383241a1806a813c6f30a8ff382 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:28:07 +0100 Subject: [PATCH 1/6] feat(history): add rollback revision column to helm history output Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- internal/release/v2/info.go | 27 +++-- pkg/action/rollback.go | 9 +- pkg/action/rollback_test.go | 48 +++++++++ pkg/cmd/history.go | 67 ++++++------ pkg/cmd/history_test.go | 120 ++++++++++++++++++++++ pkg/cmd/testdata/output/history-limit.txt | 6 +- pkg/cmd/testdata/output/history.txt | 10 +- pkg/release/v1/info.go | 27 +++-- 8 files changed, 251 insertions(+), 63 deletions(-) diff --git a/internal/release/v2/info.go b/internal/release/v2/info.go index 038f19409..04a9580fe 100644 --- a/internal/release/v2/info.go +++ b/internal/release/v2/info.go @@ -36,6 +36,8 @@ type Info struct { Description string `json:"description,omitempty"` // Status is the current state of the release Status common.Status `json:"status,omitempty"` + // RollbackRevision is the revision that was rolled back to. Zero means not a rollback. + RollbackRevision int `json:"rollback_revision,omitempty"` // Contains the rendered templates/NOTES.txt if available Notes string `json:"notes,omitempty"` // Contains the deployed resources information @@ -44,13 +46,14 @@ type Info struct { // infoJSON is used for custom JSON marshaling/unmarshaling type infoJSON struct { - FirstDeployed *time.Time `json:"first_deployed,omitempty"` - LastDeployed *time.Time `json:"last_deployed,omitempty"` - Deleted *time.Time `json:"deleted,omitempty"` - Description string `json:"description,omitempty"` - Status common.Status `json:"status,omitempty"` - Notes string `json:"notes,omitempty"` - Resources map[string][]runtime.Object `json:"resources,omitempty"` + FirstDeployed *time.Time `json:"first_deployed,omitempty"` + LastDeployed *time.Time `json:"last_deployed,omitempty"` + Deleted *time.Time `json:"deleted,omitempty"` + Description string `json:"description,omitempty"` + Status common.Status `json:"status,omitempty"` + RollbackRevision int `json:"rollback_revision,omitempty"` + Notes string `json:"notes,omitempty"` + Resources map[string][]runtime.Object `json:"resources,omitempty"` } // UnmarshalJSON implements the json.Unmarshaler interface. @@ -95,6 +98,7 @@ func (i *Info) UnmarshalJSON(data []byte) error { } i.Description = tmp.Description i.Status = tmp.Status + i.RollbackRevision = tmp.RollbackRevision i.Notes = tmp.Notes i.Resources = tmp.Resources @@ -105,10 +109,11 @@ func (i *Info) UnmarshalJSON(data []byte) error { // It omits zero-value time fields from the JSON output. func (i Info) MarshalJSON() ([]byte, error) { tmp := infoJSON{ - Description: i.Description, - Status: i.Status, - Notes: i.Notes, - Resources: i.Resources, + Description: i.Description, + Status: i.Status, + RollbackRevision: i.RollbackRevision, + Notes: i.Notes, + Resources: i.Resources, } if !i.FirstDeployed.IsZero() { diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 459569781..6fc449c30 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -176,10 +176,11 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele Chart: previousRelease.Chart, Config: previousRelease.Config, Info: &release.Info{ - FirstDeployed: currentRelease.Info.FirstDeployed, - LastDeployed: time.Now(), - Status: common.StatusPendingRollback, - Notes: previousRelease.Info.Notes, + FirstDeployed: currentRelease.Info.FirstDeployed, + LastDeployed: time.Now(), + Status: common.StatusPendingRollback, + Notes: previousRelease.Info.Notes, + 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), diff --git a/pkg/action/rollback_test.go b/pkg/action/rollback_test.go index deb6c7c80..b34adda91 100644 --- a/pkg/action/rollback_test.go +++ b/pkg/action/rollback_test.go @@ -83,3 +83,51 @@ func TestRollback_WaitOptionsPassedDownstream(t *testing.T) { // Verify that WaitOptions were passed to GetWaiter is.NotEmpty(failer.RecordedWaitOptions, "WaitOptions should be passed to GetWaiter") } + +func TestRollbackSetsRollbackRevision(t *testing.T) { + config := actionConfigFixture(t) + + rel1 := releaseStub() + rel1.Name = "rollback-rev-test" + rel1.Version = 1 + rel1.Info.Status = "superseded" + rel1.ApplyMethod = "csa" + require.NoError(t, config.Releases.Create(rel1)) + + rel2 := releaseStub() + rel2.Name = "rollback-rev-test" + rel2.Version = 2 + rel2.Info.Status = "deployed" + rel2.ApplyMethod = "csa" + require.NoError(t, config.Releases.Create(rel2)) + + client := NewRollback(config) + client.Version = 1 + client.ServerSideApply = "auto" + + require.NoError(t, client.Run("rollback-rev-test")) + + reli, err := config.Releases.Get("rollback-rev-test", 3) + require.NoError(t, err) + rel, err := releaserToV1Release(reli) + require.NoError(t, err) + + assert.Equal(t, 1, rel.Info.RollbackRevision) + assert.Equal(t, "Rollback to 1", rel.Info.Description) +} + +func TestRollbackRevisionZeroForNonRollback(t *testing.T) { + config := actionConfigFixture(t) + + rel := releaseStub() + rel.Name = "non-rollback" + rel.Info.Status = "deployed" + require.NoError(t, config.Releases.Create(rel)) + + reli, err := config.Releases.Get("non-rollback", 1) + require.NoError(t, err) + r, err := releaserToV1Release(reli) + require.NoError(t, err) + + assert.Equal(t, 0, r.Info.RollbackRevision) +} diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index b294a9da7..eb82da684 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -43,11 +43,11 @@ configures the maximum length of the revision list returned. The historical release set is printed as a formatted table, e.g: $ helm history angry-bird - REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION - 1 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Initial install - 2 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Upgraded successfully - 3 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Rolled back to 2 - 4 Mon Oct 3 10:15:13 2016 deployed alpine-0.1.0 1.0 Upgraded successfully + REVISION UPDATED STATUS CHART APP VERSION ROLLBACK DESCRIPTION + 1 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Initial install + 2 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Upgraded successfully + 3 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 2 Rolled back to 2 + 4 Mon Oct 3 10:15:13 2016 deployed alpine-0.1.0 1.0 Upgraded successfully ` func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { @@ -84,22 +84,24 @@ func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } type releaseInfo struct { - Revision int `json:"revision"` - Updated time.Time `json:"updated,omitzero"` - Status string `json:"status"` - Chart string `json:"chart"` - AppVersion string `json:"app_version"` - Description string `json:"description"` + Revision int `json:"revision"` + Updated time.Time `json:"updated,omitzero"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` + RollbackRevision int `json:"rollback_revision,omitempty"` + Description string `json:"description"` } // releaseInfoJSON is used for custom JSON marshaling/unmarshaling type releaseInfoJSON struct { - Revision int `json:"revision"` - Updated *time.Time `json:"updated,omitempty"` - Status string `json:"status"` - Chart string `json:"chart"` - AppVersion string `json:"app_version"` - Description string `json:"description"` + Revision int `json:"revision"` + Updated *time.Time `json:"updated,omitempty"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` + RollbackRevision int `json:"rollback_revision,omitempty"` + Description string `json:"description"` } // UnmarshalJSON implements the json.Unmarshaler interface. @@ -138,6 +140,7 @@ func (r *releaseInfo) UnmarshalJSON(data []byte) error { r.Status = tmp.Status r.Chart = tmp.Chart r.AppVersion = tmp.AppVersion + r.RollbackRevision = tmp.RollbackRevision r.Description = tmp.Description return nil @@ -147,11 +150,12 @@ func (r *releaseInfo) UnmarshalJSON(data []byte) error { // It omits zero-value time fields from the JSON output. func (r releaseInfo) MarshalJSON() ([]byte, error) { tmp := releaseInfoJSON{ - Revision: r.Revision, - Status: r.Status, - Chart: r.Chart, - AppVersion: r.AppVersion, - Description: r.Description, + Revision: r.Revision, + Status: r.Status, + Chart: r.Chart, + AppVersion: r.AppVersion, + RollbackRevision: r.RollbackRevision, + Description: r.Description, } if !r.Updated.IsZero() { @@ -173,9 +177,13 @@ func (r releaseHistory) WriteYAML(out io.Writer) error { func (r releaseHistory) WriteTable(out io.Writer) error { tbl := uitable.New() - tbl.AddRow("REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION", "DESCRIPTION") + tbl.AddRow("REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION", "ROLLBACK", "DESCRIPTION") for _, item := range r { - tbl.AddRow(item.Revision, item.Updated.Format(time.ANSIC), item.Status, item.Chart, item.AppVersion, item.Description) + rollback := "" + if item.RollbackRevision > 0 { + rollback = strconv.Itoa(item.RollbackRevision) + } + tbl.AddRow(item.Revision, item.Updated.Format(time.ANSIC), item.Status, item.Chart, item.AppVersion, rollback, item.Description) } return output.EncodeTable(out, tbl) } @@ -216,11 +224,12 @@ func getReleaseHistory(rls []*release.Release) (history releaseHistory) { a := formatAppVersion(r.Chart) rInfo := releaseInfo{ - Revision: v, - Status: s, - Chart: c, - AppVersion: a, - Description: d, + Revision: v, + Status: s, + Chart: c, + AppVersion: a, + RollbackRevision: r.Info.RollbackRevision, + Description: d, } if !r.Info.LastDeployed.IsZero() { rInfo.Updated = r.Info.LastDeployed diff --git a/pkg/cmd/history_test.go b/pkg/cmd/history_test.go index d8adc2d19..86fd47bf1 100644 --- a/pkg/cmd/history_test.go +++ b/pkg/cmd/history_test.go @@ -27,6 +27,8 @@ import ( "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" + + chart "helm.sh/helm/v4/pkg/chart/v2" ) func TestHistoryCmd(t *testing.T) { @@ -76,6 +78,72 @@ func TestHistoryCmd(t *testing.T) { runTestCmd(t, tests) } +func TestHistoryWithRollback(t *testing.T) { + date := time.Unix(242085845, 0).UTC() + ch := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "foo", + Version: "0.1.0-beta.1", + AppVersion: "1.0", + }, + } + + rels := []*release.Release{ + { + Name: "angry-bird", + Version: 1, + Info: &release.Info{ + FirstDeployed: date, + LastDeployed: date, + Status: common.StatusSuperseded, + Description: "Install complete", + }, + Chart: ch, + }, + { + Name: "angry-bird", + Version: 2, + Info: &release.Info{ + FirstDeployed: date, + LastDeployed: date, + Status: common.StatusSuperseded, + Description: "Upgrade complete", + }, + Chart: ch, + }, + { + Name: "angry-bird", + Version: 3, + Info: &release.Info{ + FirstDeployed: date, + LastDeployed: date, + Status: common.StatusDeployed, + RollbackRevision: 1, + Description: "Rollback to 1", + }, + Chart: ch, + }, + } + + tests := []cmdTestCase{{ + name: "history with rollback revision", + cmd: "history angry-bird", + rels: rels, + golden: "output/history-with-rollback.txt", + }, { + name: "history with rollback revision json", + cmd: "history angry-bird --output json", + rels: rels, + golden: "output/history-with-rollback.json", + }, { + name: "history with rollback revision yaml", + cmd: "history angry-bird --output yaml", + rels: rels, + golden: "output/history-with-rollback.yaml", + }} + runTestCmd(t, tests) +} + func TestHistoryOutputCompletion(t *testing.T) { outputFlagCompletionTest(t, "history") } @@ -173,6 +241,31 @@ func TestReleaseInfoMarshalJSON(t *testing.T) { }, expected: `{"revision":0,"updated":"2025-10-08T12:00:00Z","status":"failed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Install failed"}`, }, + { + name: "with rollback revision", + info: releaseInfo{ + Revision: 3, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + RollbackRevision: 1, + Description: "Rollback to 1", + }, + expected: `{"revision":3,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","rollback_revision":1,"description":"Rollback to 1"}`, + }, + { + name: "without rollback revision", + info: releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Initial install", + }, + expected: `{"revision":1,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Initial install"}`, + }, } for _, tt := range tests { @@ -255,6 +348,31 @@ func TestReleaseInfoUnmarshalJSON(t *testing.T) { Description: "Installing", }, }, + { + name: "with rollback revision", + input: `{"revision":3,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","rollback_revision":1,"description":"Rollback to 1"}`, + expected: releaseInfo{ + Revision: 3, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + RollbackRevision: 1, + Description: "Rollback to 1", + }, + }, + { + name: "without rollback revision field", + input: `{"revision":1,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Install"}`, + expected: releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Install", + }, + }, } for _, tt := range tests { @@ -271,6 +389,7 @@ func TestReleaseInfoUnmarshalJSON(t *testing.T) { assert.Equal(t, tt.expected.Status, info.Status) assert.Equal(t, tt.expected.Chart, info.Chart) assert.Equal(t, tt.expected.AppVersion, info.AppVersion) + assert.Equal(t, tt.expected.RollbackRevision, info.RollbackRevision) assert.Equal(t, tt.expected.Description, info.Description) }) } @@ -300,6 +419,7 @@ func TestReleaseInfoRoundTrip(t *testing.T) { assert.Equal(t, original.Status, decoded.Status) assert.Equal(t, original.Chart, decoded.Chart) assert.Equal(t, original.AppVersion, decoded.AppVersion) + assert.Equal(t, original.RollbackRevision, decoded.RollbackRevision) assert.Equal(t, original.Description, decoded.Description) } diff --git a/pkg/cmd/testdata/output/history-limit.txt b/pkg/cmd/testdata/output/history-limit.txt index aee0fadb2..f6f059fb9 100644 --- a/pkg/cmd/testdata/output/history-limit.txt +++ b/pkg/cmd/testdata/output/history-limit.txt @@ -1,3 +1,3 @@ -REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION -3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock -4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock +REVISION UPDATED STATUS CHART APP VERSION ROLLBACK DESCRIPTION +3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock diff --git a/pkg/cmd/testdata/output/history.txt b/pkg/cmd/testdata/output/history.txt index 2a5d69c11..4a51add7f 100644 --- a/pkg/cmd/testdata/output/history.txt +++ b/pkg/cmd/testdata/output/history.txt @@ -1,5 +1,5 @@ -REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION -1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock -2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock -3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock -4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock +REVISION UPDATED STATUS CHART APP VERSION ROLLBACK DESCRIPTION +1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock diff --git a/pkg/release/v1/info.go b/pkg/release/v1/info.go index f895fdf6c..78f9a22fb 100644 --- a/pkg/release/v1/info.go +++ b/pkg/release/v1/info.go @@ -36,6 +36,8 @@ type Info struct { Description string `json:"description,omitempty"` // Status is the current state of the release Status common.Status `json:"status,omitempty"` + // RollbackRevision is the revision that was rolled back to. Zero means not a rollback. + RollbackRevision int `json:"rollback_revision,omitempty"` // Contains the rendered templates/NOTES.txt if available Notes string `json:"notes,omitempty"` // Contains the deployed resources information @@ -44,13 +46,14 @@ type Info struct { // infoJSON is used for custom JSON marshaling/unmarshaling type infoJSON struct { - FirstDeployed *time.Time `json:"first_deployed,omitempty"` - LastDeployed *time.Time `json:"last_deployed,omitempty"` - Deleted *time.Time `json:"deleted,omitempty"` - Description string `json:"description,omitempty"` - Status common.Status `json:"status,omitempty"` - Notes string `json:"notes,omitempty"` - Resources map[string][]runtime.Object `json:"resources,omitempty"` + FirstDeployed *time.Time `json:"first_deployed,omitempty"` + LastDeployed *time.Time `json:"last_deployed,omitempty"` + Deleted *time.Time `json:"deleted,omitempty"` + Description string `json:"description,omitempty"` + Status common.Status `json:"status,omitempty"` + RollbackRevision int `json:"rollback_revision,omitempty"` + Notes string `json:"notes,omitempty"` + Resources map[string][]runtime.Object `json:"resources,omitempty"` } // UnmarshalJSON implements the json.Unmarshaler interface. @@ -95,6 +98,7 @@ func (i *Info) UnmarshalJSON(data []byte) error { } i.Description = tmp.Description i.Status = tmp.Status + i.RollbackRevision = tmp.RollbackRevision i.Notes = tmp.Notes i.Resources = tmp.Resources @@ -105,10 +109,11 @@ func (i *Info) UnmarshalJSON(data []byte) error { // It omits zero-value time fields from the JSON output. func (i Info) MarshalJSON() ([]byte, error) { tmp := infoJSON{ - Description: i.Description, - Status: i.Status, - Notes: i.Notes, - Resources: i.Resources, + Description: i.Description, + Status: i.Status, + RollbackRevision: i.RollbackRevision, + Notes: i.Notes, + Resources: i.Resources, } if !i.FirstDeployed.IsZero() { From d52b489459d51d67d9db877f169bd09273b6cd6c Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:35:22 +0100 Subject: [PATCH 2/6] Added missing pkg/cmd/testdata/output files Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- .../output/history-with-rollback.json | 1 + .../testdata/output/history-with-rollback.txt | 4 ++++ .../output/history-with-rollback.yaml | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 pkg/cmd/testdata/output/history-with-rollback.json create mode 100644 pkg/cmd/testdata/output/history-with-rollback.txt create mode 100644 pkg/cmd/testdata/output/history-with-rollback.yaml diff --git a/pkg/cmd/testdata/output/history-with-rollback.json b/pkg/cmd/testdata/output/history-with-rollback.json new file mode 100644 index 000000000..b4175b062 --- /dev/null +++ b/pkg/cmd/testdata/output/history-with-rollback.json @@ -0,0 +1 @@ +[{"revision":1,"updated":"1977-09-02T22:04:05Z","status":"superseded","chart":"foo-0.1.0-beta.1","app_version":"1.0","description":"Install complete"},{"revision":2,"updated":"1977-09-02T22:04:05Z","status":"superseded","chart":"foo-0.1.0-beta.1","app_version":"1.0","description":"Upgrade complete"},{"revision":3,"updated":"1977-09-02T22:04:05Z","status":"deployed","chart":"foo-0.1.0-beta.1","app_version":"1.0","rollback_revision":1,"description":"Rollback to 1"}] diff --git a/pkg/cmd/testdata/output/history-with-rollback.txt b/pkg/cmd/testdata/output/history-with-rollback.txt new file mode 100644 index 000000000..c6dd62b8a --- /dev/null +++ b/pkg/cmd/testdata/output/history-with-rollback.txt @@ -0,0 +1,4 @@ +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Install complete +2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Upgrade complete +3 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Rollback to 1 diff --git a/pkg/cmd/testdata/output/history-with-rollback.yaml b/pkg/cmd/testdata/output/history-with-rollback.yaml new file mode 100644 index 000000000..19dc681c4 --- /dev/null +++ b/pkg/cmd/testdata/output/history-with-rollback.yaml @@ -0,0 +1,19 @@ +- app_version: "1.0" + chart: foo-0.1.0-beta.1 + description: Install complete + revision: 1 + status: superseded + updated: "1977-09-02T22:04:05Z" +- app_version: "1.0" + chart: foo-0.1.0-beta.1 + description: Upgrade complete + revision: 2 + status: superseded + updated: "1977-09-02T22:04:05Z" +- app_version: "1.0" + chart: foo-0.1.0-beta.1 + description: Rollback to 1 + revision: 3 + rollback_revision: 1 + status: deployed + updated: "1977-09-02T22:04:05Z" From 0737e438aae04fca872b66f185b0f2aab8bd0d86 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:49:05 +0100 Subject: [PATCH 3/6] Update pkg/cmd/history_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/history_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/history_test.go b/pkg/cmd/history_test.go index 1b07b1679..e76a1f63d 100644 --- a/pkg/cmd/history_test.go +++ b/pkg/cmd/history_test.go @@ -27,7 +27,6 @@ import ( "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" - chart "helm.sh/helm/v4/pkg/chart/v2" ) From 681ccb19a68dab98b75e407ba38b37c45ee9c5d1 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:45:55 +0100 Subject: [PATCH 4/6] Update pkg/cmd/testdata/output/history-with-rollback.txt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/testdata/output/history-with-rollback.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/testdata/output/history-with-rollback.txt b/pkg/cmd/testdata/output/history-with-rollback.txt index c6dd62b8a..4fcb957dc 100644 --- a/pkg/cmd/testdata/output/history-with-rollback.txt +++ b/pkg/cmd/testdata/output/history-with-rollback.txt @@ -1,4 +1,4 @@ -REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION -1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Install complete -2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Upgrade complete -3 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Rollback to 1 +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION ROLLBACK +1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Install complete 0 +2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Upgrade complete 0 +3 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Rollback to 1 1 From e889cff0896d842c7e1854729611ac520a925d0b Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:45:04 +0100 Subject: [PATCH 5/6] feat(history): add --show-rollback flag for opt-in rollback column Replace the always-visible ROLLBACK column with an opt-in --show-rollback flag to avoid breaking the default table output (HIP-0004). JSON and YAML formats continue to include rollback_revision when present via omitempty. Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- pkg/cmd/history.go | 34 +++++++++++++++++++ pkg/cmd/history_test.go | 9 +++-- pkg/cmd/testdata/output/history-limit.txt | 6 ++-- .../output/history-with-rollback-no-flag.txt | 4 +++ .../testdata/output/history-with-rollback.txt | 8 ++--- pkg/cmd/testdata/output/history.txt | 10 +++--- 6 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 pkg/cmd/testdata/output/history-with-rollback-no-flag.txt diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index a040cd16a..eb1c88d66 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -43,6 +43,15 @@ configures the maximum length of the revision list returned. The historical release set is printed as a formatted table, e.g: $ helm history angry-bird + REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION + 1 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Initial install + 2 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Upgraded successfully + 3 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Rolled back to 2 + 4 Mon Oct 3 10:15:13 2016 deployed alpine-0.1.0 1.0 Upgraded successfully + +Use '--show-rollback' to include a column showing the revision that was rolled back to: + + $ helm history angry-bird --show-rollback REVISION UPDATED STATUS CHART APP VERSION ROLLBACK DESCRIPTION 1 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Initial install 2 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Upgraded successfully @@ -53,6 +62,7 @@ The historical release set is printed as a formatted table, e.g: func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewHistory(cfg) var outfmt output.Format + var showRollback bool cmd := &cobra.Command{ Use: "history RELEASE_NAME", @@ -72,12 +82,16 @@ func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } + if showRollback { + return outfmt.Write(out, releaseHistoryWithRollback(history)) + } return outfmt.Write(out, history) }, } f := cmd.Flags() f.IntVar(&client.Max, "max", 256, "maximum number of revision to include in history") + f.BoolVar(&showRollback, "show-rollback", false, "show the rollback revision column in the output") bindOutputFlag(cmd, &outfmt) return cmd @@ -176,6 +190,26 @@ func (r releaseHistory) WriteYAML(out io.Writer) error { } func (r releaseHistory) WriteTable(out io.Writer) error { + tbl := uitable.New() + tbl.AddRow("REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION", "DESCRIPTION") + for _, item := range r { + tbl.AddRow(item.Revision, item.Updated.Format(time.ANSIC), item.Status, item.Chart, item.AppVersion, item.Description) + } + return output.EncodeTable(out, tbl) +} + +// releaseHistoryWithRollback wraps releaseHistory to include the rollback column in table output. +type releaseHistoryWithRollback releaseHistory + +func (r releaseHistoryWithRollback) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, releaseHistory(r)) +} + +func (r releaseHistoryWithRollback) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, releaseHistory(r)) +} + +func (r releaseHistoryWithRollback) WriteTable(out io.Writer) error { tbl := uitable.New() tbl.AddRow("REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION", "ROLLBACK", "DESCRIPTION") for _, item := range r { diff --git a/pkg/cmd/history_test.go b/pkg/cmd/history_test.go index e76a1f63d..b25eaba7f 100644 --- a/pkg/cmd/history_test.go +++ b/pkg/cmd/history_test.go @@ -25,9 +25,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" - chart "helm.sh/helm/v4/pkg/chart/v2" ) func TestHistoryCmd(t *testing.T) { @@ -125,9 +125,14 @@ func TestHistoryWithRollback(t *testing.T) { } tests := []cmdTestCase{{ - name: "history with rollback revision", + name: "history with rollback revision (default, no rollback column)", cmd: "history angry-bird", rels: rels, + golden: "output/history-with-rollback-no-flag.txt", + }, { + name: "history with rollback revision and --show-rollback flag", + cmd: "history angry-bird --show-rollback", + rels: rels, golden: "output/history-with-rollback.txt", }, { name: "history with rollback revision json", diff --git a/pkg/cmd/testdata/output/history-limit.txt b/pkg/cmd/testdata/output/history-limit.txt index f6f059fb9..aee0fadb2 100644 --- a/pkg/cmd/testdata/output/history-limit.txt +++ b/pkg/cmd/testdata/output/history-limit.txt @@ -1,3 +1,3 @@ -REVISION UPDATED STATUS CHART APP VERSION ROLLBACK DESCRIPTION -3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock -4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock diff --git a/pkg/cmd/testdata/output/history-with-rollback-no-flag.txt b/pkg/cmd/testdata/output/history-with-rollback-no-flag.txt new file mode 100644 index 000000000..8861b7572 --- /dev/null +++ b/pkg/cmd/testdata/output/history-with-rollback-no-flag.txt @@ -0,0 +1,4 @@ +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Install complete +2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Upgrade complete +3 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Rollback to 1 diff --git a/pkg/cmd/testdata/output/history-with-rollback.txt b/pkg/cmd/testdata/output/history-with-rollback.txt index 4fcb957dc..26dd210af 100644 --- a/pkg/cmd/testdata/output/history-with-rollback.txt +++ b/pkg/cmd/testdata/output/history-with-rollback.txt @@ -1,4 +1,4 @@ -REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION ROLLBACK -1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Install complete 0 -2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Upgrade complete 0 -3 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Rollback to 1 1 +REVISION UPDATED STATUS CHART APP VERSION ROLLBACK DESCRIPTION +1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Install complete +2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Upgrade complete +3 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 1 Rollback to 1 diff --git a/pkg/cmd/testdata/output/history.txt b/pkg/cmd/testdata/output/history.txt index 4a51add7f..2a5d69c11 100644 --- a/pkg/cmd/testdata/output/history.txt +++ b/pkg/cmd/testdata/output/history.txt @@ -1,5 +1,5 @@ -REVISION UPDATED STATUS CHART APP VERSION ROLLBACK DESCRIPTION -1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock -2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock -3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock -4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock From 6927cde3f98d9e5f248418805e40c7408e629746 Mon Sep 17 00:00:00 2001 From: MrJack <36191829+biagiopietro@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:43:27 +0100 Subject: [PATCH 6/6] fix: address Copilot review feedback on rollback revision PR - Clarify --show-rollback flag help text to specify it only affects table output - Add RollbackRevision JSON round-trip tests for pkg/release/v1 and internal/release/v2 - Add omitempty behavior verification for zero rollback_revision Signed-off-by: MrJack <36191829+biagiopietro@users.noreply.github.com> --- internal/release/v2/info_test.go | 98 ++++++++++++++++++++++++++++++++ pkg/cmd/history.go | 2 +- pkg/release/v1/info_test.go | 98 ++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) diff --git a/internal/release/v2/info_test.go b/internal/release/v2/info_test.go index 560861e06..5812c6560 100644 --- a/internal/release/v2/info_test.go +++ b/internal/release/v2/info_test.go @@ -87,6 +87,27 @@ func TestInfoMarshalJSON(t *testing.T) { }, expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Uninstalled release","status":"uninstalled"}`, }, + { + name: "with rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + RollbackRevision: 2, + Description: "Rollback to 2", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","rollback_revision":2,"description":"Rollback to 2"}`, + }, + { + name: "zero rollback revision omitted", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + Description: "Normal install", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","description":"Normal install"}`, + }, } for _, tt := range tests { @@ -203,6 +224,27 @@ func TestInfoUnmarshalJSON(t *testing.T) { Status: "", }, }, + { + name: "with rollback revision", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","rollback_revision":2,"description":"Rollback to 2"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + RollbackRevision: 2, + Description: "Rollback to 2", + }, + }, + { + name: "zero rollback revision omitted", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","description":"Normal install"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + Description: "Normal install", + }, + }, } for _, tt := range tests { @@ -219,6 +261,7 @@ func TestInfoUnmarshalJSON(t *testing.T) { assert.Equal(t, tt.expected.Deleted.Unix(), info.Deleted.Unix()) assert.Equal(t, tt.expected.Description, info.Description) assert.Equal(t, tt.expected.Status, info.Status) + assert.Equal(t, tt.expected.RollbackRevision, info.RollbackRevision) assert.Equal(t, tt.expected.Notes, info.Notes) assert.Equal(t, tt.expected.Resources, info.Resources) }) @@ -252,6 +295,61 @@ func TestInfoRoundTrip(t *testing.T) { assert.Equal(t, original.Notes, decoded.Notes) } +func TestInfoRollbackRevisionRoundTrip(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + + tests := []struct { + name string + info Info + }{ + { + name: "with rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Rollback to 2", + Status: common.StatusDeployed, + RollbackRevision: 2, + }, + }, + { + name: "zero rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Normal install", + Status: common.StatusDeployed, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.info) + require.NoError(t, err) + + var decoded Info + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, tt.info.RollbackRevision, decoded.RollbackRevision) + assert.Equal(t, tt.info.FirstDeployed.Unix(), decoded.FirstDeployed.Unix()) + assert.Equal(t, tt.info.LastDeployed.Unix(), decoded.LastDeployed.Unix()) + assert.Equal(t, tt.info.Status, decoded.Status) + assert.Equal(t, tt.info.Description, decoded.Description) + + // Verify omitempty behavior: zero rollback_revision should not appear in JSON + if tt.info.RollbackRevision == 0 { + var raw map[string]any + err = json.Unmarshal(data, &raw) + require.NoError(t, err) + assert.NotContains(t, raw, "rollback_revision") + } + }) + } +} + func TestInfoEmptyStringRoundTrip(t *testing.T) { // This test specifically verifies that empty string time fields // are handled correctly during parsing diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index eb1c88d66..49de85a06 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -91,7 +91,7 @@ func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() f.IntVar(&client.Max, "max", 256, "maximum number of revision to include in history") - f.BoolVar(&showRollback, "show-rollback", false, "show the rollback revision column in the output") + f.BoolVar(&showRollback, "show-rollback", false, "show the rollback revision column in table output") bindOutputFlag(cmd, &outfmt) return cmd diff --git a/pkg/release/v1/info_test.go b/pkg/release/v1/info_test.go index 6cff4db64..12b3014f6 100644 --- a/pkg/release/v1/info_test.go +++ b/pkg/release/v1/info_test.go @@ -87,6 +87,27 @@ func TestInfoMarshalJSON(t *testing.T) { }, expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Uninstalled release","status":"uninstalled"}`, }, + { + name: "with rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + RollbackRevision: 2, + Description: "Rollback to 2", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","rollback_revision":2,"description":"Rollback to 2"}`, + }, + { + name: "zero rollback revision omitted", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + Description: "Normal install", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","description":"Normal install"}`, + }, } for _, tt := range tests { @@ -203,6 +224,27 @@ func TestInfoUnmarshalJSON(t *testing.T) { Status: "", }, }, + { + name: "with rollback revision", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","rollback_revision":2,"description":"Rollback to 2"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + RollbackRevision: 2, + Description: "Rollback to 2", + }, + }, + { + name: "zero rollback revision omitted", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","description":"Normal install"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + Description: "Normal install", + }, + }, } for _, tt := range tests { @@ -219,6 +261,7 @@ func TestInfoUnmarshalJSON(t *testing.T) { assert.Equal(t, tt.expected.Deleted.Unix(), info.Deleted.Unix()) assert.Equal(t, tt.expected.Description, info.Description) assert.Equal(t, tt.expected.Status, info.Status) + assert.Equal(t, tt.expected.RollbackRevision, info.RollbackRevision) assert.Equal(t, tt.expected.Notes, info.Notes) assert.Equal(t, tt.expected.Resources, info.Resources) }) @@ -252,6 +295,61 @@ func TestInfoRoundTrip(t *testing.T) { assert.Equal(t, original.Notes, decoded.Notes) } +func TestInfoRollbackRevisionRoundTrip(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + + tests := []struct { + name string + info Info + }{ + { + name: "with rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Rollback to 2", + Status: common.StatusDeployed, + RollbackRevision: 2, + }, + }, + { + name: "zero rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Normal install", + Status: common.StatusDeployed, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.info) + require.NoError(t, err) + + var decoded Info + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, tt.info.RollbackRevision, decoded.RollbackRevision) + assert.Equal(t, tt.info.FirstDeployed.Unix(), decoded.FirstDeployed.Unix()) + assert.Equal(t, tt.info.LastDeployed.Unix(), decoded.LastDeployed.Unix()) + assert.Equal(t, tt.info.Status, decoded.Status) + assert.Equal(t, tt.info.Description, decoded.Description) + + // Verify omitempty behavior: zero rollback_revision should not appear in JSON + if tt.info.RollbackRevision == 0 { + var raw map[string]any + err = json.Unmarshal(data, &raw) + require.NoError(t, err) + assert.NotContains(t, raw, "rollback_revision") + } + }) + } +} + func TestInfoEmptyStringRoundTrip(t *testing.T) { // This test specifically verifies that empty string time fields // are handled correctly during parsing