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
38 changes: 4 additions & 34 deletions cmd/agents/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
Expand All @@ -16,6 +15,7 @@ import (
"github.com/blang/semver"
"github.com/codefly-dev/cli/cmd/common"
"github.com/codefly-dev/cli/pkg/cli"
"github.com/codefly-dev/cli/pkg/gh"
"github.com/codefly-dev/core/resources"
"github.com/google/go-github/v89/github"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -416,7 +416,7 @@ func pinnedVersions(ctx context.Context, agent *resources.Agent) []string {
}

func fetchReleasesFromGitHub(ctx context.Context, agent *resources.Agent) ([]releaseInfo, error) {
client, err := newGitHubClient()
client, err := gh.NewClient()
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -452,7 +452,7 @@ func fetchReleasesFromGitHub(ctx context.Context, agent *resources.Agent) ([]rel
}

func fetchTagsFromGitHub(ctx context.Context, agent *resources.Agent) ([]string, error) {
client, err := newGitHubClient()
client, err := gh.NewClient()
if err != nil {
return nil, err
}
Expand All @@ -478,37 +478,7 @@ func fetchTagsFromGitHub(ctx context.Context, agent *resources.Agent) ([]string,
// githubSource mirrors manager.toGithubSource (unexported): the publisher's
// dots become dashes and the repo is service-<name>.
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, error) {
if token := githubToken(); token != "" {
return github.NewClient(github.WithAuthToken(token))
}
return github.NewClient()
}

// githubToken resolves a GitHub token from GITHUB_TOKEN/GH_TOKEN, falling back
// to the `gh` CLI's stored credential. Without the `gh` fallback, `agent list`/
// `versions` runs unauthenticated (60 req/hour) and reports resolvable versions
// as "-" the moment a workspace has several pins to check — a confusing false
// negative on a machine that is in fact fully authenticated via `gh`.
func githubToken() string {
if t := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); t != "" {
return t
}
if t := strings.TrimSpace(os.Getenv("GH_TOKEN")); t != "" {
return t
}
out, err := exec.Command("gh", "auth", "token").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
return gh.Owner(agent.Publisher), "service-" + agent.Name
}

func localCacheVersions(ctx context.Context, agent *resources.Agent) []string {
Expand Down
36 changes: 0 additions & 36 deletions cmd/agents/versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,42 +303,6 @@ func TestLocalCacheVersionsScansAgentDir(t *testing.T) {
}
}

func TestNewGitHubClientAddsAuthorization(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "secret")
var got string
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
got = r.Header.Get("Authorization")
}))
defer server.Close()

client, err := newGitHubClient()
if err != nil {
t.Fatalf("newGitHubClient: %v", err)
}
resp, err := client.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 TestNewGitHubClientUnauthenticated(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "")
t.Setenv("GH_TOKEN", "")
t.Setenv("PATH", "") // no `gh` on PATH: force the tokenless path

client, err := newGitHubClient()
if err != nil {
t.Fatalf("newGitHubClient: %v", err)
}
if client == nil {
t.Fatal("newGitHubClient returned a nil client")
}
}

func TestSummarizeWorkspaceAgentsCachesAndFlagsResolvability(t *testing.T) {
restoreReleases, restoreTags, restoreOCI := fetchReleases, fetchTags, fetchOCITags
defer func() { fetchReleases, fetchTags, fetchOCITags = restoreReleases, restoreTags, restoreOCI }()
Expand Down
111 changes: 74 additions & 37 deletions cmd/publish/agent_release.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import (
"strings"
"time"

"github.com/codefly-dev/cli/pkg/gh"
"github.com/codefly-dev/core/agents/manager"
"github.com/codefly-dev/core/resources"
"github.com/google/go-github/v89/github"
"gopkg.in/yaml.v3"
)

Expand All @@ -42,14 +44,15 @@ var loaderPlatforms = []platform{
// checkAgentReleasePreconditions fails fast on the two things that would
// otherwise only surface AFTER the expensive CI run (or, in `publish
// all`, after earlier repos already shipped): a host that can't build
// every loader platform, and a missing gh CLI. Both are deterministic and
// every loader platform, and the absence of any GitHub credential to
// authenticate the release API calls. Both are deterministic and
// side-effect free, so they are safe to run during the validate phase.
func checkAgentReleasePreconditions() error {
if err := hostBuildsLoaderPlatforms(); err != nil {
return err
}
if _, err := exec.LookPath("gh"); err != nil {
return fmt.Errorf("the gh CLI is required to upload agent release assets but is not on PATH: %w", err)
if gh.Token() == "" {
return fmt.Errorf("a GitHub token is required to publish agent release assets; set GITHUB_TOKEN or GH_TOKEN, or authenticate the gh CLI (gh auth login)")
}
return nil
}
Expand Down Expand Up @@ -104,7 +107,7 @@ func loaderSBOMName(reg *resources.AgentKindRegistration, name, version string,
// (the resolver only knows the host platform). Consistency with the real
// resolver is asserted in verifyReleaseAssets for the host target.
func loaderDownloadURL(reg *resources.AgentKindRegistration, publisher, name, version string, p platform) string {
owner := strings.ReplaceAll(publisher, ".", "-")
owner := gh.Owner(publisher)
return fmt.Sprintf("https://github.com/%s/%s/releases/download/v%s/%s",
owner, reg.GitHubRepository(name), version, loaderArchiveName(reg, name, version, p))
}
Expand Down Expand Up @@ -281,47 +284,77 @@ func runReleaseAgentCI(ctx context.Context, self, agentDir, output string, nativ
}

// createAndUploadRelease publishes every staged loader archive and SBOM to
// the GitHub release for tag. gh runs from workDir so it resolves the
// repository from the origin remote.
// the GitHub release for tag in owner/repo.
//
// Idempotent by design: it creates the release on the first publish, or
// uploads into an existing one (clobbering same-named assets) on a retry
// or `re-tag`. Without this a re-run after a partial upload would error on
// the already-existing release, stranding a half-uploaded release.
func createAndUploadRelease(ctx context.Context, workDir, tag string, assets []loaderAsset) error {
files := make([]string, 0, len(assets)*2)
func createAndUploadRelease(ctx context.Context, client *github.Client, owner, repo, tag string, assets []loaderAsset) error {
release, err := getOrCreateRelease(ctx, client, owner, repo, tag)
if err != nil {
return err
}
existing := map[string]int64{}
for _, asset := range release.Assets {
existing[asset.GetName()] = asset.GetID()
}
for _, asset := range assets {
files = append(files, asset.archivePath)
files := []string{asset.archivePath}
if asset.sbomPath != "" {
files = append(files, asset.sbomPath)
}
for _, file := range files {
if err := uploadReleaseAsset(ctx, client, owner, repo, release.GetID(), file, existing); err != nil {
return err
}
}
}
var args []string
if releaseExists(ctx, workDir, tag) {
args = append([]string{"release", "upload", tag}, files...)
args = append(args, "--clobber")
} else {
args = append([]string{"release", "create", tag, "--title", tag, "--notes", "Release " + tag}, files...)
return nil
}

// getOrCreateRelease returns the existing release for tag, or creates one when
// none exists yet. A non-404 lookup error is surfaced rather than masked as a
// missing release, so a transient API failure can't silently spawn a duplicate.
func getOrCreateRelease(ctx context.Context, client *github.Client, owner, repo, tag string) (*github.RepositoryRelease, error) {
release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
if err == nil {
return release, nil
}
cmd := exec.CommandContext(ctx, "gh", args...)
cmd.Dir = workDir
cmd.Env = os.Environ()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("publish GitHub release %s: %w", tag, err)
if resp == nil || resp.StatusCode != http.StatusNotFound {
return nil, fmt.Errorf("look up GitHub release %s: %w", tag, err)
}
return nil
created, _, err := client.Repositories.CreateRelease(ctx, owner, repo, github.CreateReleaseRequest{
TagName: tag,
Name: github.Ptr(tag),
Body: github.Ptr("Release " + tag),
})
if err != nil {
return nil, fmt.Errorf("create GitHub release %s: %w", tag, err)
}
return created, nil
}

// releaseExists reports whether a GitHub release already exists for tag.
func releaseExists(ctx context.Context, workDir, tag string) bool {
cmd := exec.CommandContext(ctx, "gh", "release", "view", tag)
cmd.Dir = workDir
cmd.Env = os.Environ()
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
return cmd.Run() == nil
// uploadReleaseAsset uploads path into the release, replicating `gh --clobber`:
// an already-present asset of the same name is deleted first, since the GitHub
// API rejects uploading a duplicate name into a release.
func uploadReleaseAsset(ctx context.Context, client *github.Client, owner, repo string, releaseID int64, path string, existing map[string]int64) error {
name := filepath.Base(path)
if id, ok := existing[name]; ok {
if _, err := client.Repositories.DeleteReleaseAsset(ctx, owner, repo, id); err != nil {
return fmt.Errorf("replace existing release asset %s: %w", name, err)
}
delete(existing, name)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open release asset %s: %w", path, err)
}
defer file.Close()
if _, _, err := client.Repositories.UploadReleaseAsset(ctx, owner, repo, releaseID, &github.UploadOptions{Name: name}, file); err != nil {
return fmt.Errorf("upload release asset %s: %w", name, err)
}
return nil
}

// verifyReleaseAssets confirms every uploaded loader archive resolves
Expand Down Expand Up @@ -387,7 +420,6 @@ func assertAssetReachable(ctx context.Context, url string) error {
type agentReleaser struct {
self string
agentDir string
workDir string
reg *resources.AgentKindRegistration
skipConformance bool
publisher string
Expand Down Expand Up @@ -428,14 +460,14 @@ var sourceTagKinds = map[string]bool{
}

// newAgentReleaseGate selects release behavior from the manifest kind.
func newAgentReleaseGate(agentDir, workDir string) (releaseGate, error) {
func newAgentReleaseGate(agentDir string) (releaseGate, error) {
identity, err := readAgentIdentity(filepath.Join(agentDir, "agent.codefly.yaml"))
if err != nil {
return nil, err
}
switch {
case loaderAssetKinds[identity.Kind]:
return newAgentReleaser(agentDir, workDir)
return newAgentReleaser(agentDir)
case sourceTagKinds[identity.Kind]:
return newSourceTagReleaser(agentDir, identity.Kind == string(resources.ModuleAgent))
default:
Expand Down Expand Up @@ -477,7 +509,7 @@ func unsupportedReleaseKindError(kind string) error {
return fmt.Errorf("publish supports %s; got %q", strings.Join(supported, ", "), kind)
}

func newAgentReleaser(agentDir, workDir string) (*agentReleaser, error) {
func newAgentReleaser(agentDir string) (*agentReleaser, error) {
if err := checkAgentReleasePreconditions(); err != nil {
return nil, err
}
Expand Down Expand Up @@ -509,7 +541,6 @@ func newAgentReleaser(agentDir, workDir string) (*agentReleaser, error) {
return &agentReleaser{
self: self,
agentDir: agentDir,
workDir: workDir,
reg: &reg,
skipConformance: reg.Resource != resources.ServiceAgent,
publisher: identity.Publisher,
Expand Down Expand Up @@ -544,7 +575,13 @@ func (r *agentReleaser) beforeCommit(ctx context.Context, newTag string) error {

func (r *agentReleaser) afterPush(ctx context.Context, newTag string) error {
version := strings.TrimPrefix(newTag, "v")
if err := createAndUploadRelease(ctx, r.workDir, newTag, r.assets); err != nil {
owner := gh.Owner(r.publisher)
repo := r.reg.GitHubRepository(r.name)
client, err := gh.NewClient()
if err != nil {
return err
}
if err := createAndUploadRelease(ctx, client, owner, repo, newTag, r.assets); err != nil {
return err
}
return verifyReleaseAssets(ctx, r.reg, r.publisher, r.name, version, r.assets)
Expand Down
10 changes: 5 additions & 5 deletions cmd/publish/agent_release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func TestModuleAndProviderSelectSourceTagGateWithoutLoaderAssets(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.codefly.yaml"), manifest, 0o644))

require.NoError(t, checkAgentReleasePreconditionsForManifest(filepath.Join(dir, "agent.codefly.yaml")))
gate, err := newAgentReleaseGate(dir, dir)
gate, err := newAgentReleaseGate(dir)
require.NoError(t, err)
defer gate.cleanup()
releaser, ok := gate.(*sourceTagReleaser)
Expand All @@ -156,8 +156,8 @@ func TestModuleAndProviderSelectSourceTagGateWithoutLoaderAssets(t *testing.T) {

func TestLoaderAssetGateSelectsRegistrationAndConformance(t *testing.T) {
// Loader-asset publishing requires a host that can build every loader
// platform plus gh — the same gate a real service/toolbox publish hits.
// Skip where that can't be exercised.
// platform plus a resolvable GitHub token — the same gate a real
// service/toolbox publish hits. Skip where that can't be exercised.
if err := checkAgentReleasePreconditions(); err != nil {
t.Skipf("host cannot exercise loader-asset publishing: %v", err)
}
Expand All @@ -174,7 +174,7 @@ func TestLoaderAssetGateSelectsRegistrationAndConformance(t *testing.T) {
manifest := []byte("publisher: codefly.dev\nkind: " + tc.kind + "\nname: web\nversion: 0.0.14\n")
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.codefly.yaml"), manifest, 0o644))

gate, err := newAgentReleaseGate(dir, dir)
gate, err := newAgentReleaseGate(dir)
require.NoError(t, err)
defer gate.cleanup()
releaser, ok := gate.(*agentReleaser)
Expand Down Expand Up @@ -202,7 +202,7 @@ func TestUnsupportedAgentKindFailsClosedWithActionableError(t *testing.T) {
manifest := []byte("publisher: codefly.dev\nkind: codefly:job\nname: batch\nversion: 0.0.1\n")
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.codefly.yaml"), manifest, 0o644))

_, err := newAgentReleaseGate(dir, dir)
_, err := newAgentReleaseGate(dir)
require.ErrorContains(t, err, "publish supports")
require.ErrorContains(t, err, "codefly:service")
require.ErrorContains(t, err, `got "codefly:job"`)
Expand Down
2 changes: 1 addition & 1 deletion cmd/publish/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func runAll(c *cobra.Command, args []string) error {
timeout := 120 * time.Second
var releaser releaseGate
if t.Manifest.Mode == ModeAgent {
releaser, err = newAgentReleaseGate(filepath.Dir(t.Manifest.Path), t.Dir)
releaser, err = newAgentReleaseGate(filepath.Dir(t.Manifest.Path))
if err != nil {
return fmt.Errorf("prepare agent release for %s: %w", relOrBase(root, t.Dir), err)
}
Expand Down
6 changes: 4 additions & 2 deletions cmd/publish/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ func run(c *cobra.Command, args []string) error {
// bare tag push, so they get a generous timeout.
timeout := 60 * time.Second
if manifest.Mode == ModeAgent && !dryRun {
releaser, err := newAgentReleaseGate(filepath.Dir(manifest.Path), workDir)
var releaser releaseGate
releaser, err = newAgentReleaseGate(filepath.Dir(manifest.Path))
if err != nil {
return err
}
Expand Down Expand Up @@ -169,7 +170,8 @@ func runReTag(c *cobra.Command, _ []string) error {
// but failed to upload. Same generous timeout as publish.
timeout := 60 * time.Second
if manifest.Mode == ModeAgent && !dryRun {
releaser, err := newAgentReleaseGate(filepath.Dir(manifest.Path), workDir)
var releaser releaseGate
releaser, err = newAgentReleaseGate(filepath.Dir(manifest.Path))
if err != nil {
return err
}
Expand Down
Loading
Loading