From 7bf90d89ad597ca6c78c3d03e4d37d41541038e0 Mon Sep 17 00:00:00 2001 From: Geoff Turk Date: Thu, 15 Oct 2026 15:14:03 +0200 Subject: [PATCH] feat(ghcr): flag nightly and main images and report creation time The build pipeline now repoints three moving tags: latest on a tagged release, nightly on a nightly build and main on a main-branch build. ListTags treated only latest as a channel, so nightly and main showed up as rows of their own in the image pickers, and the row they pointed to carried no mark. Treat all three as channel tags: none is a row, and the versioned tag sharing a channel's digest gets the latest, nightly or main flag. Each row also reports the image creation time from its config blob, which the client already fetched for sorting. The images handler returned a trimmed copy of each row, dropping everything but tag, image and latest; it now serves the ghcr rows as they are. Signed-off-by: Geoff Turk Signed-off-by: vthwang --- internal/ghcr/client.go | 75 ++++++++++++++++++++++++++++----------- internal/handler/setup.go | 15 ++------ 2 files changed, 57 insertions(+), 33 deletions(-) diff --git a/internal/ghcr/client.go b/internal/ghcr/client.go index fca5613..ba97364 100644 --- a/internal/ghcr/client.go +++ b/internal/ghcr/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "sort" "strconv" "strings" @@ -25,11 +26,20 @@ type Client struct { // ImageTag is one entry in the version dropdown. type ImageTag struct { - Tag string `json:"tag"` - Image string `json:"image"` - Latest bool `json:"latest,omitempty"` + Tag string `json:"tag"` + Image string `json:"image"` + Latest bool `json:"latest,omitempty"` // the image "latest" points to (last tagged release) + Nightly bool `json:"nightly,omitempty"` // the image "nightly" points to + Main bool `json:"main,omitempty"` // the image "main" points to + Created string `json:"created,omitempty"` // image creation time, RFC 3339 UTC; "" when unknown } +// channelTags are the moving tags the build pipeline repoints on each build of +// its source (devops fpp/build-pipeline.md). They are never rows of their own: +// the versioned tag that shares a channel's digest carries the flag instead, +// so the dropdown only ever offers immutable - images. +var channelTags = []string{"latest", "nightly", "main"} + func New(token, owner, packageName string) *Client { return &Client{ token: token, @@ -47,7 +57,8 @@ func (c *Client) FullImage() string { // ListTags fetches all tags for the package, sorted newest-first by image // 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 tag that "latest" points to is marked Latest=true. +// 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. func (c *Client) ListTags(ctx context.Context) ([]ImageTag, error) { registryToken, err := c.fetchRegistryToken(ctx) if err != nil { @@ -78,21 +89,23 @@ func (c *Client) ListTags(ctx context.Context) ([]ImageTag, error) { return nil, fmt.Errorf("decode tags: %w", err) } - // Separate versioned tags from "latest"; semver order is the fallback for - // tags whose creation time can't be resolved below. + // Separate versioned tags from the channel tags; semver order is the + // fallback for tags whose creation time can't be resolved below. var tags []string - hasLatest := false + present := map[string]bool{} for _, t := range body.Tags { - if t == "latest" { - hasLatest = true + if slices.Contains(channelTags, t) { + present[t] = true } else { tags = append(tags, t) } } sortSemverDesc(tags) - // Resolve each tag's manifest digest and image creation time, best-effort. + // Resolve each tag's manifest digest and image creation time, and each + // present channel tag's digest, best-effort. infos := make([]tagInfo, len(tags)) + channelDigest := make([]string, len(channelTags)) g, gctx := errgroup.WithContext(ctx) g.SetLimit(5) for i, t := range tags { @@ -101,6 +114,15 @@ func (c *Client) ListTags(ctx context.Context) ([]ImageTag, error) { return nil }) } + for i, t := range channelTags { + if !present[t] { + continue + } + g.Go(func() error { + channelDigest[i], _ = c.manifestDigest(gctx, registryToken, t) + return nil + }) + } _ = g.Wait() // Newest-first by creation time; the stable sort leaves undated tags in @@ -109,22 +131,33 @@ func (c *Client) ListTags(ctx context.Context) ([]ImageTag, error) { return infos[i].created.After(infos[j].created) }) - // Resolve which versioned tag "latest" points to by comparing manifest digests. - latestDigest := "" - if hasLatest { - latestDigest, _ = c.manifestDigest(ctx, registryToken, "latest") - } - + // Flag the versioned tag each channel points to by comparing manifest + // digests. Only the first (newest) match per channel is flagged. base := c.FullImage() result := make([]ImageTag, len(infos)) + flagged := make([]bool, len(channelTags)) latestFound := false for i, info := range infos { - isLatest := false - if latestDigest != "" && !latestFound && info.digest == latestDigest { - isLatest = true - latestFound = true + r := ImageTag{Tag: info.tag, Image: base + ":" + info.tag} + if !info.created.IsZero() { + r.Created = info.created.UTC().Format(time.RFC3339) + } + for k, digest := range channelDigest { + if digest == "" || flagged[k] || info.digest != digest { + continue + } + flagged[k] = true + switch channelTags[k] { + case "latest": + r.Latest = true + latestFound = true + case "nightly": + r.Nightly = true + case "main": + r.Main = true + } } - result[i] = ImageTag{Tag: info.tag, Image: base + ":" + info.tag, Latest: isLatest} + result[i] = r } // Ensure the latest-marked tag is first so frontends can default to result[0]. diff --git a/internal/handler/setup.go b/internal/handler/setup.go index e6535a4..773f147 100644 --- a/internal/handler/setup.go +++ b/internal/handler/setup.go @@ -106,14 +106,9 @@ func (h *SetupHandler) Validate(c *gin.Context) { // GET /api/v1/setup/images?component=vta|mediator|dids|vtc // component defaults to "vta" (vta_only's existing behavior, unchanged). // mediator/dids are full_stack-only and vtc is full_stack-only — -// same GHCR-package-tags pattern as vta. +// same GHCR-package-tags pattern as vta. The response rows are ghcr.ImageTag +// as-is: tag, image, the latest/nightly/main flags and the creation time. func (h *SetupHandler) Images(c *gin.Context) { - type imageOption struct { - Tag string `json:"tag"` - Image string `json:"image"` - Latest bool `json:"latest,omitempty"` - } - component := c.DefaultQuery("component", "vta") var client *ghcr.Client switch component { @@ -141,11 +136,7 @@ func (h *SetupHandler) Images(c *gin.Context) { return } - result := make([]imageOption, len(tags)) - for i, t := range tags { - result[i] = imageOption{Tag: t.Tag, Image: t.Image, Latest: t.Latest} - } - c.JSON(http.StatusOK, result) + c.JSON(http.StatusOK, tags) } type createSetupRequest struct {