From 684f939117f427994ddb5341150daecdb428d78c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Thu, 23 Jul 2026 17:55:42 +0200 Subject: [PATCH 1/3] Add codefly agent versions and agent list for pin resolvability (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface, per agent, every known version and whether each is actually usable — not just tagged. `codefly agent versions ` unions git tags, GitHub release assets (for the CI os/arch), OCI manifests, the workspace pin, and the local cache into one table, and reports latest-tag vs latest-resolvable so the module-saas-starter#3 gap (a tag with no downloadable artifact) is obvious. `codefly agent list` gives the workspace-wide "are all my pins publishable?" view. Co-Authored-By: Claude Opus 4.8 --- cmd/agent.go | 2 + cmd/agents/versions.go | 580 ++++++++++++++++++++++++++++++++++++ cmd/agents/versions_test.go | 226 ++++++++++++++ go.mod | 2 +- 4 files changed, 809 insertions(+), 1 deletion(-) create mode 100644 cmd/agents/versions.go create mode 100644 cmd/agents/versions_test.go diff --git a/cmd/agent.go b/cmd/agent.go index 77bcdae8..f39984b1 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -18,4 +18,6 @@ func init() { AgentCmd.AddCommand(agents.AgentCICmd) AgentCmd.AddCommand(agents.DepsCmd) AgentCmd.AddCommand(agents.InstallCmd) + AgentCmd.AddCommand(agents.VersionsCmd) + AgentCmd.AddCommand(agents.ListCmd) } diff --git a/cmd/agents/versions.go b/cmd/agents/versions.go new file mode 100644 index 00000000..9ae177b0 --- /dev/null +++ b/cmd/agents/versions.go @@ -0,0 +1,580 @@ +package agents + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "text/tabwriter" + + "github.com/blang/semver" + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/core/agents/manager" + "github.com/codefly-dev/core/resources" + "github.com/google/go-github/v37/github" + "github.com/spf13/cobra" +) + +// ciPlatform is the os/arch a released agent must ship an asset for to be +// downloadable inside CI. A git tag can exist with no artifact for this +// platform — that gap (module-saas-starter#3) is exactly what these commands +// surface. +const ciPlatform = "linux_amd64" + +// Seams so the resolvability logic can be tested without reaching GitHub, an +// OCI registry, or the local filesystem. +var ( + fetchReleases = fetchReleasesFromGitHub + fetchTags = fetchTagsFromGitHub +) + +// releaseInfo is one published GitHub release, reduced to what resolvability +// needs: the version it tags and whether it carries the CI-platform asset. +type releaseInfo struct { + version string + hasCIAsset bool +} + +// sourceFlags records, per version, which sources can supply it. +type sourceFlags struct { + Tag bool `json:"tag"` + GithubRelease bool `json:"github_release"` + OCI bool `json:"oci"` + PinnedHere bool `json:"pinned_here"` + LocalCache bool `json:"local_cache"` +} + +func (f sourceFlags) resolvable() bool { + return f.GithubRelease || f.OCI +} + +type versionEntry struct { + Version string `json:"version"` + Sources sourceFlags `json:"sources"` + sem semver.Version +} + +type inventory struct { + Agent string `json:"agent"` + CIPlatform string `json:"ci_platform"` + OCIConfigured bool `json:"oci_configured"` + Versions []versionEntry `json:"versions"` + Pinned []string `json:"pinned,omitempty"` + LatestTag string `json:"latest_tag,omitempty"` + LatestResolvable string `json:"latest_resolvable,omitempty"` +} + +func (inv inventory) versionResolvable(version string) bool { + if version == "latest" { + return inv.LatestResolvable != "" + } + for _, entry := range inv.Versions { + if entry.Version == version { + return entry.Sources.resolvable() + } + } + return false +} + +var versionsJSON bool + +// VersionsCmd reports every known version of a single agent and whether each is +// resolvable per source (git tag, GitHub release asset, OCI manifest), plus the +// version pinned in the current workspace and what sits in the local cache. +var VersionsCmd = &cobra.Command{ + Use: "versions ", + Short: "List an agent's versions and whether each is resolvable", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + + agent, err := resources.ParseAgent(ctx, resources.ServiceAgent, args[0]) + if err != nil { + return fmt.Errorf("invalid agent: %w", err) + } + + inv := collectInventory(ctx, agent, pinnedVersions(ctx, agent)) + + if versionsJSON { + return writeJSON(inv) + } + renderInventory(inv) + return nil + }, +} + +var listJSON bool + +// ListCmd enumerates every agent pinned across the current workspace and, for +// each, whether the pinned version is resolvable, the latest resolvable +// version, and the latest tag — a one-shot "are all my pins publishable?" view. +var ListCmd = &cobra.Command{ + Use: "list", + Short: "List every agent pinned in the workspace and its resolvability", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, done := common.NewContext() + defer done() + + workspace, err := common.LoadWorkspace(ctx) + if err != nil { + return err + } + pins, err := workspacePins(ctx, workspace) + if err != nil { + return err + } + + summaries := summarizeWorkspaceAgents(ctx, pins) + if listJSON { + return writeJSON(summaries) + } + renderSummaries(summaries) + return nil + }, +} + +func init() { + VersionsCmd.Flags().BoolVar(&versionsJSON, "json", false, "Emit the version inventory as JSON") + ListCmd.Flags().BoolVar(&listJSON, "json", false, "Emit the workspace agent inventory as JSON") +} + +// collectInventory gathers versions from every source and assembles the +// resolvability inventory. GitHub lookups that fail (missing repo, rate limit) +// degrade to a warning so the local-cache and pinned columns still render. +func collectInventory(ctx context.Context, agent *resources.Agent, pinned []string) inventory { + releases, err := fetchReleases(ctx, agent) + if err != nil { + cli.Warning("cannot list GitHub releases for %s/%s: %v", agent.Publisher, agent.Name, err) + } + tags, err := fetchTags(ctx, agent) + if err != nil { + cli.Warning("cannot list GitHub tags for %s/%s: %v", agent.Publisher, agent.Name, err) + } + local := localCacheVersions(ctx, agent) + ociConfigured, ociAvailable := ociVersionChecker(ctx, agent) + return buildInventory(agent, releases, tags, local, pinned, ociConfigured, ociAvailable) +} + +// buildInventory is the pure assembly step: given the versions each source +// knows about, it unions them, flags every source per version, and derives the +// latest tag and latest resolvable version. +func buildInventory(agent *resources.Agent, releases []releaseInfo, tags, local, pinned []string, ociConfigured bool, ociAvailable func(version string) bool) inventory { + entries := map[string]*versionEntry{} + ensure := func(version string) (*versionEntry, bool) { + parsed, err := semver.Parse(strings.TrimPrefix(version, "v")) + if err != nil { + return nil, false + } + key := parsed.String() + entry, ok := entries[key] + if !ok { + entry = &versionEntry{Version: key, sem: parsed} + entries[key] = entry + } + return entry, true + } + + for _, release := range releases { + if entry, ok := ensure(release.version); ok { + entry.Sources.Tag = true + if release.hasCIAsset { + entry.Sources.GithubRelease = true + } + } + } + for _, tag := range tags { + if entry, ok := ensure(tag); ok { + entry.Sources.Tag = true + } + } + for _, version := range local { + if entry, ok := ensure(version); ok { + entry.Sources.LocalCache = true + } + } + for _, version := range pinned { + if entry, ok := ensure(version); ok { + entry.Sources.PinnedHere = true + } + } + if ociConfigured { + for _, entry := range entries { + if ociAvailable(entry.Version) { + entry.Sources.OCI = true + } + } + } + + inv := inventory{ + Agent: fmt.Sprintf("%s/%s", agent.Publisher, agent.Name), + CIPlatform: ciPlatform, + OCIConfigured: ociConfigured, + Pinned: pinned, + } + var latestTag, latestResolvable *semver.Version + for _, entry := range entries { + inv.Versions = append(inv.Versions, *entry) + if entry.Sources.Tag && (latestTag == nil || entry.sem.GT(*latestTag)) { + v := entry.sem + latestTag = &v + } + if entry.Sources.resolvable() && (latestResolvable == nil || entry.sem.GT(*latestResolvable)) { + v := entry.sem + latestResolvable = &v + } + } + sort.Slice(inv.Versions, func(i, j int) bool { + return inv.Versions[i].sem.GT(inv.Versions[j].sem) + }) + if latestTag != nil { + inv.LatestTag = latestTag.String() + } + if latestResolvable != nil { + inv.LatestResolvable = latestResolvable.String() + } + return inv +} + +// agentPin is a single service's pinned agent, tagged with the module the +// service lives in. +type agentPin struct { + module string + agent *resources.Agent +} + +type agentSummary struct { + Agent string `json:"agent"` + Pinned string `json:"pinned"` + PinnedResolvable bool `json:"pinned_resolvable"` + LatestResolvable string `json:"latest_resolvable"` + LatestTag string `json:"latest_tag"` + Modules []string `json:"modules,omitempty"` +} + +// workspacePins enumerates every service in the workspace and returns those +// that pin an agent, tagged with their module. +func workspacePins(ctx context.Context, workspace *resources.Workspace) ([]agentPin, error) { + refs, err := workspace.LoadServiceWithModules(ctx) + if err != nil { + return nil, fmt.Errorf("load workspace services: %w", err) + } + var pins []agentPin + for _, ref := range refs { + service, err := workspace.LoadService(ctx, ref) + if err != nil { + return nil, fmt.Errorf("load service %q: %w", ref.Name, err) + } + if service.Agent == nil { + continue + } + pins = append(pins, agentPin{module: ref.Module, agent: service.Agent}) + } + return pins, nil +} + +// summarizeWorkspaceAgents builds one summary row per distinct pinned agent. +// Inventories are cached per publisher/name so agents pinned by several +// services only hit GitHub once. +func summarizeWorkspaceAgents(ctx context.Context, pins []agentPin) []agentSummary { + inventories := map[string]inventory{} + rows := map[string]*agentSummary{} + var order []string + + for _, pin := range pins { + agent := pin.agent + repoKey := fmt.Sprintf("%s/%s", agent.Publisher, agent.Name) + inv, ok := inventories[repoKey] + if !ok { + inv = collectInventory(ctx, agent, nil) + inventories[repoKey] = inv + } + pinKey := agent.Identifier() + row, ok := rows[pinKey] + if !ok { + row = &agentSummary{ + Agent: repoKey, + Pinned: agent.Version, + PinnedResolvable: inv.versionResolvable(agent.Version), + LatestResolvable: inv.LatestResolvable, + LatestTag: inv.LatestTag, + } + rows[pinKey] = row + order = append(order, pinKey) + } + if pin.module != "" { + row.Modules = appendUnique(row.Modules, pin.module) + } + } + + summaries := make([]agentSummary, 0, len(order)) + for _, key := range order { + summaries = append(summaries, *rows[key]) + } + sort.Slice(summaries, func(i, j int) bool { + if summaries[i].Agent != summaries[j].Agent { + return summaries[i].Agent < summaries[j].Agent + } + return summaries[i].Pinned < summaries[j].Pinned + }) + return summaries +} + +// pinnedVersions returns the versions of the given agent pinned by services in +// the current workspace. Best-effort: outside a workspace it returns nothing so +// `agent versions` still works from anywhere. +func pinnedVersions(ctx context.Context, agent *resources.Agent) []string { + workspace, err := common.LoadWorkspace(ctx) + if err != nil { + return nil + } + services, err := workspace.LoadServices(ctx) + if err != nil { + return nil + } + var versions []string + for _, service := range services { + if service.Agent == nil { + continue + } + if service.Agent.Publisher == agent.Publisher && service.Agent.Name == agent.Name { + versions = appendUnique(versions, service.Agent.Version) + } + } + return versions +} + +func fetchReleasesFromGitHub(ctx context.Context, agent *resources.Agent) ([]releaseInfo, error) { + client := newGitHubClient() + owner, repo := githubSource(agent) + var out []releaseInfo + opt := &github.ListOptions{PerPage: 100} + for { + releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opt) + if err != nil { + return nil, err + } + for _, release := range releases { + version := strings.TrimPrefix(release.GetTagName(), "v") + wantAsset := fmt.Sprintf("service-%s_%s_%s.tar.gz", agent.Name, version, ciPlatform) + hasAsset := false + for _, asset := range release.Assets { + if asset.GetName() == wantAsset { + hasAsset = true + break + } + } + out = append(out, releaseInfo{version: version, hasCIAsset: hasAsset}) + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + return out, nil +} + +func fetchTagsFromGitHub(ctx context.Context, agent *resources.Agent) ([]string, error) { + client := newGitHubClient() + owner, repo := githubSource(agent) + var out []string + opt := &github.ListOptions{PerPage: 100} + for { + tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opt) + if err != nil { + return nil, err + } + for _, tag := range tags { + out = append(out, strings.TrimPrefix(tag.GetName(), "v")) + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + return out, nil +} + +// githubSource mirrors manager.toGithubSource (unexported): the publisher's +// dots become dashes and the repo is service-. +func githubSource(agent *resources.Agent) (owner, repo string) { + return strings.ReplaceAll(agent.Publisher, ".", "-"), "service-" + agent.Name +} + +// newGitHubClient returns a client authenticated with GITHUB_TOKEN/GH_TOKEN +// when either is set. Listing every version of every pinned agent multiplies +// requests fast, and the unauthenticated 60/hour limit turns this diagnostic +// flaky exactly when a workspace has many pins to check. +func newGitHubClient() *github.Client { + token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + if token == "" { + token = strings.TrimSpace(os.Getenv("GH_TOKEN")) + } + if token == "" { + return github.NewClient(nil) + } + return github.NewClient(&http.Client{Transport: &tokenTransport{token: token}}) +} + +type tokenTransport struct { + token string +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.Header.Set("Authorization", "Bearer "+t.token) + return http.DefaultTransport.RoundTrip(clone) +} + +func localCacheVersions(ctx context.Context, agent *resources.Agent) []string { + dir := filepath.Join(resources.AgentBase(ctx), "agents", agentSubdir(agent), agent.Publisher) + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + prefix := agent.Name + "__" + var out []string + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, prefix) { + continue + } + version := strings.TrimPrefix(name, prefix) + if _, err := semver.Parse(version); err != nil { + continue + } + out = append(out, version) + } + return out +} + +func agentSubdir(agent *resources.Agent) string { + switch { + case agent.IsApplication(): + return "applications" + case agent.IsToolbox(): + return "toolboxes" + case agent.IsService(): + return "services" + default: + return "modules" + } +} + +// ociVersionChecker reports whether an OCI registry is configured and, if so, +// returns a probe for whether a given version's manifest exists there. +func ociVersionChecker(ctx context.Context, agent *resources.Agent) (bool, func(version string) bool) { + store := manager.NewOCIStoreFromEnv(slog.Default()) + if store == nil { + return false, func(string) bool { return false } + } + return true, func(version string) bool { + probe := *agent + probe.Version = version + ok, err := store.Available(ctx, &probe) + return err == nil && ok + } +} + +func renderInventory(inv inventory) { + cli.Header(1, "%s", inv.Agent) + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "VERSION\tTAG\tGITHUB-RELEASE\tOCI\tPINNED-HERE\tLOCAL-CACHE") + for _, entry := range inv.Versions { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + entry.Version, + mark(entry.Sources.Tag), + mark(entry.Sources.GithubRelease), + ociMark(entry.Sources.OCI, inv.OCIConfigured), + mark(entry.Sources.PinnedHere), + mark(entry.Sources.LocalCache), + ) + } + _ = tw.Flush() + + fmt.Printf("\nCI platform: %s | GitHub-release column = downloadable %s asset\n", inv.CIPlatform, inv.CIPlatform) + if !inv.OCIConfigured { + fmt.Println("OCI column: not checked (set AGENT_REGISTRY)") + } + fmt.Printf("latest tag -> %s\n", dashIfEmpty(inv.LatestTag)) + fmt.Printf("latest resolvable -> %s\n", dashIfEmpty(inv.LatestResolvable)) + if inv.LatestTag != "" && inv.LatestTag != inv.LatestResolvable { + fmt.Printf(" warning: latest tag %s has no downloadable artifact\n", inv.LatestTag) + } + for _, pin := range inv.Pinned { + fmt.Printf("pinned -> %s (resolvable: %s)\n", pin, yesNo(inv.versionResolvable(pin))) + } +} + +func renderSummaries(summaries []agentSummary) { + if len(summaries) == 0 { + cli.Info("No agents pinned in the workspace") + return + } + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "AGENT\tPINNED\tRESOLVABLE\tLATEST-RESOLVABLE\tLATEST-TAG\tMODULES") + for _, summary := range summaries { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + summary.Agent, + summary.Pinned, + yesNo(summary.PinnedResolvable), + dashIfEmpty(summary.LatestResolvable), + dashIfEmpty(summary.LatestTag), + dashIfEmpty(strings.Join(summary.Modules, ", ")), + ) + } + _ = tw.Flush() +} + +func writeJSON(payload any) error { + encoded, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + fmt.Println(string(encoded)) + return nil +} + +func mark(ok bool) string { + if ok { + return "✓" + } + return "✗" +} + +func ociMark(ok, configured bool) string { + if !configured { + return "-" + } + return mark(ok) +} + +func yesNo(ok bool) string { + if ok { + return "yes" + } + return "no" +} + +func dashIfEmpty(s string) string { + if s == "" { + return "-" + } + return s +} + +func appendUnique(list []string, value string) []string { + if slices.Contains(list, value) { + return list + } + return append(list, value) +} diff --git a/cmd/agents/versions_test.go b/cmd/agents/versions_test.go new file mode 100644 index 00000000..022660e3 --- /dev/null +++ b/cmd/agents/versions_test.go @@ -0,0 +1,226 @@ +package agents + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/resources" +) + +func redisAgent() *resources.Agent { + return &resources.Agent{ + Kind: resources.ServiceAgent, + Publisher: "codefly.dev", + Name: "redis", + Version: "0.0.74", + } +} + +func findVersion(inv inventory, version string) (versionEntry, bool) { + for _, entry := range inv.Versions { + if entry.Version == version { + return entry, true + } + } + return versionEntry{}, false +} + +func TestBuildInventoryUnionsSourcesAndFlags(t *testing.T) { + releases := []releaseInfo{ + {version: "0.0.74", hasCIAsset: true}, + {version: "0.0.73", hasCIAsset: false}, // release exists but no CI asset + } + tags := []string{"0.0.74", "0.0.73", "0.0.56"} // 0.0.56 is tag-only + local := []string{"0.0.74", "0.0.10"} // 0.0.10 only in cache + pinned := []string{"0.0.74"} + + inv := buildInventory(redisAgent(), releases, tags, local, pinned, false, func(string) bool { return false }) + + cases := map[string]sourceFlags{ + "0.0.74": {Tag: true, GithubRelease: true, PinnedHere: true, LocalCache: true}, + "0.0.73": {Tag: true}, + "0.0.56": {Tag: true}, + "0.0.10": {LocalCache: true}, + } + if len(inv.Versions) != len(cases) { + t.Fatalf("versions = %d, want %d", len(inv.Versions), len(cases)) + } + for version, want := range cases { + entry, ok := findVersion(inv, version) + if !ok { + t.Fatalf("missing version %s", version) + } + if entry.Sources != want { + t.Fatalf("version %s sources = %+v, want %+v", version, entry.Sources, want) + } + } +} + +func TestBuildInventoryLatestTagBeatsLatestResolvable(t *testing.T) { + // The module-saas-starter#3 failure mode: the newest tag has no + // downloadable artifact, so latest-resolvable lags latest-tag. + releases := []releaseInfo{{version: "0.0.74", hasCIAsset: true}} + tags := []string{"0.0.90", "0.0.74"} + + inv := buildInventory(redisAgent(), releases, tags, nil, nil, false, func(string) bool { return false }) + + if inv.LatestTag != "0.0.90" { + t.Fatalf("latest tag = %q, want 0.0.90", inv.LatestTag) + } + if inv.LatestResolvable != "0.0.74" { + t.Fatalf("latest resolvable = %q, want 0.0.74", inv.LatestResolvable) + } +} + +func TestBuildInventorySortsDescending(t *testing.T) { + tags := []string{"0.0.56", "0.0.74", "0.0.73"} + inv := buildInventory(redisAgent(), nil, tags, nil, nil, false, func(string) bool { return false }) + want := []string{"0.0.74", "0.0.73", "0.0.56"} + for i, entry := range inv.Versions { + if entry.Version != want[i] { + t.Fatalf("versions[%d] = %s, want %s", i, entry.Version, want[i]) + } + } +} + +func TestBuildInventoryOCIMakesVersionResolvable(t *testing.T) { + tags := []string{"0.0.74"} // tag-only, no release asset + oci := func(version string) bool { return version == "0.0.74" } + + inv := buildInventory(redisAgent(), nil, tags, nil, []string{"0.0.74"}, true, oci) + + entry, ok := findVersion(inv, "0.0.74") + if !ok || !entry.Sources.OCI { + t.Fatalf("OCI flag not set: %+v", entry.Sources) + } + if inv.LatestResolvable != "0.0.74" { + t.Fatalf("latest resolvable = %q, want 0.0.74 via OCI", inv.LatestResolvable) + } + if !inv.versionResolvable("0.0.74") { + t.Fatal("pinned version reported unresolvable despite OCI availability") + } +} + +func TestVersionResolvableHandlesLatest(t *testing.T) { + inv := inventory{LatestResolvable: "0.0.74"} + if !inv.versionResolvable("latest") { + t.Fatal("latest should be resolvable when a resolvable version exists") + } + empty := inventory{} + if empty.versionResolvable("latest") { + t.Fatal("latest should be unresolvable when nothing is resolvable") + } +} + +func TestBuildInventorySkipsNonSemverTags(t *testing.T) { + inv := buildInventory(redisAgent(), nil, []string{"main", "0.0.74", "v-broken"}, nil, nil, false, func(string) bool { return false }) + if len(inv.Versions) != 1 || inv.Versions[0].Version != "0.0.74" { + t.Fatalf("versions = %+v, want only 0.0.74", inv.Versions) + } +} + +func TestLocalCacheVersionsScansAgentDir(t *testing.T) { + home := t.TempDir() + t.Setenv("CODEFLY_HOME", home) + + agent := redisAgent() + dir := filepath.Join(home, "agents", "services", agent.Publisher) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"redis__0.0.74", "redis__0.0.10", "redis__notsemver", "vault__0.0.1"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + versions := localCacheVersions(context.Background(), agent) + if len(versions) != 2 { + t.Fatalf("versions = %v, want redis 0.0.74 and 0.0.10", versions) + } + joined := strings.Join(versions, ",") + if !strings.Contains(joined, "0.0.74") || !strings.Contains(joined, "0.0.10") { + t.Fatalf("versions = %v, want both redis versions", versions) + } +} + +func TestTokenTransportAddsAuthorization(t *testing.T) { + var got string + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + })) + defer server.Close() + + client := &http.Client{Transport: &tokenTransport{token: "secret"}} + resp, err := client.Get(server.URL) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if got != "Bearer secret" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer secret") + } +} + +func TestSummarizeWorkspaceAgentsCachesAndFlagsResolvability(t *testing.T) { + restoreReleases, restoreTags := fetchReleases, fetchTags + defer func() { fetchReleases, fetchTags = restoreReleases, restoreTags }() + + var releaseCalls int + fetchReleases = func(_ context.Context, agent *resources.Agent) ([]releaseInfo, error) { + releaseCalls++ + if agent.Name == "redis" { + return []releaseInfo{{version: "0.0.74", hasCIAsset: true}}, nil + } + return nil, nil // vault: tag-only, unpublished + } + fetchTags = func(_ context.Context, agent *resources.Agent) ([]string, error) { + if agent.Name == "redis" { + return []string{"0.0.74"}, nil + } + return []string{"0.0.15"}, nil + } + t.Setenv("AGENT_REGISTRY", "") + + pins := []agentPin{ + {module: "cache", agent: &resources.Agent{Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "redis", Version: "0.0.74"}}, + {module: "session", agent: &resources.Agent{Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "redis", Version: "0.0.74"}}, + {module: "secrets", agent: &resources.Agent{Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "vault", Version: "0.0.15"}}, + } + + summaries := summarizeWorkspaceAgents(context.Background(), pins) + + if releaseCalls != 2 { + t.Fatalf("release lookups = %d, want 2 (one per distinct agent repo)", releaseCalls) + } + if len(summaries) != 2 { + t.Fatalf("summaries = %d, want 2 distinct pins", len(summaries)) + } + + byAgent := map[string]agentSummary{} + for _, summary := range summaries { + byAgent[summary.Agent] = summary + } + redis := byAgent["codefly.dev/redis"] + if !redis.PinnedResolvable { + t.Fatal("redis pin should be resolvable") + } + if len(redis.Modules) != 2 { + t.Fatalf("redis modules = %v, want cache and session", redis.Modules) + } + vault := byAgent["codefly.dev/vault"] + if vault.PinnedResolvable { + t.Fatal("vault pin should be unresolvable (tag-only)") + } + if vault.LatestResolvable != "" { + t.Fatalf("vault latest resolvable = %q, want empty", vault.LatestResolvable) + } + if vault.LatestTag != "0.0.15" { + t.Fatalf("vault latest tag = %q, want 0.0.15", vault.LatestTag) + } +} diff --git a/go.mod b/go.mod index 98d38344..46ddc924 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/fatih/color v1.19.0 github.com/go-git/go-git/v5 v5.19.1 + github.com/google/go-github/v37 v37.0.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.1.1 github.com/rs/cors v1.11.1 @@ -101,7 +102,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/cel-go v0.28.0 // indirect - github.com/google/go-github/v37 v37.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect From 61427fe309bb010d631d046c664ca9020a2eb075 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Thu, 23 Jul 2026 18:06:02 +0200 Subject: [PATCH 2/3] Address review: OCI tag listing, quieter parse, resilient list, precise marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - List OCI registry tags via the distribution-spec tags/list endpoint so a version published only to OCI still surfaces (was invisible when it wasn't also tagged/released/cached). - `agent versions` pins the spec to :latest before parsing so the core "no version specified" warning no longer fires on the bare form. - `agent list` skips a service that fails to load with a warning instead of aborting the whole publishability overview. - Warn on the latest tag only when that tag is itself unresolvable, not on any string difference from latest-resolvable. - Absent tag/pinned/cache cells render "-" (n/a) instead of ✗ (error); nil version list marshals to [] not null. - Cross-check test pins githubSource to manager.DownloadURL as the single source of truth for the owner/repo mapping. Co-Authored-By: Claude Opus 4.8 --- cmd/agents/versions.go | 108 ++++++++++++++++++++++++++---------- cmd/agents/versions_test.go | 101 +++++++++++++++++++++++++++++---- 2 files changed, 171 insertions(+), 38 deletions(-) diff --git a/cmd/agents/versions.go b/cmd/agents/versions.go index 9ae177b0..2322606e 100644 --- a/cmd/agents/versions.go +++ b/cmd/agents/versions.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log/slog" "net/http" "os" "path/filepath" @@ -16,7 +15,6 @@ import ( "github.com/blang/semver" "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" - "github.com/codefly-dev/core/agents/manager" "github.com/codefly-dev/core/resources" "github.com/google/go-github/v37/github" "github.com/spf13/cobra" @@ -33,6 +31,7 @@ const ciPlatform = "linux_amd64" var ( fetchReleases = fetchReleasesFromGitHub fetchTags = fetchTagsFromGitHub + fetchOCITags = fetchOCITagsFromRegistry ) // releaseInfo is one published GitHub release, reduced to what resolvability @@ -96,7 +95,14 @@ var VersionsCmd = &cobra.Command{ ctx, done := common.NewContext() defer done() - agent, err := resources.ParseAgent(ctx, resources.ServiceAgent, args[0]) + // This command lists every version, so the specific version in the + // argument is irrelevant. Pin it to a placeholder so ParseAgent doesn't + // warn "no version specified, using latest" on the common bare form. + spec := args[0] + if !strings.Contains(spec, ":") { + spec += ":latest" + } + agent, err := resources.ParseAgent(ctx, resources.ServiceAgent, spec) if err != nil { return fmt.Errorf("invalid agent: %w", err) } @@ -160,14 +166,18 @@ func collectInventory(ctx context.Context, agent *resources.Agent, pinned []stri cli.Warning("cannot list GitHub tags for %s/%s: %v", agent.Publisher, agent.Name, err) } local := localCacheVersions(ctx, agent) - ociConfigured, ociAvailable := ociVersionChecker(ctx, agent) - return buildInventory(agent, releases, tags, local, pinned, ociConfigured, ociAvailable) + ociConfigured, ociVersions, err := fetchOCITags(ctx, agent) + if err != nil { + cli.Warning("cannot list OCI tags for %s/%s: %v", agent.Publisher, agent.Name, err) + } + return buildInventory(agent, releases, tags, local, pinned, ociVersions, ociConfigured) } // buildInventory is the pure assembly step: given the versions each source // knows about, it unions them, flags every source per version, and derives the -// latest tag and latest resolvable version. -func buildInventory(agent *resources.Agent, releases []releaseInfo, tags, local, pinned []string, ociConfigured bool, ociAvailable func(version string) bool) inventory { +// latest tag and latest resolvable version. ociVersions are the tags an OCI +// registry lists as available; a version present only there still surfaces. +func buildInventory(agent *resources.Agent, releases []releaseInfo, tags, local, pinned, ociVersions []string, ociConfigured bool) inventory { entries := map[string]*versionEntry{} ensure := func(version string) (*versionEntry, bool) { parsed, err := semver.Parse(strings.TrimPrefix(version, "v")) @@ -206,11 +216,9 @@ func buildInventory(agent *resources.Agent, releases []releaseInfo, tags, local, entry.Sources.PinnedHere = true } } - if ociConfigured { - for _, entry := range entries { - if ociAvailable(entry.Version) { - entry.Sources.OCI = true - } + for _, version := range ociVersions { + if entry, ok := ensure(version); ok { + entry.Sources.OCI = true } } @@ -219,6 +227,7 @@ func buildInventory(agent *resources.Agent, releases []releaseInfo, tags, local, CIPlatform: ciPlatform, OCIConfigured: ociConfigured, Pinned: pinned, + Versions: make([]versionEntry, 0, len(entries)), } var latestTag, latestResolvable *semver.Version for _, entry := range entries { @@ -271,7 +280,10 @@ func workspacePins(ctx context.Context, workspace *resources.Workspace) ([]agent for _, ref := range refs { service, err := workspace.LoadService(ctx, ref) if err != nil { - return nil, fmt.Errorf("load service %q: %w", ref.Name, err) + // A single broken service shouldn't blind the whole "are all my + // pins publishable?" overview — skip it with a warning. + cli.Warning("cannot load service %q: %v", ref.Name, err) + continue } if service.Agent == nil { continue @@ -469,19 +481,48 @@ func agentSubdir(agent *resources.Agent) string { } } -// ociVersionChecker reports whether an OCI registry is configured and, if so, -// returns a probe for whether a given version's manifest exists there. -func ociVersionChecker(ctx context.Context, agent *resources.Agent) (bool, func(version string) bool) { - store := manager.NewOCIStoreFromEnv(slog.Default()) - if store == nil { - return false, func(string) bool { return false } +// fetchOCITagsFromRegistry lists the versions an OCI registry publishes for the +// agent via the distribution-spec tags/list endpoint. Listing (rather than +// probing known versions one at a time) is what lets a version published only +// to OCI still appear in the inventory. The first return reports whether a +// registry is configured at all, so the OCI column can distinguish "absent" +// from "not checked". +func fetchOCITagsFromRegistry(ctx context.Context, agent *resources.Agent) (bool, []string, error) { + registry := strings.TrimSpace(os.Getenv("AGENT_REGISTRY")) + if registry == "" { + return false, nil, nil + } + scheme := strings.TrimSpace(os.Getenv("AGENT_REGISTRY_SCHEME")) + if scheme == "" { + if strings.HasPrefix(registry, "localhost") || strings.HasPrefix(registry, "127.0.0.1") { + scheme = "http" + } else { + scheme = "https" + } } - return true, func(version string) bool { - probe := *agent - probe.Version = version - ok, err := store.Available(ctx, &probe) - return err == nil && ok + url := fmt.Sprintf("%s://%s/v2/agents/%s/%s/tags/list", scheme, registry, agent.Publisher, agent.Name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return true, nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return true, nil, err } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return true, nil, fmt.Errorf("registry returned %d", resp.StatusCode) + } + var payload struct { + Tags []string `json:"tags"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return true, nil, err + } + for i := range payload.Tags { + payload.Tags[i] = strings.TrimPrefix(payload.Tags[i], "v") + } + return true, payload.Tags, nil } func renderInventory(inv inventory) { @@ -492,11 +533,11 @@ func renderInventory(inv inventory) { for _, entry := range inv.Versions { fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", entry.Version, - mark(entry.Sources.Tag), + presence(entry.Sources.Tag), mark(entry.Sources.GithubRelease), ociMark(entry.Sources.OCI, inv.OCIConfigured), - mark(entry.Sources.PinnedHere), - mark(entry.Sources.LocalCache), + presence(entry.Sources.PinnedHere), + presence(entry.Sources.LocalCache), ) } _ = tw.Flush() @@ -507,7 +548,7 @@ func renderInventory(inv inventory) { } fmt.Printf("latest tag -> %s\n", dashIfEmpty(inv.LatestTag)) fmt.Printf("latest resolvable -> %s\n", dashIfEmpty(inv.LatestResolvable)) - if inv.LatestTag != "" && inv.LatestTag != inv.LatestResolvable { + if inv.LatestTag != "" && !inv.versionResolvable(inv.LatestTag) { fmt.Printf(" warning: latest tag %s has no downloadable artifact\n", inv.LatestTag) } for _, pin := range inv.Pinned { @@ -544,6 +585,8 @@ func writeJSON(payload any) error { return nil } +// mark is for columns where absence is a meaningful "no" (a release asset that +// isn't there): present ✓, absent ✗. func mark(ok bool) string { if ok { return "✓" @@ -551,6 +594,15 @@ func mark(ok bool) string { return "✗" } +// presence is for columns where absence is just "not applicable" (this version +// isn't tagged / pinned / cached) rather than a failure: present ✓, absent "-". +func presence(ok bool) string { + if ok { + return "✓" + } + return "-" +} + func ociMark(ok, configured bool) string { if !configured { return "-" diff --git a/cmd/agents/versions_test.go b/cmd/agents/versions_test.go index 022660e3..9a589f84 100644 --- a/cmd/agents/versions_test.go +++ b/cmd/agents/versions_test.go @@ -4,11 +4,13 @@ import ( "context" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" "testing" + "github.com/codefly-dev/core/agents/manager" "github.com/codefly-dev/core/resources" ) @@ -39,7 +41,7 @@ func TestBuildInventoryUnionsSourcesAndFlags(t *testing.T) { local := []string{"0.0.74", "0.0.10"} // 0.0.10 only in cache pinned := []string{"0.0.74"} - inv := buildInventory(redisAgent(), releases, tags, local, pinned, false, func(string) bool { return false }) + inv := buildInventory(redisAgent(), releases, tags, local, pinned, nil, false) cases := map[string]sourceFlags{ "0.0.74": {Tag: true, GithubRelease: true, PinnedHere: true, LocalCache: true}, @@ -67,7 +69,7 @@ func TestBuildInventoryLatestTagBeatsLatestResolvable(t *testing.T) { releases := []releaseInfo{{version: "0.0.74", hasCIAsset: true}} tags := []string{"0.0.90", "0.0.74"} - inv := buildInventory(redisAgent(), releases, tags, nil, nil, false, func(string) bool { return false }) + inv := buildInventory(redisAgent(), releases, tags, nil, nil, nil, false) if inv.LatestTag != "0.0.90" { t.Fatalf("latest tag = %q, want 0.0.90", inv.LatestTag) @@ -79,7 +81,7 @@ func TestBuildInventoryLatestTagBeatsLatestResolvable(t *testing.T) { func TestBuildInventorySortsDescending(t *testing.T) { tags := []string{"0.0.56", "0.0.74", "0.0.73"} - inv := buildInventory(redisAgent(), nil, tags, nil, nil, false, func(string) bool { return false }) + inv := buildInventory(redisAgent(), nil, tags, nil, nil, nil, false) want := []string{"0.0.74", "0.0.73", "0.0.56"} for i, entry := range inv.Versions { if entry.Version != want[i] { @@ -89,10 +91,11 @@ func TestBuildInventorySortsDescending(t *testing.T) { } func TestBuildInventoryOCIMakesVersionResolvable(t *testing.T) { - tags := []string{"0.0.74"} // tag-only, no release asset - oci := func(version string) bool { return version == "0.0.74" } + tags := []string{"0.0.74"} // tag-only, no release asset + ociVersions := []string{"0.0.74"} // but published to the OCI registry + pinned := []string{"0.0.74"} - inv := buildInventory(redisAgent(), nil, tags, nil, []string{"0.0.74"}, true, oci) + inv := buildInventory(redisAgent(), nil, tags, nil, pinned, ociVersions, true) entry, ok := findVersion(inv, "0.0.74") if !ok || !entry.Sources.OCI { @@ -106,6 +109,26 @@ func TestBuildInventoryOCIMakesVersionResolvable(t *testing.T) { } } +func TestBuildInventorySurfacesOCIOnlyVersion(t *testing.T) { + // A version present only in the OCI registry — no git tag, no release, + // not cached, not pinned — must still appear as a resolvable row. + inv := buildInventory(redisAgent(), nil, nil, nil, nil, []string{"0.0.99"}, true) + + entry, ok := findVersion(inv, "0.0.99") + if !ok { + t.Fatal("OCI-only version 0.0.99 missing from inventory") + } + if entry.Sources.Tag || entry.Sources.GithubRelease { + t.Fatalf("OCI-only version wrongly flagged as tagged/released: %+v", entry.Sources) + } + if !entry.Sources.OCI || inv.LatestResolvable != "0.0.99" { + t.Fatalf("OCI-only version not resolvable: sources=%+v latest=%q", entry.Sources, inv.LatestResolvable) + } + if inv.LatestTag != "" { + t.Fatalf("latest tag = %q, want empty (no tags)", inv.LatestTag) + } +} + func TestVersionResolvableHandlesLatest(t *testing.T) { inv := inventory{LatestResolvable: "0.0.74"} if !inv.versionResolvable("latest") { @@ -118,7 +141,7 @@ func TestVersionResolvableHandlesLatest(t *testing.T) { } func TestBuildInventorySkipsNonSemverTags(t *testing.T) { - inv := buildInventory(redisAgent(), nil, []string{"main", "0.0.74", "v-broken"}, nil, nil, false, func(string) bool { return false }) + inv := buildInventory(redisAgent(), nil, []string{"main", "0.0.74", "v-broken"}, nil, nil, nil, false) if len(inv.Versions) != 1 || inv.Versions[0].Version != "0.0.74" { t.Fatalf("versions = %+v, want only 0.0.74", inv.Versions) } @@ -168,8 +191,8 @@ func TestTokenTransportAddsAuthorization(t *testing.T) { } func TestSummarizeWorkspaceAgentsCachesAndFlagsResolvability(t *testing.T) { - restoreReleases, restoreTags := fetchReleases, fetchTags - defer func() { fetchReleases, fetchTags = restoreReleases, restoreTags }() + restoreReleases, restoreTags, restoreOCI := fetchReleases, fetchTags, fetchOCITags + defer func() { fetchReleases, fetchTags, fetchOCITags = restoreReleases, restoreTags, restoreOCI }() var releaseCalls int fetchReleases = func(_ context.Context, agent *resources.Agent) ([]releaseInfo, error) { @@ -185,7 +208,9 @@ func TestSummarizeWorkspaceAgentsCachesAndFlagsResolvability(t *testing.T) { } return []string{"0.0.15"}, nil } - t.Setenv("AGENT_REGISTRY", "") + fetchOCITags = func(_ context.Context, _ *resources.Agent) (bool, []string, error) { + return false, nil, nil + } pins := []agentPin{ {module: "cache", agent: &resources.Agent{Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "redis", Version: "0.0.74"}}, @@ -224,3 +249,59 @@ func TestSummarizeWorkspaceAgentsCachesAndFlagsResolvability(t *testing.T) { t.Fatalf("vault latest tag = %q, want 0.0.15", vault.LatestTag) } } + +// TestGithubSourceMatchesManagerDownloadURL guards against drift: githubSource +// reimplements core's unexported owner/repo mapping, so pin it to the one +// exported source of truth (manager.DownloadURL) the actual download uses. +func TestGithubSourceMatchesManagerDownloadURL(t *testing.T) { + for _, agent := range []*resources.Agent{ + {Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "redis", Version: "0.0.74"}, + {Kind: resources.ServiceAgent, Publisher: "acme.co.uk", Name: "multi.part", Version: "1.2.3"}, + } { + owner, repo := githubSource(agent) + u, err := url.Parse(manager.DownloadURL(agent)) + if err != nil { + t.Fatalf("parse download URL: %v", err) + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 2 { + t.Fatalf("unexpected download URL path %q", u.Path) + } + if parts[0] != owner || parts[1] != repo { + t.Fatalf("githubSource = %s/%s, download URL uses %s/%s", owner, repo, parts[0], parts[1]) + } + } +} + +func TestFetchOCITagsListsRegistryVersions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/agents/codefly.dev/redis/tags/list" { + w.Write([]byte(`{"name":"agents/codefly.dev/redis","tags":["0.0.74","v0.0.73"]}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + t.Setenv("AGENT_REGISTRY", strings.TrimPrefix(server.URL, "http://")) + t.Setenv("AGENT_REGISTRY_SCHEME", "http") + + configured, versions, err := fetchOCITagsFromRegistry(context.Background(), redisAgent()) + if err != nil { + t.Fatal(err) + } + if !configured { + t.Fatal("registry configured but reported otherwise") + } + if strings.Join(versions, ",") != "0.0.74,0.0.73" { + t.Fatalf("versions = %v, want [0.0.74 0.0.73] with the v-prefix stripped", versions) + } +} + +func TestFetchOCITagsUnconfigured(t *testing.T) { + t.Setenv("AGENT_REGISTRY", "") + configured, versions, err := fetchOCITagsFromRegistry(context.Background(), redisAgent()) + if err != nil || configured || versions != nil { + t.Fatalf("unconfigured registry: configured=%v versions=%v err=%v", configured, versions, err) + } +} From 5cd1cb608d1139e7ef5d6db9593516c546e2afbf Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Thu, 23 Jul 2026 18:10:49 +0200 Subject: [PATCH 3/3] GitHub-release column reflects all shipped platforms, not just CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column previously showed ✗ for a version whose release ships assets only for the host arch (not linux_amd64), which reads as "no artifact" while local-cache shows ✓ for the same version. Now the release's actual platform set is captured and rendered: "✓ " when the CI asset is present, "✗ ()" when only non-CI assets exist, plain "✗" when the release has no downloadable asset. Resolvability is unchanged — still the CI-platform asset (or OCI). Co-Authored-By: Claude Opus 4.8 --- cmd/agents/versions.go | 59 +++++++++++++++++++++++++++---------- cmd/agents/versions_test.go | 33 ++++++++++++++++++--- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/cmd/agents/versions.go b/cmd/agents/versions.go index 2322606e..35ea7683 100644 --- a/cmd/agents/versions.go +++ b/cmd/agents/versions.go @@ -34,14 +34,16 @@ var ( fetchOCITags = fetchOCITagsFromRegistry ) -// releaseInfo is one published GitHub release, reduced to what resolvability -// needs: the version it tags and whether it carries the CI-platform asset. +// releaseInfo is one published GitHub release: the version it tags and the +// os_arch suffixes it ships a downloadable asset for. type releaseInfo struct { - version string - hasCIAsset bool + version string + platforms []string } -// sourceFlags records, per version, which sources can supply it. +// sourceFlags records, per version, which sources can supply it. GithubRelease +// specifically means the CI-platform asset is present — the resolvability +// signal — regardless of which other platforms the release also ships. type sourceFlags struct { Tag bool `json:"tag"` GithubRelease bool `json:"github_release"` @@ -57,7 +59,11 @@ func (f sourceFlags) resolvable() bool { type versionEntry struct { Version string `json:"version"` Sources sourceFlags `json:"sources"` - sem semver.Version + // ReleasePlatforms lists every os_arch the GitHub release ships an asset + // for, so a version downloadable only for the host (not the CI platform) + // isn't misread as having no artifact at all. + ReleasePlatforms []string `json:"release_platforms,omitempty"` + sem semver.Version } type inventory struct { @@ -196,8 +202,11 @@ func buildInventory(agent *resources.Agent, releases []releaseInfo, tags, local, for _, release := range releases { if entry, ok := ensure(release.version); ok { entry.Sources.Tag = true - if release.hasCIAsset { - entry.Sources.GithubRelease = true + for _, platform := range release.platforms { + entry.ReleasePlatforms = appendUnique(entry.ReleasePlatforms, platform) + if platform == ciPlatform { + entry.Sources.GithubRelease = true + } } } } @@ -231,6 +240,7 @@ func buildInventory(agent *resources.Agent, releases []releaseInfo, tags, local, } var latestTag, latestResolvable *semver.Version for _, entry := range entries { + slices.Sort(entry.ReleasePlatforms) inv.Versions = append(inv.Versions, *entry) if entry.Sources.Tag && (latestTag == nil || entry.sem.GT(*latestTag)) { v := entry.sem @@ -376,15 +386,18 @@ func fetchReleasesFromGitHub(ctx context.Context, agent *resources.Agent) ([]rel } for _, release := range releases { version := strings.TrimPrefix(release.GetTagName(), "v") - wantAsset := fmt.Sprintf("service-%s_%s_%s.tar.gz", agent.Name, version, ciPlatform) - hasAsset := false + assetPrefix := fmt.Sprintf("service-%s_%s_", agent.Name, version) + var platforms []string for _, asset := range release.Assets { - if asset.GetName() == wantAsset { - hasAsset = true - break + platform, ok := strings.CutPrefix(asset.GetName(), assetPrefix) + if !ok { + continue + } + if platform, ok := strings.CutSuffix(platform, ".tar.gz"); ok { + platforms = append(platforms, platform) } } - out = append(out, releaseInfo{version: version, hasCIAsset: hasAsset}) + out = append(out, releaseInfo{version: version, platforms: platforms}) } if resp.NextPage == 0 { break @@ -534,7 +547,7 @@ func renderInventory(inv inventory) { fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", entry.Version, presence(entry.Sources.Tag), - mark(entry.Sources.GithubRelease), + releaseCell(entry), ociMark(entry.Sources.OCI, inv.OCIConfigured), presence(entry.Sources.PinnedHere), presence(entry.Sources.LocalCache), @@ -542,7 +555,7 @@ func renderInventory(inv inventory) { } _ = tw.Flush() - fmt.Printf("\nCI platform: %s | GitHub-release column = downloadable %s asset\n", inv.CIPlatform, inv.CIPlatform) + fmt.Printf("\nGitHub-release: ✓/✗ = %s asset present (CI-downloadable); any other platforms shipped are listed in parentheses\n", inv.CIPlatform) if !inv.OCIConfigured { fmt.Println("OCI column: not checked (set AGENT_REGISTRY)") } @@ -585,6 +598,20 @@ func writeJSON(payload any) error { return nil } +// releaseCell renders the GitHub-release column. ✓/✗ reflects whether the +// CI-platform asset (the resolvability signal) is present; any platforms the +// release actually ships are listed so a host-only asset isn't misread as +// "no artifact at all". +func releaseCell(entry versionEntry) string { + if entry.Sources.GithubRelease { + return "✓ " + strings.Join(entry.ReleasePlatforms, ",") + } + if len(entry.ReleasePlatforms) > 0 { + return "✗ (" + strings.Join(entry.ReleasePlatforms, ",") + ")" + } + return "✗" +} + // mark is for columns where absence is a meaningful "no" (a release asset that // isn't there): present ✓, absent ✗. func mark(ok bool) string { diff --git a/cmd/agents/versions_test.go b/cmd/agents/versions_test.go index 9a589f84..029c3b5d 100644 --- a/cmd/agents/versions_test.go +++ b/cmd/agents/versions_test.go @@ -34,8 +34,8 @@ func findVersion(inv inventory, version string) (versionEntry, bool) { func TestBuildInventoryUnionsSourcesAndFlags(t *testing.T) { releases := []releaseInfo{ - {version: "0.0.74", hasCIAsset: true}, - {version: "0.0.73", hasCIAsset: false}, // release exists but no CI asset + {version: "0.0.74", platforms: []string{ciPlatform, "darwin_arm64"}}, + {version: "0.0.73", platforms: []string{"darwin_arm64"}}, // release exists, but no CI asset } tags := []string{"0.0.74", "0.0.73", "0.0.56"} // 0.0.56 is tag-only local := []string{"0.0.74", "0.0.10"} // 0.0.10 only in cache @@ -61,12 +61,18 @@ func TestBuildInventoryUnionsSourcesAndFlags(t *testing.T) { t.Fatalf("version %s sources = %+v, want %+v", version, entry.Sources, want) } } + + // The host-only release (0.0.73) keeps its platform for display even though + // it isn't CI-resolvable. + if entry, _ := findVersion(inv, "0.0.73"); len(entry.ReleasePlatforms) != 1 || entry.ReleasePlatforms[0] != "darwin_arm64" { + t.Fatalf("0.0.73 release platforms = %v, want [darwin_arm64]", entry.ReleasePlatforms) + } } func TestBuildInventoryLatestTagBeatsLatestResolvable(t *testing.T) { // The module-saas-starter#3 failure mode: the newest tag has no // downloadable artifact, so latest-resolvable lags latest-tag. - releases := []releaseInfo{{version: "0.0.74", hasCIAsset: true}} + releases := []releaseInfo{{version: "0.0.74", platforms: []string{ciPlatform}}} tags := []string{"0.0.90", "0.0.74"} inv := buildInventory(redisAgent(), releases, tags, nil, nil, nil, false) @@ -129,6 +135,25 @@ func TestBuildInventorySurfacesOCIOnlyVersion(t *testing.T) { } } +func TestReleaseCellReflectsPlatforms(t *testing.T) { + cases := []struct { + name string + entry versionEntry + want string + }{ + {"ci asset present", versionEntry{Sources: sourceFlags{GithubRelease: true}, ReleasePlatforms: []string{"darwin_arm64", ciPlatform}}, "✓ darwin_arm64," + ciPlatform}, + {"host only, no ci asset", versionEntry{ReleasePlatforms: []string{"darwin_arm64"}}, "✗ (darwin_arm64)"}, + {"no assets at all", versionEntry{}, "✗"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := releaseCell(tc.entry); got != tc.want { + t.Fatalf("releaseCell = %q, want %q", got, tc.want) + } + }) + } +} + func TestVersionResolvableHandlesLatest(t *testing.T) { inv := inventory{LatestResolvable: "0.0.74"} if !inv.versionResolvable("latest") { @@ -198,7 +223,7 @@ func TestSummarizeWorkspaceAgentsCachesAndFlagsResolvability(t *testing.T) { fetchReleases = func(_ context.Context, agent *resources.Agent) ([]releaseInfo, error) { releaseCalls++ if agent.Name == "redis" { - return []releaseInfo{{version: "0.0.74", hasCIAsset: true}}, nil + return []releaseInfo{{version: "0.0.74", platforms: []string{ciPlatform}}}, nil } return nil, nil // vault: tag-only, unpublished }