diff --git a/.github/workflows/companions.yaml b/.github/workflows/companions.yaml index d63a159d..3bc8faae 100644 --- a/.github/workflows/companions.yaml +++ b/.github/workflows/companions.yaml @@ -62,6 +62,15 @@ 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: @@ -69,10 +78,10 @@ jobs: 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" @@ -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" diff --git a/cmd/agent.go b/cmd/agent.go index 8717f1c4..b0d9928b 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -21,4 +21,5 @@ func init() { AgentCmd.AddCommand(agents.VersionsCmd) AgentCmd.AddCommand(agents.ListCmd) AgentCmd.AddCommand(agents.PromoteSourceCmd) + AgentCmd.AddCommand(agents.VerifyPlatformCmd) } diff --git a/cmd/agents/verify_platform.go b/cmd/agents/verify_platform.go new file mode 100644 index 00000000..1c926040 --- /dev/null +++ b/cmd/agents/verify_platform.go @@ -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 ", + 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 +} diff --git a/cmd/agents/verify_platform_test.go b/cmd/agents/verify_platform_test.go new file mode 100644 index 00000000..66fe2bd5 --- /dev/null +++ b/cmd/agents/verify_platform_test.go @@ -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) + } + } +}