Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 36 additions & 11 deletions cli/cmd/channel_releases.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,26 @@ import (

func (r *runners) InitChannelReleases(parent *cobra.Command) {
cmd := &cobra.Command{
Use: "releases CHANNEL_ID",
Use: "releases CHANNEL_ID_OR_NAME",
Short: "List all releases in a channel",
Long: "List all releases in a channel",
Long: "List all releases promoted to a channel, including demoted releases. Accepts a channel ID or name.",
Example: `# List releases for a channel by name
replicated channel releases Stable

# List releases for a channel by ID
replicated channel releases 2abc123

# JSON output for scripting or AI agents
replicated channel releases Stable --output json

# Paginate (second page of 50)
replicated channel releases Stable --page 1 --page-size 50`,
}
cmd.Hidden = true // Not supported in KOTS
parent.AddCommand(cmd)
cmd.Flags().StringVarP(&r.outputFormat, "output", "o", "table", "The output format to use. One of: json|table")
cmd.Flags().IntVar(&r.args.channelReleasesPage, "page", 0, "The page to fetch (KOTS apps only).")
cmd.Flags().IntVar(&r.args.channelReleasesPageSize, "page-size", 0, "The number of releases per page (KOTS apps only).")

cmd.RunE = r.channelReleases
}

Expand All @@ -24,23 +38,34 @@ func (r *runners) channelReleases(cmd *cobra.Command, args []string) error {
}

if len(args) != 1 {
return errors.New("channel ID is required")
return errors.New("channel name or ID is required")
}
chanID := args[0]
channelNameOrID := args[0]

if r.appType == "platform" {
if r.args.channelReleasesPage != 0 && r.args.channelReleasesPageSize == 0 {
return errors.New("--page requires --page-size")
}

_, releases, err := r.platformAPI.GetChannel(r.appID, chanID)
channel, err := r.api.GetChannelByName(r.appID, r.appType, channelNameOrID)
if err != nil {
return err
}

if r.appType == "platform" {
_, releases, err := r.platformAPI.GetChannel(r.appID, channel.ID)
if err != nil {
return err
}

if err = print.ChannelReleases(r.w, releases); err != nil {
return print.ChannelReleases(r.outputFormat, r.w, releases)
} else if r.appType == "kots" {
releases, err := r.api.ListChannelReleasesPaged(r.appID, r.appType, channel.ID, "", r.args.channelReleasesPage, r.args.channelReleasesPageSize)
if err != nil {
return err
}
} else if r.appType == "kots" {
return errors.New("This feature is not supported for Kots applications.")

return print.KotsChannelReleases(r.outputFormat, r.w, releases)
}

return nil
return errors.New("unknown app type")
}
3 changes: 3 additions & 0 deletions cli/cmd/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ type runnerArgs struct {
channelCreateName string
channelCreateDescription string

channelReleasesPage int
channelReleasesPageSize int

releaseImageLSChannel string
releaseImageLSVersion string
releaseImageLSKeepProxy bool
Expand Down
58 changes: 57 additions & 1 deletion cli/print/channel_releases.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package print

import (
"encoding/json"
"fmt"
"text/tabwriter"
"text/template"

channels "github.com/replicatedhq/replicated/gen/go/v1"
"github.com/replicatedhq/replicated/pkg/types"
)

var channelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE RELEASED VERSION REQUIRED AIRGAP_STATUS RELEASE_NOTES
Expand All @@ -15,7 +17,15 @@ var channelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE RELEASED VERSION

var channelReleasesTmpl = template.Must(template.New("ChannelReleases").Funcs(funcs).Parse(channelReleasesTmplSrc))

func ChannelReleases(w *tabwriter.Writer, releases []channels.ChannelRelease) error {
func ChannelReleases(outputFormat string, w *tabwriter.Writer, releases []channels.ChannelRelease) error {
if outputFormat == "json" {
out, _ := json.MarshalIndent(releases, "", " ")
if _, err := fmt.Fprintln(w, string(out)); err != nil {
return err
}
return w.Flush()
}

if len(releases) == 0 {
if _, err := fmt.Fprintln(w, "No releases in channel"); err != nil {
return err
Expand All @@ -29,3 +39,49 @@ func ChannelReleases(w *tabwriter.Writer, releases []channels.ChannelRelease) er

return w.Flush()
}

var kotsChannelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE VERSION CREATED RELEASED STATE
{{ range . -}}
{{ .ChannelSequence }} {{ .Sequence }} {{ .Semver }} {{ time .Created }} {{ time .ReleasedAt }} {{ .State }}
{{ end }}`

var kotsChannelReleasesTmpl = template.Must(template.New("KotsChannelReleases").Funcs(funcs).Parse(kotsChannelReleasesTmplSrc))

func KotsChannelReleases(outputFormat string, w *tabwriter.Writer, releases []*types.ChannelRelease) error {
if outputFormat == "json" {
out, _ := json.MarshalIndent(releases, "", " ")
if _, err := fmt.Fprintln(w, string(out)); err != nil {
return err
}
return w.Flush()
}

if len(releases) == 0 {
if _, err := fmt.Fprintln(w, "No releases in channel"); err != nil {
return err
}
return w.Flush()
}

rows := make([]map[string]interface{}, len(releases))
for i, r := range releases {
state := "active"
if r.IsDemoted {
state = "demoted"
}
rows[i] = map[string]interface{}{
"ChannelSequence": r.ChannelSequence,
"Sequence": r.Sequence,
"Semver": r.Semver,
"Created": r.Created,
"ReleasedAt": r.ReleasedAt,
"State": state,
}
}

if err := kotsChannelReleasesTmpl.Execute(w, rows); err != nil {
return err
}

return w.Flush()
}
80 changes: 80 additions & 0 deletions cli/print/channel_releases_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package print

import (
"bytes"
"encoding/json"
"testing"
"text/tabwriter"
"time"

"github.com/replicatedhq/replicated/pkg/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestKotsChannelReleases_Table(t *testing.T) {
demoted := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
releases := []*types.ChannelRelease{
{
ChannelSequence: 5,
Sequence: 12,
Semver: "1.2.0",
Created: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
ReleasedAt: time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC),
},
{
ChannelSequence: 4,
Sequence: 11,
Semver: "1.1.0",
Created: time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC),
ReleasedAt: time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC),
IsDemoted: true,
DemotedAt: &demoted,
},
}

var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent)
require.NoError(t, KotsChannelReleases("table", w, releases))

got := out.String()
assert.Contains(t, got, "CHANNEL_SEQUENCE")
assert.Contains(t, got, "RELEASE_SEQUENCE")
assert.Contains(t, got, "VERSION")
assert.Contains(t, got, "STATE")
assert.Contains(t, got, "1.2.0")
assert.Contains(t, got, "active")
assert.Contains(t, got, "demoted")
}

func TestKotsChannelReleases_JSON(t *testing.T) {
releases := []*types.ChannelRelease{
{
ChannelSequence: 5,
Sequence: 12,
Semver: "1.2.0",
},
}

var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent)
require.NoError(t, KotsChannelReleases("json", w, releases))

var decoded []*types.ChannelRelease
require.NoError(t, json.Unmarshal(out.Bytes(), &decoded))
require.Len(t, decoded, 1)
assert.Equal(t, "1.2.0", decoded[0].Semver)
assert.Equal(t, int32(12), decoded[0].Sequence)

// isDemoted and demotedAt must always appear so agents can do
// `release.isDemoted === false` instead of seeing undefined.
assert.Contains(t, out.String(), `"isDemoted": false`)
assert.Contains(t, out.String(), `"demotedAt": null`)
}

func TestKotsChannelReleases_Empty(t *testing.T) {
var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent)
require.NoError(t, KotsChannelReleases("table", w, nil))
assert.Contains(t, out.String(), "No releases in channel")
}
3 changes: 2 additions & 1 deletion cli/print/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import (
var releaseTmplSrc = `SEQUENCE: {{ .Sequence }}
CREATED: {{ time .CreatedAt }}
EDITED: {{ time .EditedAt }}
{{if .CompatibilityResults}}COMPATIBILITY RESULTS:
{{if .Channels}}CHANNELS: {{ range $i, $c := .Channels }}{{if $i}}, {{end}}{{ $c.Name }}{{ end }}
{{end}}{{if .CompatibilityResults}}COMPATIBILITY RESULTS:
DISTRIBUTION VERSION SUCCESS_AT SUCCESS_NOTES FAILURE_AT FAILURE_NOTES
{{ range .CompatibilityResults -}}
{{ .Distribution }} {{ .Version }} {{if .SuccessAt}}{{ time .SuccessAt }}{{else}}-{{end}} {{ .SuccessNotes }} {{if .FailureAt}}{{ time .FailureAt }}{{else}}-{{end}} {{ .FailureNotes }}
Expand Down
59 changes: 59 additions & 0 deletions cli/print/release_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package print

import (
"bytes"
"encoding/json"
"testing"
"text/tabwriter"
"time"

"github.com/replicatedhq/replicated/pkg/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRelease_Table_RendersChannels(t *testing.T) {
release := &types.AppRelease{
Sequence: 42,
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
EditedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
Channels: []*types.Channel{
{ID: "c1", Name: "Stable"},
{ID: "c2", Name: "Beta"},
},
Config: "spec: yaml",
}

var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent)
require.NoError(t, Release("table", w, release))

got := out.String()
assert.Contains(t, got, "CHANNELS:")
assert.Contains(t, got, "Stable")
assert.Contains(t, got, "Beta")
}

func TestRelease_Table_NoChannelsSection_WhenEmpty(t *testing.T) {
release := &types.AppRelease{Sequence: 1, Config: "x"}
var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent)
require.NoError(t, Release("table", w, release))
assert.NotContains(t, out.String(), "CHANNELS:")
}

func TestRelease_JSON_IncludesChannels(t *testing.T) {
release := &types.AppRelease{
Sequence: 7,
Channels: []*types.Channel{{ID: "c1", Name: "Stable"}},
}

var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent)
require.NoError(t, Release("json", w, release))

var decoded types.AppRelease
require.NoError(t, json.Unmarshal(out.Bytes(), &decoded))
require.Len(t, decoded.Channels, 1)
assert.Equal(t, "Stable", decoded.Channels[0].Name)
}
9 changes: 9 additions & 0 deletions client/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,15 @@ func (c *Client) ListChannelReleases(appID string, appType string, channelID str
return nil, errors.Errorf("unknown app type %q", appType)
}

func (c *Client) ListChannelReleasesPaged(appID string, appType string, channelID string, includeInstallerImages string, page int, pageSize int) ([]*types.ChannelRelease, error) {
if appType == "platform" {
return nil, errors.New("This feature is not currently supported for Platform applications.")
} else if appType == "kots" {
return c.KotsClient.ListChannelReleasesPaged(appID, channelID, includeInstallerImages, page, pageSize)
}
return nil, errors.Errorf("unknown app type %q", appType)
}

func (c *Client) GetCustomHostnames(appID string, appType string, channelID string) (*types.CustomHostNameOverrides, error) {
if appType == "platform" {
return nil, errors.New("This feature is not currently supported for Platform applications.")
Expand Down
Loading
Loading