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
24 changes: 20 additions & 4 deletions .github/workflows/companions.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,17 +62,26 @@ jobs:
working-directory: cli
run: GOWORK=off GOBIN="${{ github.workspace }}/bin" go install ./cmd/codefly

# A multi-platform buildx push builds every image as one manifest, so the
# arm64 stages need an emulator (QEMU) and a builder that can emit a
# manifest list.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Publish every companion at its pinned version
# Pin linux/amd64: companion images in the registry are amd64, and
# agents/CI pull them on amd64. Explicit so the arch never depends on
# the runner.
run: ${{ github.workspace }}/bin/codefly companion publish --all --platform linux/amd64 --core-dir "${{ github.workspace }}/core"
# Publish multi-arch: every language service agent ships linux/arm64, so
# a companion must too or it can't run a single language service on an
# arm64 host. One manifest list serves both arches.
run: ${{ github.workspace }}/bin/codefly companion publish --all --platform linux/amd64,linux/arm64 --core-dir "${{ github.workspace }}/core"

- name: Verify every embedded tag is in the registry
run: ${{ github.workspace }}/bin/codefly companion verify --core-dir "${{ github.workspace }}/core"
Expand DownExpand Up@@ -107,5 +116,12 @@ jobs:
working-directory: cli
run: GOWORK=off GOBIN="${{ github.workspace }}/bin" go install ./cmd/codefly

- name: Verify a linux/arm64 companion can resolve every language agent
# Guards the arm64 companion footgun: publishing an arm64 companion is
# only safe while every language agent still ships a linux/arm64 asset.
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
run: ${{ github.workspace }}/bin/codefly agent verify-platform linux/arm64

- name: Verify every embedded tag is in the registry
run: ${{ github.workspace }}/bin/codefly companion verify --core-dir "${{ github.workspace }}/core"
1 change: 1 addition & 0 deletions cmd/agent.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,4 +21,5 @@ func init() {
AgentCmd.AddCommand(agents.VersionsCmd)
AgentCmd.AddCommand(agents.ListCmd)
AgentCmd.AddCommand(agents.PromoteSourceCmd)
AgentCmd.AddCommand(agents.VerifyPlatformCmd)
}
85 changes: 85 additions & 0 deletions cmd/agents/verify_platform.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
package agents

import (
"context"
"fmt"
"slices"
"strings"

"github.com/codefly-dev/cli/cmd/common"
"github.com/codefly-dev/cli/pkg/cli"
"github.com/codefly-dev/cli/pkg/sourceworkspace"
"github.com/codefly-dev/core/resources"
"github.com/spf13/cobra"
)

// VerifyPlatformCmd asserts that every language service agent — the source-
// workspace compatibility roster is the CLI's canonical list of them — ships a
// downloadable release asset for a target platform in its latest resolvable
// release.
//
// It guards the arm64 companion footgun: `codefly companion build/publish` on an
// Apple Silicon host defaults to linux/arm64 (dockerArch() → arm64), and a
// companion built for an arch whose language agents publish no asset can't run a
// single language service. Running this for linux_arm64 in CI turns a regression
// — an agent that drops the arch from its releases — into a loud failure here
// instead of an opaque 404 at a runtime pull.
//
// It checks the latest resolvable release, not the roster's pinned version: the
// pin is a source-workspace compatibility value promoted through a separate
// qualification gate (agent promote-source), so the arm64 signal that matters
// for a companion is whether each agent is still shipping the arch at all.
var VerifyPlatformCmd = &cobra.Command{
Use: "verify-platform <os_arch>",
Short: "Verify every language agent ships a release asset for a platform (e.g. linux_arm64)",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
ctx, done := common.NewContext()
defer done()
return verifyRosterPlatform(ctx, normalizePlatform(args[0]))
},
}

// normalizePlatform accepts either the docker "linux/arm64" spelling or the
// release-asset "linux_arm64" spelling and returns the asset spelling, which is
// how a release's shipped platforms are recorded.
func normalizePlatform(platform string) string {
return strings.ReplaceAll(strings.TrimSpace(platform), "/", "_")
}

func verifyRosterPlatform(ctx context.Context, platform string) error {
plugins := sourceworkspace.Roster().Plugins
var missing []string
for _, plugin := range plugins {
agent := plugin.Agent()
ok, version, shipped := platformResolvable(ctx, agent, platform)
if ok {
cli.Info("%s/%s@%s ships %s", plugin.Publisher, plugin.Name, version, platform)
continue
}
missing = append(missing, fmt.Sprintf("%s/%s@%s ships [%s]", plugin.Publisher, plugin.Name, dashIfEmpty(version), strings.Join(shipped, " ")))
}
if len(missing) > 0 {
return fmt.Errorf("%d language agent(s) have no %s release asset — a %s companion cannot resolve them:\n %s",
len(missing), platform, platform, strings.Join(missing, "\n "))
}
cli.Info("all %d language agents resolve for %s", len(plugins), platform)
return nil
}

// platformResolvable reports whether the agent's latest resolvable release ships
// a downloadable asset for the target os_arch, reusing the same release
// inventory as `agent versions`. It returns that version and the platforms it
// ships so a gap reads at a glance.
func platformResolvable(ctx context.Context, agent *resources.Agent, platform string) (ok bool, version string, shipped []string) {
inv := collectInventory(ctx, agent, nil)
if inv.LatestResolvable == "" {
return false, "", nil
}
for _, entry := range inv.Versions {
if entry.Version == inv.LatestResolvable {
return slices.Contains(entry.ReleasePlatforms, platform), entry.Version, entry.ReleasePlatforms
}
}
return false, inv.LatestResolvable, nil
}
61 changes: 61 additions & 0 deletions cmd/agents/verify_platform_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
package agents

import (
"context"
"strings"
"testing"

"github.com/codefly-dev/core/resources"
)

// stubResolvability points the release/tag/OCI/archived seams at in-memory
// fixtures so verifyRosterPlatform runs against the real roster without touching
// GitHub. releasesFor decides what each rostered agent appears to publish.
func stubResolvability(t *testing.T, releasesFor func(agent *resources.Agent) []releaseInfo) {
t.Helper()
restoreReleases, restoreTags, restoreOCI, restoreArchived := fetchReleases, fetchTags, fetchOCITags, repoArchived
t.Cleanup(func() {
fetchReleases, fetchTags, fetchOCITags, repoArchived = restoreReleases, restoreTags, restoreOCI, restoreArchived
})
repoArchived = func(context.Context, *resources.Agent) bool { return false }
fetchOCITags = func(context.Context, *resources.Agent) (bool, []string, error) { return false, nil, nil }
fetchTags = func(context.Context, *resources.Agent) ([]string, error) { return nil, nil }
fetchReleases = func(_ context.Context, agent *resources.Agent) ([]releaseInfo, error) {
return releasesFor(agent), nil
}
}

func TestVerifyRosterPlatformPassesWhenEveryAgentShipsArch(t *testing.T) {
stubResolvability(t, func(_ *resources.Agent) []releaseInfo {
// The latest resolvable release (has the CI asset) also ships arm64.
return []releaseInfo{{version: "9.9.9", platforms: []string{ciPlatform, "linux_arm64"}}}
})
if err := verifyRosterPlatform(context.Background(), "linux_arm64"); err != nil {
t.Fatalf("verifyRosterPlatform = %v, want nil when every agent ships linux_arm64", err)
}
}

func TestVerifyRosterPlatformFailsWhenAnAgentLacksArch(t *testing.T) {
stubResolvability(t, func(agent *resources.Agent) []releaseInfo {
platforms := []string{ciPlatform, "linux_arm64"}
if agent.Name == "rust" {
platforms = []string{ciPlatform} // amd64-only: the arm64 footgun
}
return []releaseInfo{{version: "9.9.9", platforms: platforms}}
})
err := verifyRosterPlatform(context.Background(), "linux_arm64")
if err == nil {
t.Fatal("verifyRosterPlatform = nil, want error when an agent has no linux_arm64 asset")
}
if !strings.Contains(err.Error(), "codefly.dev/rust") {
t.Fatalf("error = %q, want it to name the arm64-less rust agent", err)
}
}

func TestNormalizePlatformAcceptsDockerAndAssetSpelling(t *testing.T) {
for _, in := range []string{"linux/arm64", "linux_arm64", " linux/arm64 "} {
if got := normalizePlatform(in); got != "linux_arm64" {
t.Fatalf("normalizePlatform(%q) = %q, want linux_arm64", in, got)
}
}
}
Loading