diff --git a/go.mod b/go.mod index 3e858a6b2..b8678dd00 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.5 github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20260414223304-7a662782a11f + github.com/google/go-github/v74 v74.0.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 @@ -184,7 +185,6 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-github/v74 v74.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect diff --git a/pkg/selfupdate/selfupdate.go b/pkg/selfupdate/selfupdate.go index c89d26a3d..514dedeb1 100644 --- a/pkg/selfupdate/selfupdate.go +++ b/pkg/selfupdate/selfupdate.go @@ -71,7 +71,13 @@ func detectRelease( ctx context.Context, opts Options, ) (*selfupdate.Release, *selfupdate.Updater, error) { + source, err := newAllPagesSource() + if err != nil { + return nil, nil, fmt.Errorf("initialize release source: %w", err) + } + updater, err := selfupdate.NewUpdater(selfupdate.Config{ + Source: source, Prerelease: opts.IncludePrerelease, }) if err != nil { diff --git a/pkg/selfupdate/source.go b/pkg/selfupdate/source.go new file mode 100644 index 000000000..9b0a53f0a --- /dev/null +++ b/pkg/selfupdate/source.go @@ -0,0 +1,58 @@ +package selfupdate + +import ( + "context" + "fmt" + "os" + + "github.com/creativeprojects/go-selfupdate" + "github.com/google/go-github/v74/github" +) + +const releasesPerPage = 100 + +// allPagesSource lists releases across all pages so a target release behind +// more than one page of pre-releases is still found. +type allPagesSource struct { + *selfupdate.GitHubSource + api *github.Client +} + +func newAllPagesSource() (*allPagesSource, error) { + base, err := selfupdate.NewGitHubSource(selfupdate.GitHubConfig{}) + if err != nil { + return nil, err + } + client := github.NewClient(nil) + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + client = client.WithAuthToken(token) + } + return &allPagesSource{GitHubSource: base, api: client}, nil +} + +func (s *allPagesSource) ListReleases( + ctx context.Context, + repository selfupdate.Repository, +) ([]selfupdate.SourceRelease, error) { + owner, repo, err := repository.GetSlug() + if err != nil { + return nil, err + } + + opts := &github.ListOptions{PerPage: releasesPerPage} + var releases []selfupdate.SourceRelease + for { + page, resp, err := s.api.Repositories.ListReleases(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("list releases for %s/%s: %w", owner, repo, err) + } + for _, rel := range page { + releases = append(releases, selfupdate.NewGitHubRelease(rel)) + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return releases, nil +} diff --git a/pkg/selfupdate/source_test.go b/pkg/selfupdate/source_test.go new file mode 100644 index 000000000..4430aa772 --- /dev/null +++ b/pkg/selfupdate/source_test.go @@ -0,0 +1,59 @@ +package selfupdate + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/creativeprojects/go-selfupdate" + "github.com/google/go-github/v74/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAllPagesSourceListReleasesPaginates(t *testing.T) { + var requestedPages []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + requestedPages = append(requestedPages, page) + w.Header().Set("Content-Type", "application/json") + + switch page { + case "", "1": + base := "http://" + r.Host + r.URL.Path + w.Header().Set("Link", fmt.Sprintf(`<%s?page=2>; rel="next"`, base)) + _, _ = w.Write([]byte(`[{"tag_name":"v2.0.0-beta.1","prerelease":true}]`)) + case "2": + _, _ = w.Write([]byte(`[{"tag_name":"v1.0.0","prerelease":false}]`)) + default: + _, _ = w.Write([]byte(`[]`)) + } + })) + defer srv.Close() + + client := github.NewClient(nil) + baseURL, err := url.Parse(srv.URL + "/") + require.NoError(t, err) + client.BaseURL = baseURL + + src := &allPagesSource{api: client} + + releases, err := src.ListReleases( + context.Background(), + selfupdate.NewRepositorySlug("owner", "repo"), + ) + require.NoError(t, err) + + require.Len(t, releases, 2, "should aggregate releases from both pages") + assert.Equal(t, "v2.0.0-beta.1", releases[0].GetTagName()) + assert.Equal(t, "v1.0.0", releases[1].GetTagName(), "stable release on page 2 must be returned") + assert.Equal( + t, + []string{"", "2"}, + requestedPages, + "should request page 1 then follow next to page 2", + ) +}