Render CLI timestamps in local time so TZ is honored

helm history, helm status, and helm list format release timestamps
with the offset the release was stored with, so setting TZ has no
effect on displayed times (the stored offset simply passes through).

Convert to local time before formatting in the table writers for
history, status (including test-suite hook times), and list. Go
derives the local zone from TZ, so 'TZ=UTC helm history ...' now
behaves as expected. JSON/YAML output is unchanged and keeps the
stored offset.

Golden files are unaffected: the pkg/cmd test init pins time.Local
to UTC so table output stays deterministic across machines, and a
regression test renders history in a fixed non-UTC zone to lock in
the conversion.

Closes #31902

Signed-off-by: Mukul <nmukul32@gmail.com>
pull/32391/head
Mukul 3 months ago
parent ccb8f595ab
commit 05e9a98b5e

@ -45,6 +45,9 @@ func testTimestamper() time.Time { return time.Unix(242085845, 0).UTC() }
func init() { func init() {
action.Timestamper = testTimestamper 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) { func runTestCmd(t *testing.T, tests []cmdTestCase) {

@ -194,7 +194,7 @@ func (r releaseHistory) WriteTable(out io.Writer) error {
tbl := uitable.New() tbl := uitable.New()
tbl.AddRow("REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION", "DESCRIPTION") tbl.AddRow("REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION", "DESCRIPTION")
for _, item := range r { 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) return output.EncodeTable(out, tbl)
} }
@ -218,7 +218,7 @@ func (r releaseHistoryWithRollback) WriteTable(out io.Writer) error {
if item.RollbackRevision > 0 { if item.RollbackRevision > 0 {
rollback = strconv.Itoa(item.RollbackRevision) 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) return output.EncodeTable(out, tbl)
} }

@ -17,8 +17,10 @@ limitations under the License.
package cmd package cmd
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"testing" "testing"
"time" "time"
@ -30,6 +32,31 @@ import (
release "helm.sh/helm/v4/pkg/release/v1" 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) { func TestHistoryCmd(t *testing.T) {
mk := func(name string, vers int, status common.Status) *release.Release { mk := func(name string, vers int, status common.Status) *release.Release {
return release.Mock(&release.MockReleaseOptions{ return release.Mock(&release.MockReleaseOptions{

@ -171,9 +171,9 @@ func newReleaseListWriter(releases []*release.Release, timeFormat string, noHead
t := "-" t := "-"
if tspb := r.Info.LastDeployed; !tspb.IsZero() { if tspb := r.Info.LastDeployed; !tspb.IsZero() {
if timeFormat != "" { if timeFormat != "" {
t = tspb.Format(timeFormat) t = tspb.Local().Format(timeFormat)
} else { } else {
t = tspb.String() t = tspb.Local().String()
} }
} }
element.Updated = t element.Updated = t

@ -147,7 +147,7 @@ func (s statusPrinter) WriteTable(out io.Writer) error {
rel := s.getV1Release() rel := s.getV1Release()
_, _ = fmt.Fprintf(out, "NAME: %s\n", rel.Name) _, _ = fmt.Fprintf(out, "NAME: %s\n", rel.Name)
if !rel.Info.LastDeployed.IsZero() { 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, "NAMESPACE: %s\n", coloroutput.ColorizeNamespace(rel.Namespace, s.noColor))
_, _ = fmt.Fprintf(out, "STATUS: %s\n", coloroutput.ColorizeStatus(rel.Info.Status, 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", _, _ = fmt.Fprintf(out, "TEST SUITE: %s\n%s\n%s\n%s\n",
h.Name, h.Name,
"Last Started: "+h.LastRun.StartedAt.Format(time.ANSIC), "Last Started: "+h.LastRun.StartedAt.Local().Format(time.ANSIC),
"Last Completed: "+h.LastRun.CompletedAt.Format(time.ANSIC), "Last Completed: "+h.LastRun.CompletedAt.Local().Format(time.ANSIC),
"Phase: "+h.LastRun.Phase, "Phase: "+h.LastRun.Phase,
) )
} }

Loading…
Cancel
Save