diff --git a/internal/ghcr/client.go b/internal/ghcr/client.go index ba97364..42c12e0 100644 --- a/internal/ghcr/client.go +++ b/internal/ghcr/client.go @@ -58,7 +58,7 @@ func (c *Client) FullImage() string { // creation time — the same order GitHub shows package versions. Tags whose // creation time can't be resolved keep a semver-descending fallback order // after the dated ones. The tags that "latest", "nightly" and "main" point to -// are marked Latest, Nightly and Main; the Latest row is moved to the front. +// are marked Latest, Nightly and Main and moved to the front in that order. func (c *Client) ListTags(ctx context.Context) ([]ImageTag, error) { registryToken, err := c.fetchRegistryToken(ctx) if err != nil { @@ -136,7 +136,6 @@ func (c *Client) ListTags(ctx context.Context) ([]ImageTag, error) { base := c.FullImage() result := make([]ImageTag, len(infos)) flagged := make([]bool, len(channelTags)) - latestFound := false for i, info := range infos { r := ImageTag{Tag: info.tag, Image: base + ":" + info.tag} if !info.created.IsZero() { @@ -150,7 +149,6 @@ func (c *Client) ListTags(ctx context.Context) ([]ImageTag, error) { switch channelTags[k] { case "latest": r.Latest = true - latestFound = true case "nightly": r.Nightly = true case "main": @@ -160,19 +158,34 @@ func (c *Client) ListTags(ctx context.Context) ([]ImageTag, error) { result[i] = r } - // Ensure the latest-marked tag is first so frontends can default to result[0]. - if latestFound { - for i, r := range result { - if r.Latest && i != 0 { - result[0], result[i] = result[i], result[0] - break - } - } - } - + orderChannelsFirst(result) return result, nil } +// orderChannelsFirst moves the channel rows to the front in the order latest, +// nightly, main, so frontends can default to result[0]. The stable sort keeps +// the remaining rows in their existing newest-first order. +func orderChannelsFirst(tags []ImageTag) { + sort.SliceStable(tags, func(i, j int) bool { + return channelRank(tags[i]) < channelRank(tags[j]) + }) +} + +// channelRank is a row's sort rank by its highest-priority channel flag; +// rows no channel points to rank last. +func channelRank(t ImageTag) int { + switch { + case t.Latest: + return 0 + case t.Nightly: + return 1 + case t.Main: + return 2 + default: + return 3 + } +} + // tagInfo carries what sorting and latest-matching need per tag. type tagInfo struct { tag string diff --git a/internal/ghcr/client_test.go b/internal/ghcr/client_test.go new file mode 100644 index 0000000..4ee6214 --- /dev/null +++ b/internal/ghcr/client_test.go @@ -0,0 +1,64 @@ +package ghcr + +import ( + "slices" + "testing" +) + +func TestOrderChannelsFirst(t *testing.T) { + tests := []struct { + name string + in []ImageTag + want []string + }{ + { + name: "nightly newest, latest in the middle", + in: []ImageTag{ + {Tag: "f16a2fe", Nightly: true}, + {Tag: "6456f7b", Main: true}, + {Tag: "cf959b0"}, + {Tag: "eucalyptus", Latest: true}, + {Tag: "dogwood-r1"}, + {Tag: "dogwood"}, + }, + want: []string{"eucalyptus", "f16a2fe", "6456f7b", "cf959b0", "dogwood-r1", "dogwood"}, + }, + { + name: "nightly and main on one build", + in: []ImageTag{ + {Tag: "a"}, + {Tag: "b", Nightly: true, Main: true}, + {Tag: "c", Latest: true}, + {Tag: "d"}, + }, + want: []string{"c", "b", "a", "d"}, + }, + { + name: "no nightly", + in: []ImageTag{ + {Tag: "a"}, + {Tag: "b", Main: true}, + {Tag: "c"}, + {Tag: "d", Latest: true}, + }, + want: []string{"d", "b", "a", "c"}, + }, + { + name: "no channels", + in: []ImageTag{{Tag: "a"}, {Tag: "b"}, {Tag: "c"}}, + want: []string{"a", "b", "c"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + orderChannelsFirst(tt.in) + var got []string + for _, r := range tt.in { + got = append(got, r.Tag) + } + if !slices.Equal(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +}