From 1b95c0f1ed329459cf1fcdb0599ff7f91c54a141 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 22 Jun 2026 19:12:22 -0500 Subject: [PATCH 1/2] fix(selfupdate): list all release pages so latest stable is found `self update` (and its e2e dry-run test) failed with "no release found" once more than ~30 pre-releases had shipped since the last stable release. go-selfupdate's default GitHub source lists only the first page (30 releases), so the latest stable fell onto page 2 and was never seen by DetectLatest/DetectVersion. Inject a custom Source that pages through all releases via go-github, embedding the stock GitHubSource so asset downloads are unchanged. Fixes both stable and beta channels regardless of how many pre-releases precede the target. Co-Authored-By: Claude Opus 4.8 --- go.mod | 2 +- pkg/selfupdate/selfupdate.go | 6 ++++ pkg/selfupdate/source.go | 58 +++++++++++++++++++++++++++++++++++ pkg/selfupdate/source_test.go | 51 ++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 pkg/selfupdate/source.go create mode 100644 pkg/selfupdate/source_test.go 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..60f4f2a3c --- /dev/null +++ b/pkg/selfupdate/source_test.go @@ -0,0 +1,51 @@ +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") +} From 3d6befacab799ee860e85d6d6e6d3c0f1eeefa3f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 23 Jun 2026 10:22:24 -0500 Subject: [PATCH 2/2] style(selfupdate): wrap long lines in source_test to satisfy golines Co-Authored-By: Claude Opus 4.8 --- pkg/selfupdate/source_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/selfupdate/source_test.go b/pkg/selfupdate/source_test.go index 60f4f2a3c..4430aa772 100644 --- a/pkg/selfupdate/source_test.go +++ b/pkg/selfupdate/source_test.go @@ -41,11 +41,19 @@ func TestAllPagesSourceListReleasesPaginates(t *testing.T) { src := &allPagesSource{api: client} - releases, err := src.ListReleases(context.Background(), selfupdate.NewRepositorySlug("owner", "repo")) + 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") + assert.Equal( + t, + []string{"", "2"}, + requestedPages, + "should request page 1 then follow next to page 2", + ) }