diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go index 611d1a54c..6b62150ca 100644 --- a/pkg/cmd/helpers_test.go +++ b/pkg/cmd/helpers_test.go @@ -45,6 +45,9 @@ func testTimestamper() time.Time { return time.Unix(242085845, 0).UTC() } func init() { action.Timestamper = testTimestamper + // Timestamps are rendered in local time; pin the local zone to UTC so + // golden-file output does not depend on the machine's timezone. + time.Local = time.UTC } func runTestCmd(t *testing.T, tests []cmdTestCase) { diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index f53a2f1a6..388ed7f3b 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -194,7 +194,7 @@ 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) + tbl.AddRow(item.Revision, item.Updated.Local().Format(time.ANSIC), item.Status, item.Chart, item.AppVersion, item.Description) } return output.EncodeTable(out, tbl) } @@ -218,7 +218,7 @@ func (r releaseHistoryWithRollback) WriteTable(out io.Writer) error { 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) + tbl.AddRow(item.Revision, item.Updated.Local().Format(time.ANSIC), item.Status, item.Chart, item.AppVersion, rollback, item.Description) } return output.EncodeTable(out, tbl) } diff --git a/pkg/cmd/history_test.go b/pkg/cmd/history_test.go index cd8e353e7..8504ac803 100644 --- a/pkg/cmd/history_test.go +++ b/pkg/cmd/history_test.go @@ -17,8 +17,10 @@ limitations under the License. package cmd import ( + "bytes" "encoding/json" "fmt" + "strings" "testing" "time" @@ -30,6 +32,31 @@ import ( release "helm.sh/helm/v4/pkg/release/v1" ) +// Regression test for #31902: table timestamps must be rendered in the +// local timezone (which Go derives from TZ) rather than the zone the +// release happened to be stored with. +func TestHistoryTableUsesLocalTime(t *testing.T) { + originalLocal := time.Local + time.Local = time.FixedZone("UTC+2", 2*60*60) + defer func() { time.Local = originalLocal }() + + h := releaseHistory{{ + Revision: 1, + Updated: time.Date(2026, 3, 4, 14, 38, 52, 0, time.UTC), + Status: "deployed", + Chart: "foo-0.1.0", + AppVersion: "1.0", + Description: "Install complete", + }} + + var buf bytes.Buffer + require.NoError(t, h.WriteTable(&buf)) + + if !strings.Contains(buf.String(), "Wed Mar 4 16:38:52 2026") { + t.Fatalf("expected timestamp rendered in local zone (UTC+2), got:\n%s", buf.String()) + } +} + func TestHistoryCmd(t *testing.T) { mk := func(name string, vers int, status common.Status) *release.Release { return release.Mock(&release.MockReleaseOptions{ diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 00465d232..6e9306eab 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -171,9 +171,9 @@ func newReleaseListWriter(releases []*release.Release, timeFormat string, noHead t := "-" if tspb := r.Info.LastDeployed; !tspb.IsZero() { if timeFormat != "" { - t = tspb.Format(timeFormat) + t = tspb.Local().Format(timeFormat) } else { - t = tspb.String() + t = tspb.Local().String() } } element.Updated = t diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go index 41426fedb..bbcf90614 100644 --- a/pkg/cmd/list_test.go +++ b/pkg/cmd/list_test.go @@ -17,6 +17,7 @@ limitations under the License. package cmd import ( + "strings" "testing" "time" @@ -28,6 +29,34 @@ import ( release "helm.sh/helm/v4/pkg/release/v1" ) +// Regression test for #31902: the UPDATED column must be rendered in the +// local timezone (derived from TZ) for both the --time-format path and the +// default String() path. +func TestListWriterUsesLocalTime(t *testing.T) { + originalLocal := time.Local + time.Local = time.FixedZone("UTC+2", 2*60*60) + defer func() { time.Local = originalLocal }() + + releases := []*release.Release{{ + Name: "tz-release", + Version: 1, + Namespace: "default", + Info: &release.Info{ + LastDeployed: time.Date(2026, 3, 4, 14, 38, 52, 0, time.UTC), + Status: common.StatusDeployed, + }, + Chart: &chart.Chart{Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0", AppVersion: "0.0.1"}}, + }} + + writer := newReleaseListWriter(releases, "2006-01-02 15:04:05", false, false) + assert.Equal(t, "2026-03-04 16:38:52", writer.releases[0].Updated) + + writer = newReleaseListWriter(releases, "", false, false) + if got := writer.releases[0].Updated; !strings.Contains(got, "16:38:52") || !strings.Contains(got, "+0200") { + t.Fatalf("expected default-format timestamp in local zone (UTC+2), got %q", got) + } +} + func TestListCmd(t *testing.T) { defaultNamespace := "default" diff --git a/pkg/cmd/status.go b/pkg/cmd/status.go index 6998e341a..cfef344ef 100644 --- a/pkg/cmd/status.go +++ b/pkg/cmd/status.go @@ -147,7 +147,7 @@ func (s statusPrinter) WriteTable(out io.Writer) error { rel := s.getV1Release() _, _ = fmt.Fprintf(out, "NAME: %s\n", rel.Name) if !rel.Info.LastDeployed.IsZero() { - _, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", rel.Info.LastDeployed.Format(time.ANSIC)) + _, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", rel.Info.LastDeployed.Local().Format(time.ANSIC)) } _, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", coloroutput.ColorizeNamespace(rel.Namespace, s.noColor)) _, _ = fmt.Fprintf(out, "STATUS: %s\n", coloroutput.ColorizeStatus(rel.Info.Status, s.noColor)) @@ -197,8 +197,8 @@ func (s statusPrinter) WriteTable(out io.Writer) error { } _, _ = fmt.Fprintf(out, "TEST SUITE: %s\n%s\n%s\n%s\n", h.Name, - "Last Started: "+h.LastRun.StartedAt.Format(time.ANSIC), - "Last Completed: "+h.LastRun.CompletedAt.Format(time.ANSIC), + "Last Started: "+h.LastRun.StartedAt.Local().Format(time.ANSIC), + "Last Completed: "+h.LastRun.CompletedAt.Local().Format(time.ANSIC), "Phase: "+h.LastRun.Phase, ) } diff --git a/pkg/cmd/status_test.go b/pkg/cmd/status_test.go index 2e1a5579e..1cabe9744 100644 --- a/pkg/cmd/status_test.go +++ b/pkg/cmd/status_test.go @@ -17,6 +17,8 @@ limitations under the License. package cmd import ( + "bytes" + "strings" "testing" "time" @@ -25,6 +27,32 @@ import ( release "helm.sh/helm/v4/pkg/release/v1" ) +// Regression test for #31902: status table timestamps must be rendered in +// the local timezone (derived from TZ), not the zone they were stored with. +func TestStatusTableUsesLocalTime(t *testing.T) { + originalLocal := time.Local + time.Local = time.FixedZone("UTC+2", 2*60*60) + defer func() { time.Local = originalLocal }() + + rel := &release.Release{ + Name: "tz-release", + Namespace: "default", + Info: &release.Info{ + LastDeployed: time.Date(2026, 3, 4, 14, 38, 52, 0, time.UTC), + Status: common.StatusDeployed, + }, + } + + var buf bytes.Buffer + if err := (statusPrinter{release: rel}).WriteTable(&buf); err != nil { + t.Fatal(err) + } + + if !strings.Contains(buf.String(), "LAST DEPLOYED: Wed Mar 4 16:38:52 2026") { + t.Fatalf("expected LAST DEPLOYED rendered in local zone (UTC+2), got:\n%s", buf.String()) + } +} + func TestStatusCmd(t *testing.T) { releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { info.LastDeployed = time.Unix(1452902400, 0).UTC()