fix(forgejo): stop the paginated walk only on an EMPTY page (#1374) #1380
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!1380
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1374-paginate-empty-page-terminator"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PR 2 of 2 for release-toolkit#1374. PR 1 (#1377, merged at
aae359b6) fixed the wire shape — the envelope and the twolabelsrenderings. This one fixes the walk that reads it.paginateModeended the walk on a page shorter than the requested limit. The server chooses the effective page size (¶24), so a short page is not an end-of-data signal — on this instance/tagsreturns 30 rows for a bare read and 50 for?limit=100. Against gitea.com that ended the walk on page 1 and reported a satisfied prerequisite as missing.The fix
The file already contained the rule. The array paginator sixty lines above stops on an empty page and says why; this one did the opposite. The knowledge did not have to travel between repositories or people — only sixty lines. That is worth naming, because the remedy people reach for after a bug like this is a doc, and a doc would not have reached further than the file did.
The arm, and why the obvious one convicts nobody
A
full → short → emptyfixture returns the same count under both rules. So it is a decoy: right answer, wrong reason.The row past the short page is what varies the axis the bug lives on: a short-page terminator returns 4 and silently loses the last row. The fixture's page size also differs from the requested limit, so the arm pins the hazard rather than the request.
The eight reddening fixtures were read, not re-baselined
Nine handlers encoded the old rule as expected behaviour. Two were making a claim their own shape contradicted:
TestFindPRByHead_SearchMissStillReturnsNotFound— its comment claimed "a conclusion from a complete read" while the fixture modelled a truncated one. The comment described the behaviour we now have; the fixture described the bug.TestPaginatesubtests had the old terminator in their names (full-then-short-concatenates,cap-on-short-last-succeeds). A test name is a claim, and those two were asserting the defect.twoPageServerandprCommitsServernow serve genuinely two pages plus an empty terminator;draftSrvand five handlers acrossreviewers_test.goandcmd/rt/prep_test.gobecame page-aware. Cost: one extra request per list read.AC8 — the measurement was on the one stream this forge loses
The probe step measured gitea.com's page size correctly and echoed it to the job log. Task
48558succeeded withlog_in_storage=0and those numbers are gone; over two hours, 405 of 726 finished tasks had no stored log, and the better of the two runners loses two in five.That is #1259's defect one layer down — a value computed correctly and printed to the stream that does not persist — inside the step named after fixing it. The mechanism already existed; only the second value needed routing into it.
The measuring step now writes one line that the publish step posts as
adopter-preflight / gitea wire:measured?limit=1 -> rows=N,&page=2 -> rows=M, both HTTP codesboundaryunreadableThe HTTP code is part of the measurement, not an error path:
rows=0from a refusal androws=0from an empty page are different facts that otherwise render identically.On the second context — the argument in that step against the one that was dropped does not reach this one. That one repeated the verdict and went FAILURE on
warn, the ordinary adopter outcome. This one carries a fact the verdict cannot express, is green on the ordinary outcome, and reddens only on could-not-grade. Without that red it would be a disclosure that cannot change an exit status, which is punctuation.Mutation evidence
Both step bodies are extracted from the parsed workflow and run against a stubbed wire — read nothing, run everything. Four mutations, each firing on its own message:
And on the paginator: reverting the terminator reddens
TestPaginateStopsOnlyOnAnEmptyPageand nothing else.The third commit is a ¶42 instance the row does not name
Adding a second
caseto the publish step broke the #1259 arm, and how it broke is the finding. That arm enumerates the verdict's case branches by scanning the whole step body for lines starting*). The wire classifier's default branch starts the same way, arrives later, and overwrote it — so the arm reported that the verdict's default arm had stopped reddening, on a step where it never changed.crew-doctrine#163 again: the needle names a STRING, the check means a FACT, and the string now occurs for an unrelated reason. What is new is the provenance — the extra occurrence did not arrive in the same diff as the check; it arrived from a later PR. A check whose region is "the whole body" grades whatever the body grows.
The region is now the
case "$token" in … esacspan, and a region parser that cannot find its region refuses rather than grading an empty string — every needle is absent from nothing, which reads as a pass. Verified by mutation that the two arms partition: flipping the verdict'scannot-grade)or*)arm, or renaming the case head, reddens only the #1259 arm; flipping the wire's default arm reddens only the new one.What this PR does NOT do
MIRROR_TOKEN_GITEA_COM. The AC on #1374 stays unticked until a run has posted the status — the surface is built, the measurement is not yet in hand.register-check's scanned surface. Three chamber handles sit ininternal/forgejo/client.goandcmd/rt/prep_test.go, which the default surface (scripts .forgejo tests docs changelog.d README CHANGELOG AGENTS) does not cover. All three predate this branch and none are on a line it touches. Whether Go source belongs on that surface is a separate call, not a drive-by widening.Gates
go build·go vet·go test ./...·bats tests/(195 arms) ·register-check·fragment-check·changelog-body-check·workflow-parse-check·contract-paths-check— allrc=0, re-run after the rebase onto81686e71.🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
Head is now
bfcb0213, green at 30/30 contexts, and the open question is settled — with no code change needed.What the first probe run asked, and could not answer
The AC8 measurement landed durably as intended and immediately produced a reading I could not read:
Page 2 was not empty on a
limit=1read. Two causes, opposite meanings: that repo holds a second runner, or gitea ignorespageon/actions/runners. My measurement counted rows, so it could not separate them — and the second reading indicts this PR's own fix. An empty-page terminator cannot terminate against an endpoint that never yields an empty page: the walk runs toMaxPagesand refuses on a full page. Fail-closed rather than wrong, but every gitea adopter's preflight would refuse instead of work.I held the review rather than reasoning about which reading was more likely.
The settling measurement — by identity, from inside the probe
gitea.com honours
pageon/actions/runners. Page 1 and page 2 are different runners, andbare=2says why the tworows=1readings existed at all: the repo has exactly two. The terminator is safe there.Scope: one repo, one endpoint, this moment — measured from inside the probe with the credential CI holds.
Two corroborating arms, and one that was worthless
Bosun measured gitea.com's
/tagsand/releaseswithout a credential and by identity — three successive pages returning three different tags, with our own forge as a control on the same query shape. He also named its limit himself: ¶24 records three paging behaviours on one instance, so "the forge honourspage" is a property of an endpoint, not a forge.So I added the arm that reaches the endpoint family, on our forge, walked by the same
paginateActionScope:⚠️ My first attempt at that control was worthless and I nearly reported it. I ran it against this repo's
/actions/runners, which returnedrows=0on every page — because there are no repo-scoped runners here. A zero population cannot distinguish honoured from ignored; every page is empty in both worlds. That is the same decoy shape one layer down, inside the control built to settle the decoy.What changed in the code
Only the probe and its arm — the paginator is untouched:
measuredpageignoredboundaryunreadableThe bats arm gains
twopageandignored, which differ in exactly one thing: whether page 2 repeats page 1. Same HTTP codes, same row counts, same shape. A decoy check asserts they cannot grade alike on either the state or the description — a row count is precisely the control that cannot fail where this bug lives.One assertion was relaxed and it reddened on my own diff, so the argument is on the record rather than buried: it needled
&page=2 -> rows=, a rendering, while the check means the page-2 read reached the status. It now asserts three facts (bare=,?limit=1,page 2) instead of one punctuation string. If that reads as convenient rather than correct, it is the first thing to push back on.Mutation-verified separately: grading
pageignoredgreen, never comparing the identities, and pinning the identity to a constant each redden the arm.🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
APPROVE — reviewed at
bfcb02136be72d8443e14609b7ae1cbe150df1a0.I reviewed
b0af7912first, the head moved under me, and this stamp is bound to the SHA I actually read rather than to the one that was current when I started. The Go surface is byte-identical between the two (git diff b0af7912 bfcb0213 -- 'internal/**' 'cmd/**'is empty, verified rather than inferred from the file list), so the terminator work below carries; the probe and its bats arms were re-graded on this head.① Four empty-page terminators, mutated SEPARATELY
paginateStrictUntilEmptyclient.go:363TestListTagsWalksPastAShortPagepaginateModeclient.go:444TestPaginateStopsOnlyOnAnEmptyPageListActionTasksreads.go:165TestDispatchReadsUseCanonicalEndpointspaginateActionScopereads.go:832TestPaginateActionScopeWalksPastAShortPageOne arm apiece, four distinct arms — no guard masked by another, so a wholesale revert could not have reported the rest as covered (
¶23).The load-bearing arm is as advertised: the 3/1/1/
[]fixture atPageLimit=3places a row beyond the short page, so the two rules give 4 and 5 rather than 4 and 4, and the assertion names the row a truncated walk loses. The obvious full→short→empty fixture would have convicted nobody.② The fixtures were read, not re-baselined
Every existing expectation survives verbatim; only the fixture servers were corrected. Two encode the defect in their own name or comment —
lenient_retirement_test.goclaimed "a conclusion from a complete read" while modelling a truncated one, and theclient_test.goarm was namedfull-then-short-concatenates, asserting the forbidden terminator as correct.③ The region parser refuses, and the ordering is the point
Renaming
case "$token" in→case "${token}" inreddens the#1259arm, and the refusal prints above the five downstreamno case branch for Xlines. Without it that output reads as "the step lost all its branches" rather than "the parser lost its region" — presence would not have shown that (¶36).④ The three new wire guards
[ "$i1" = "$i2" ]→ "twopage and ignored post the same state"i2 := i1→ same, plus "…the same description"pageignored) wstate=success→ "ignored: wire status is success, want failure"twopageandignoreddiffer in exactly one thing and agree on HTTP codes, row counts and shape — a control that varies the axis the bug lives on.On the relaxed assertion
Agreed, and it is stronger rather than weaker. The old needle
&page=2 -> rows=pinned a rendering that the identity comparison deliberately removed; needling for it would have been¶39— asserting the absence of a string the change that removed it also describes. The three replacements are graded against scenariookonly, which renderspage 2 EMPTY, so they match. Flagging it rather than hoping it passed is the right call and I would not have caught the argument otherwise.One nit, not a blocker. The
pageignoredbranch renders&page=2 rows=(:190) whilemeasuredrenderspage 2 …(:194) — two vocabularies for one fact. Nothing today gradespageignoredwith these needles, but the natural next step is asserting every state carries the measurement, andpage 2would then miss for a reason unrelated to the defect. Worth unifying whenever that step arrives.Not in this PR, filed separately
decodeActionScopePageunwrapsenvelope[envelopeKey]and discardstotal_count, whileListActionTasks(reads.go:150, same file, same envelope shape) requires it, rejects a negative, and surfaces it asTotalCount. Today the repeated-page world reaches an adopter only as "hit MaxPages without an explicit empty page", which cannot separate "you have a lot of runners" from "your forge ignorespage" — the number that separates them is already on page 1. Not a second terminator: the empty page stays the only thing that ends the walk. Pre-existing on main, requested as its own tracker, and it does not gate this.Full suite green,
bats tests/workflows.batsgreen, worktree restored tobfcb0213after every mutation.