From d9749517541e5fdaabfc7f9bd73cf860d4333dd3 Mon Sep 17 00:00:00 2001 From: Geoff Turk Date: Fri, 16 Oct 2026 10:33:49 +0200 Subject: [PATCH] fix(ghcr): list latest, nightly and main images ahead of the rest ListTags sorted every tag newest-first and then swapped the latest row with whatever was first. When nightly was the newest build, the swap moved latest to the top and dropped nightly into latest's old slot, below main and unchanneled builds. Nightly and main were never placed on purpose. - internal/ghcr/client.go: replace the swap with a stable sort by channel rank (latest, nightly, main, then the rest), so unchanneled tags keep their newest-first order and undated tags stay last. A build carrying several channels takes its highest-ranked slot. result[0] is still the latest row, so frontends default as before. - internal/ghcr/client_test.go: cover the nightly-newest case, a build flagged both nightly and main, a missing channel, and no channels. Signed-off-by: Geoff Turk --- internal/ghcr/client.go | 39 ++++++++++++++-------- internal/ghcr/client_test.go | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 internal/ghcr/client_test.go 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) + } + }) + } +}