Skip to content

fix(release): paginate the release list so the draft reaper sees every release - #625

Merged
joshua-temple merged 2 commits into
mainfrom
fix/release-list-pagination
Jul 17, 2026
Merged

joshua-temple merged 2 commits into
mainfrom
fix/release-list-pagination

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem

listDraftReleases issued GET /releases with no per_page and no Link-header pagination, so it only ever saw GitHub's default first 30 releases.

Its consumer is cleanupStaleDrafts, the reaper that deletes superseded RC drafts. On any repository with more than 30 releases (this one has 173 tags and four pages of releases), the drafts it exists to delete have sunk past page one, so the reaper found nothing and superseded drafts accumulated forever. It was a no-op in exactly the case that needs it. create() logs a cleanup failure as a warning rather than failing, so the truncation was doubly silent.

Verified before fixing: a live GET on this repo's releases returns exactly 30 items with a rel="next" header. gh api repos/stablekernel/cascade/tags returns 30; with --paginate it returns 173.

Existing tests could not catch this: every release stub encodes the whole corpus into one response, so an unpaginated caller reads as correct.

Fix

  • Walk rel="next" at per_page=100 until the API stops advertising a next page.
  • Bound the walk at 50 pages so a malformed or self-referential link chain terminates instead of spinning and holding up a release.
  • Fail loudly on a mid-walk error rather than returning the pages gathered so far. Callers act destructively on the result, so a truncated list would make them report success while skipping the releases they exist to act on.
  • Pin the next-page host to the configured API host. Every request carries a bearer token, so following an arbitrary Link target would hand that token to whoever set the header.
  • Route findReleaseByTagOrSHA, which stopped at the first 100 releases, through the same listing.
  • Pass --paginate to the fleet-e2e and suite-bootstrap-pin fallbacks that scan all tags for a commit SHA.

Verification

Four new tests in internal/release/list_pagination_test.go stage a stub that paginates for real: it honors per_page, slices by page, and emits a genuine Link: <...>; rel="next" header, omitting it on the last page. The corpus is 250 releases, which no single request can cover at any page size.

Before the fix these fail, and the failure is the bug speaking: the lister returned 0 of 2 drafts and the reaper deleted nothing. Also covered: a mid-pagination error surfaces rather than truncating, and an unterminated link chain stops at the bound.

go build ./..., go test ./... -count=1 (3367 pass), go test ./... -race -count=1, golangci-lint run ./... clean. e2e builds and vets. cascade verify --own-repo: no drift. go test ./internal/changelog/... passes post-commit.

No e2e scenario: the harness would need to stage more than 30 releases to exercise the boundary, which is impractical and slow, and a scenario that stages fewer would assert nothing about pagination. The unit test with a real multi-page Link header is the honest bar here.

Touches the release path, so this is fleet-relevant.

Follow-up

Two swallowed-error defects of a different root cause are left out to keep this reviewable: internal/reset/reset.go (status, _ := r.gitOutput(...) treats a git failure as "no changes") and internal/version/command.go (baseSHA, _ = git.GetInitialCommit() proceeds with zero commits). Both are local-git error handling in unrelated packages, not pagination.

…y release

The release list is paginated by the GitHub API. listDraftReleases read a
single response with no per_page and no Link-header walk, so it saw only the
30 most recent releases and the stale-draft reaper never found superseded RC
drafts on any repository with a longer history. Draft cleanup is best-effort,
so the accumulation was silent.

Walk rel="next" at 100 per page, bound the walk so a malformed link chain
cannot spin, fail loudly rather than return a truncated list, and pin the next
page to the API host since every request carries a bearer token. Route draft
resolution by tag or SHA through the same listing. Pass --paginate to the two
fleet tag lookups that scan for a commit SHA across all tags.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
Pinning only the host left the bearer token exposed to the same attacker the
host pin exists to stop: a Link of http://api.github.com keeps the host, passes
the check, and sends the token in cleartext. Compare the scheme too.

The comparison is relative to the configured base rather than a hardcoded
https, so an enterprise deployment reached over plain http via GITHUB_API_URL
advertises http links of its own and still matches. Only a scheme disagreeing
with the base is rejected.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-temple merged commit 1380daa into main Jul 17, 2026
21 checks passed
@joshua-temple
joshua-temple deleted the fix/release-list-pagination branch July 17, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant