ci(release): the milestone gate reads 200 PRs and vouches for the rest - #362
Conversation
`check-milestone-landed.sh` asks `gh pr list --search "milestone:X"` for at
most 200 rows. `--limit` is a cap, not a request: `gh` returns that many and
says nothing about the ones it dropped. A milestone larger than the cap has its
most recent 200 PRs checked, and the script then prints
OK: every merged PR in milestone 'X' is an ancestor of <sha>
about a set nobody read. That is the vacuous-check shape this script's own
header refuses everywhere else -- the absent-milestone case is fatal for
exactly this reason.
Raise the limit to 1000, where `gh pr list --search` truncates anyway, and
refuse a full page instead of checking a prefix of it.
|
Re-classifying this to milestone 2.1.0. It was correctly 2.2.0 when filed; the tree has since crossed the threshold it describes. Measured on 199 merged PRs against a hard cap of 200. This PR's own explanatory comment says "Milestone 2.1.0 carries 151 merged PRs today, so the old cap was 49 away from silently under-reporting" — that was true when written and is now stale by 48. The margin is one PR. Worth re-measuring that sentence before merge. The failure direction is open, confirmed with a single-call-site mutation on a copy of the script ( Arm B read 5 of 199 and still vouched for the whole milestone. Past the cap the gate reports success for a question it never asked — the vacuous-check shape the script's own header refuses everywhere else. Why this becomes a 2.1.0 blocker rather than a 2.2.0 cleanup: every PR added to milestone 2.1.0 before the tag raises the count. At 201 the gate silently drops one. And Landing this resolves it at any round size: it raises the cap to 1000 and, more importantly, refuses a full page rather than checking a prefix. Merging it into milestone 2.1.0 makes it the 200th merged PR — exactly at the old cap, so still complete — and removes the ceiling for everything after. Recommend landing this before the tag. |
… at startup (#425) ## What this fixes `_RecipeWatchState.recipe` was the body parsed when the process started. Nothing refreshed it — the only two assignments to it sat inside YAML-error-recovery branches — so for a recipe that always parses cleanly it stayed the startup body for the life of the process. #417 fixed one reader of that field (the recipe-drift warning) by adding a second field, `latest_recipe`, refreshed on every rescan. **Three more readers were left on the startup body.** This PR fixes all three by making the one field mean what its name says: *the recipe as it is on disk now*. ### 1. `item_metadata` — a retrain silently produced a mixed response `_build_entry` joins the response against the table the recipe names. Repoint `item_metadata.path` **and retrain**, and the new artifact hot-swaps in while the join still reads the table the *startup* body named. Nothing warns, because the artifact and the recipe on disk now legitimately agree — the drift warning is silent by design in exactly this case. **Which body it reads now, and why that one.** The watcher cannot reconstruct the body an artifact was trained from; the header carries only that body's `recipe_hash`, not the body. So "the trained-from body" is not an option that exists — the startup body was only ever a proxy for it, and it stops being one at the first hot-swap: after an edit and a retrain, the startup body is neither the body on disk nor the body the new artifact was trained from. The body on disk is the only one the watcher can name, and it is already the one `app.py` uses on the startup load path, so a hot-swap and a restart now build the same entry from the same bytes. That parity is the same reason the recipe-hash gate was mirrored onto both load paths in #270. When the two bodies do differ — an edit with no retrain — the drift warning is already firing, and this PR corrects that warning's docstring, which claimed the response is "of a piece" while it fires. It is not: what comes out of the artifact is the older recipe, while `item_metadata` is read from the recipe on disk at load time. The docstring now says which half is which. ### 2. `output.path` — an edit was ignored until restart `state.artifact_path` was taken from `recipe.output.path` once, at discovery. An edited output path was ignored for the life of the process, and silently: the drift warning is emitted from the load path, and no load ever happens at the new location, so nothing anywhere says the watcher is polling a file the recipe no longer names. The rescan now follows it. A re-point drops everything the watcher remembers about the previous file — change marker, payload digest, `.sha256` sidecar contents, load backoff, repeat-suppression signatures — because none of it says anything about the new path. Dropping `last_sha256` matters in particular: a new path holding byte-identical content would otherwise take the "unchanged bytes" short-circuit and never rebuild the entry, leaving the served entry naming the old path. One INFO line, `recipe_output_path_changed`, carries `previous_path` and `path`. **A path that does not exist yet** — the ordinary case, an operator who edited the recipe and has not retrained — degrades rather than evicting. The model in the registry keeps serving, the next poll records "artifact missing or unreadable" against the entry and `/v1/health/details` goes degraded. That is the same M-2 contract every other missing artifact already gets, and there is a test for it. ### 3. The `.sha256` sidecar suppression latch could never be released Three consecutive unreadable sidecar reads set `sidecar_unsupported` so a broken sidecar cannot drive a full reload on every tick (m7). The documented escape — clear it when the recipe changes (C4) — was reached through: ```python getattr(state.recipe, "_yaml_path", None) or getattr(state.recipe, "yaml_path", None) ``` `Recipe` is a pydantic model with `extra="forbid"` and no private attributes, so it has never carried either name. That chain has answered `None` for every recipe that has ever existed, the guard it feeds (`yaml_mtime is None or ...`) always declined, and the recovery has never run once in a live server. The latch was permanent, and the sidecar is a real backstop: it is what catches a change the `(mtime, size)` marker cannot discriminate. **Chosen fix: make the recovery work, rather than delete it.** Deleting the dead chain would have been a pure no-op — the latch is already permanent in practice — and would have made "no recovery at all" the design. Instead the clearing moves to `_scan_recipes_dir`, where the watcher *already* decides that the YAML changed (`_yaml_mtime_cache` miss). That is a signal the watcher genuinely has, it removes a second, independently-drifting mtime comparison, and it is now load-bearing for a reason the old placement did not have: with fix 2 above, a recipe edit can move `output.path`, and the sidecar path is `artifact_path + ".sha256"`, so an edit really can move the sidecar out from under a latch set on the old one. The three dead `getattr` chains and the `sidecar_unsupported_at_mtime` field they compared against are removed. **The test for this recovery passed, and proved nothing.** `test_sidecar_unsupported_clears_on_yaml_mtime_change` built the state around a `MagicMock` with `recipe._yaml_path = yaml_path` — the only kind of object in existence for which the code worked. It is replaced by `test_sidecar_unsupported_is_never_cleared_by_the_sidecar_check_itself`, which uses a real `Recipe` and pins the other half of the split, so a reintroduced mtime probe in `_check_sidecar_changed` fails there. ## Pre-existing since 2.0.0 — evidence None of these is a regression from #415, #417, #418 or #362. | defect | evidence | |---|---| | `item_metadata` from the startup body | `_build_entry(name, state.recipe, ...)` and `_RecipeWatchState` come from `8395a811` (2026-05-14, the 2.0 rewrite); `git merge-base --is-ancestor 8395a81 v2.0.0` succeeds | | frozen `output.path` | same commit introduced `_RecipeWatchState.artifact_path` | | dead `sidecar_unsupported` recovery | `git show v2.0.0:src/recotem/serving/watcher.py \| grep -c '_yaml_path", None'` → **3**, the same count as on `main`; the chains are at lines 1245 / 1277 / 1338 of the 2.0.0 file, and `git show v2.0.0:src/recotem/recipe/models.py` has no `yaml_path` | A note on the dating, because the obvious command misleads: `git log -S '_yaml_path' -- src/recotem/serving/watcher.py` returns `f15ebbd6` (2026-09-03, #167), which is not in `v2.0.0` and reads like "new in 2.1.0". `-S` reports commits that changed the *count* of the string, and #167's only `_yaml_path` line is a docstring mention of the unrelated `_yaml_path_to_name` map. The `getattr` chains themselves are untouched since 2.0.0. ## Tests Each behaviour has a test that fails on unmodified `main` **on its assertion**, not on an import or a signature error. New file `tests/unit/test_watcher_live_recipe_body.py`. On `main` (696d55c), before the fix — 4 failed, 1 passed: ``` E AssertionError: the artifact that just swapped in was trained FROM the edited recipe, yet the metadata join still uses the table the STARTUP body named. The response is the new model on the old table. Titles: ['OLD_TITLE'] E assert ['OLD_TITLE'] == ['NEW_TITLE'] E AssertionError: output.path was repointed under a running watcher and the artifact at the new path never loaded: the watcher is still polling '.../old.recotem', the path captured at discovery. E AssertionError: output.path now names a file that does not exist and nothing was recorded against the entry: the watcher is still polling the old path ('.../old.recotem') and reporting success for it. E AssertionError: the recipe YAML was re-parsed and the suppression was not cleared: the documented recovery reads Recipe._yaml_path, which does not exist, so the latch is permanent and the sidecar backstop is off until restart. E assert True is False ``` The one that passes on `main` is the control, `test_item_metadata_is_not_reloaded_without_a_hot_swap`: an edit with *no* retrain must leave the old model and the old table in place. Without it, "the join followed the edit" could as easily mean "the join re-reads every tick", which would be a different behaviour. After the fix: `5 passed`. ### Each fix is load-bearing — single-call-site mutations Each mutation rewrites one expression and leaves the statement in place (never a deletion, so a presence check cannot score as a behaviour check): | mutation | change | result | |---|---|---| | M1 | `existing_state.recipe = recipe` → assign the field back to itself | `test_item_metadata_follows_the_edited_recipe_after_a_retrain` **and both #417 drift tests** fail — one site now carries both fixes | | M2 | `_repoint_artifact(..., recipe.output.path)` → pass `existing_state.artifact_path`, so the call still runs and is a no-op | both `output.path` tests fail | | M3 | `existing_state.sidecar_unsupported = False` → assign the field back to itself | the sidecar test fails | M1 failing both #417 tests is the check that removing `latest_recipe` did not quietly drop #417's fix. ### Suite `main` baseline: 3157 passed, 1 skipped, 4 deselected. This branch: **3162 passed, 1 skipped, 4 deselected** (+5 = the new file). `ruff check src tests` and `ruff format --check src tests` clean. `uv run bash tests/e2e/run.sh` passes. ### Concurrent watcher work `fix/watcher-pointer-content-marker` (the pointer-content change marker) is in flight against the same file. The two touch disjoint hunks, so this opens against `main` rather than stacking. Merged together locally the tree is clean and green: **3165 passed, 1 skipped, 4 deselected**, ruff clean — no conflict and no cross-failure. That branch narrows, but does not remove, the reason the sidecar backstop matters: it folds a digest into the marker only for pointer-sized files, so `versioning: always_overwrite` (a real artifact at the watched path) still relies on `(mtime, size)`, and the sidecar is still its only backstop. ## Deliberately not changed - **An `item_metadata` edit alone does not trigger a reload.** Metadata is read where the model is built, so an edit with no retrain leaves the old model *and* the old table — which is coherent, and is what the drift warning describes. Making a metadata edit its own hot-swap trigger is a new trigger, not this fix. - **`ModelEntry.artifact_path` is not updated eagerly on a re-point.** That field documents "the path last successfully loaded", which stays true; the `recipe_output_path_changed` log line ties the old and new paths together. - **`_stat_marker` is untouched** — that is the other branch's lane. ## Docs follow-up (separate repo) `https://recotem.org/2.1/docs/operations.html` needs two edits that are not in this repo: - the "Watcher and registry semantics" bullet list should say that an edited `output.path` is now followed at the next rescan, and that `item_metadata` is re-read from the recipe on disk at the next hot-swap; - the watcher event table should gain `recipe_output_path_changed` (INFO). Also pre-existing and unrelated to this PR, but worth correcting while that page is open: it states that during a drift warning "`/v1/recipes/{name}` reports the *current* recipe's `algorithms`, `metric` and `cutoff`". It reports the *artifact's* — `routes.py` never reads a `Recipe` object; every field on that endpoint comes from `entry.header`.
…o-op, and calls HEAD the release (#291) This PR closes two of the four ways a milestone's "MERGED" claim goes wrong, and states the other two in the gate's own output rather than leaving a reader to infer completeness. The gate asks an **ancestry** question; every failure is about **content**, and the two coincide only in the first row. | class | instance | ancestry alone | this PR | |---|---|---|---| | merged, never reached main | #245 | **catches** | unchanged | | reached main, then reverted | #259, #261 | blind — reports LANDED | **refused** | | merges cleanly, moves no bytes | #277 | blind — and **vouches** for it | **refused** | | removed later with no revert trailer | — | blind | **declared unchecked** | | HEAD is main plus a smuggled commit | R9-P3 | blind — **names the smuggled SHA as verified** | **disclaimed, owner named** | The last row is not a content question at all: it is the gate answering a true question about the wrong tree. It is not enforced here because `check-release-tag.sh` owns it and this script is a documented local pre-flight run from a branch — but **that owner's implementation is not on main**: #259 added it, #276 reverted it, #277's re-land is one of the no-ops this PR now refuses. The gate says so out loud. --- # Commit 1 — reverts `check-milestone-landed.sh` asks one question: is this PR's merge commit an ancestor of the tree being released? That detects a PR that NEVER REACHED main -- #245, stranded on a branch whose base was squash-merged first. It is structurally blind to the other way the same claim goes wrong: a PR that REACHED MAIN AND WAS TAKEN BACK OUT. A reverted PR's merge commit stays an ancestor forever, so the ancestry test reports it landed while the tree has none of its lines. Both end in the identical false statement -- the milestone says the release contains a change the tree does not have -- and only one of them was checked. **This repository contains a live instance right now.** PR #276 reverted #259 (`90c96f0`) and #261 (`d0118fc`): git merge-base --is-ancestor 90c96f0 HEAD -> exit 0 "LANDED" git merge-base --is-ancestor d0118fc HEAD -> exit 0 "LANDED" grep -c 'is-ancestor|not on main' check-release-tag.sh at 90c96f0: 3 in the tree: 0 grep -c mariadb src/recotem/training/search.py at d0118fc: 5 in the tree: 0 Not a 2.1.0 problem: both carry milestone 2.2.0, so the 2.1.0 check never looks at them. But **on the day 2.2.0 is cut, this gate would green-light a release missing exactly the content it was written to catch.** That is #245's failure one turn of the crank later, and the reason to fix it before the milestone that contains the instance comes up rather than after. ## The check Each PR that passes the ancestry test is now also checked for a revert that still stands, keyed on the canonical `This reverts commit <40-hex>` trailer -- a record `git revert` and GitHub's own Revert button both write, so it is produced by the tooling rather than by a convention anyone has to remember. `--fixed-strings` keeps the SHA out of the regex engine. Two things it deliberately does NOT do: * **A revert of a revert is not reported.** "We put it back" leaves the content in the tree, and a gate that fails a healthy release is a gate that gets switched off. Depth is one un-revert, documented at the function: anything deeper is rare enough that the existing waiver is a better answer than more recursion, and a loud wrong answer an operator clears by hand beats a silent one. * **A revert is not a stranding and does not get the stranding remedy.** Being told to cherry-pick a change someone deliberately backed out is the wrong instruction. The message offers both real options and names the usual one first: re-land it and record the replacement, or move the PR off the milestone -- which for a deliberate revert is the honest record and clears the check on its own. The `relanded-prs.tsv` waiver now serves both failure modes, and gained the matching condition: a replacement clears the original only if its own merge commit is an ancestor **and has not itself been reverted**. Without that second half a reverted re-land would keep clearing the original, so the waiver would outlive the change it vouches for. The file's header says all of this, and says not to add a row for a deliberate revert. ## Guard `tests/unit/test_check_milestone_landed.py`, 15 -> 24 tests, driving the real script against synthetic repositories with a fake `gh`, as the existing ones do. `test_ancestry_alone_cannot_see_a_revert` is the positive control on the *defect*: it asserts a reverted commit is still an ancestor and that the content really is gone, so nothing below can pass for the wrong reason. Two run against this repository's own history rather than a fixture, using #259/#261/#276 -- the shape as someone really made it, not one written to be caught. They skip rather than fail where the history is absent (a shallow clone), because "cannot see" and "not present" are different states of knowledge. Mutation-tested, each mutation syntax-checked before the run: revert detection disabled (the shipped, blind behaviour) -> 5 failed waiver stops checking whether the replacement was reverted-> 1 failed depth-1 un-revert dropped (revert-of-a-revert reports) -> 1 failed trailer match dropped (any commit in range counts) -> 8 failed restored -> 24 passed The first row is the one that matters: it is the behaviour on main today, and it takes the live-history test with it. 2891 passed (+9), 4 deselected; ruff check and ruff format --check clean; `bash -n` clean. --- # Commit 2 — no-op merges, and the declared boundary P3 found a third class, and it is sharper than the revert one: a PR can merge cleanly and change nothing, and after merging the gate does not merely fail to notice -- it ASSERTS the milestone is complete on the strength of it. class instance ancestry alone --------------------------------- --------- ------------------------- merged, never reached main #245 catches reached main, then reverted #259 #261 blind: reports LANDED merges cleanly, moves no bytes #277 blind: VOUCHES for it PR #277 is titled "re-land of #259" and is stacked on #276, which reverted #259. Its branch therefore reverts #259, reverts #261, then restores #259 -- and the first and third cancel. Measured by merging it: git merge --no-ff pr/277 -> "Merge made by the 'ort' strategy" git diff --stat origin/main HEAD-> (empty) check-release-tag.sh -> 305 lines, not #259's 555 'is-ancestor|not on main' hits -> 0, not 3 A PR named for re-landing #259 restores none of it, merges cleanly, and becomes an ancestor. It also opens a hole in the revert check in the first commit of this PR, which is why the two ship together: record that no-op in relanded-prs.tsv as the re-land of #259 and the waiver clears #259 on the strength of a PR that restored nothing. The gate would not just miss the gap, it would certify it closed. ## What this adds `contributes_nothing` -- the commit's diff against its first parent is empty -- applied in two places: the main loop (a milestone PR that carried nothing is reported, with its own remedy) and the waiver (a no-op can never clear the PR it is recorded against). First parent is the right comparison for both shapes here: for a squash merge it is "what did this add to main", and for a true merge it is the same question asked of the mainline. **False-positive rate, counted rather than predicted.** Over all 329 first-parent commits on main -- 4 of them true merges, 1 a root -- the number with an empty first-parent diff is **ZERO**. A gate that cries wolf on ordinary refactoring is a gate someone switches off, so this is pinned by a test that re-counts it against the live history and fails if the number ever moves. ## What this deliberately does NOT add, and why the output now says so A fourth shape exists: a later commit removes the change with no revert trailer -- a rewrite, a refactor, a hand-edit. That is a CONTENT question and content is materially harder than reachability. The cheap forms do not survive contact with a real repository: re-applying each PR's diff to see whether it is a no-op flags every file another PR has legitimately touched since, and hunk-grepping flags every reformat. It is not attempted. The two classes taken here were taken precisely because they are exact -- "is there a revert trailer naming this commit" and "is this commit's diff empty" are both decidable with no heuristic and no threshold. So the success message stops implying a completeness the gate does not have. It was a bare "is an ancestor of <sha>", which reads as "the milestone is complete". It now enumerates the three questions asked and names the one that is not, in the gate's own output where a reader will actually meet it: OK: every merged PR in milestone '2.1.0' is in the tree at <sha>. Checked, per PR: the merge commit is an ancestor; no revert of it still stands; the merge is not an empty no-op. NOT checked: whether a later commit removed the change WITHOUT a 'This reverts commit' trailer -- a rewrite or a refactor that drops a change silently is invisible here. [...] A gate that announces its own boundary is worth more than one a reader infers completeness from. ## Guard `tests/unit/test_check_milestone_landed.py`, 24 -> 30 tests. One builds #277's actual shape (revert then restore, merged) rather than asserting it, with fixture self-checks that the merge really is both a no-op and an ancestor. One is the negative control -- an ordinary change must stay green. One pins the false-positive count against the live history. One asserts the success message declares what it does not check. Mutation-tested, each mutant `bash -n`-checked first: M1 revert detection disabled (behaviour on main today) -> 6 failed M2 waiver stops checking the replacement for a revert -> 1 failed M3 depth-1 un-revert dropped -> 1 failed M4 revert trailer match dropped -> 9 failed M5 no-op detection disabled -> 2 failed M6 waiver stops checking the replacement for a no-op -> 1 failed M7 emptiness test inverted to always-true (over-broad) -> 9 failed M8 success message stops declaring what is NOT checked -> 1 failed restored -> 30 passed M7 matters as much as M5: it shows the check is bounded in both directions, so a future edit cannot quietly turn it into something that flags real work. 2897 passed (+6), 4 deselected; ruff check and ruff format --check clean; `bash -n` clean. --- # Commit 3 — HEAD is not the release tree R9-P3 found a fourth class, and it is not about a PR's content being missing -- it is this gate answering a true question about the wrong tree. Every question here is asked of `git rev-parse HEAD`. "Every milestone PR is an ancestor of HEAD" stays true when HEAD is main plus something smuggled on top: the extra commit is not any PR's merge commit, and nothing here looks for commits that no PR explains. P3 demonstrated it on a real off-main commit carrying a marker -- the script exited 0 and printed the smuggled SHA in its own success line as though it were the release. Reproduced independently here before writing this, on a commit built on top of 7871f9f and not on main: smuggled HEAD 9ffec607d8b5 is it on main? NO milestone PRs checked 13 milestone PRs NOT ancestors of it 0 All thirteen pass. The claim the success line makes is true, and much narrower than it reads. ## Why the rule is not enforced here 1. **It belongs to the tag guard.** `check-release-tag.sh` runs on the tag, where HEAD is always the tagged commit, and #259 implemented it there: shallow-clone refusal first (a shallow clone answers ancestry *wrongly* rather than failing), then `origin/main` else `refs/heads/main`, then `merge-base --is-ancestor HEAD <main>`. Two owners for one rule is how a rule ends up with none. 2. **This script is documented as a local pre-flight**, run from a branch before tagging -- see Usage in the header. A hard "HEAD must be on main" check would make its own documented workflow fail. So this does not add the check. It stops the output implying one. ## What it does add The success line was already enumerating what it checks and one thing it does not; it now names the second, and names the owner: NOT checked, 2 of 2: that <sha> is on main. Everything above is asked of HEAD, and 'every milestone PR is an ancestor of HEAD' stays true when HEAD is main plus a commit no PR explains. This line is not a statement that the release is main. check-release-tag.sh owns that rule -- and it is NOT on main today: #259 added it, #276 reverted it, and #277's re-land is an empty no-op. It returns with #277's rebuild. The last sentence is the part that matters and is why this is not merely a docstring. **The protection for P3's class existed and was reverted.** #259 added it, #276 backed it out, and #277's re-land is one of the no-ops this same PR now refuses -- so today NEITHER script checks that the released commit is on main. Verified: the current `check-release-tag.sh` makes no ancestry call at all, and #259's version has the check at line 455. Naming an open hole in the gate's own output beats leaving a reader to infer completeness from a success message, which is the whole argument of the commit before this one. ## Guard One test, `test_success_message_does_not_present_head_as_main`, asserting both the disclaimer and the owner's name -- the second because a disclaimer that does not say who *does* check reads as "nobody does" rather than "someone else does". **And a correction to the test added in the previous commit.** Numbering the disclaimers "1 of 2" and "2 of 2" silently weakened `test_success_message_states_what_is_not_checked`, which asserted the shared substring `"NOT checked"`: with two disclaimers present, deleting the first left the second satisfying the assertion. Caught by the mutation matrix -- M8 went from 1 failed to 31 passed -- not by reading. The test now asserts each disclaimer by its distinguishing marker, and M8 was widened to delete the whole block rather than one line of it. This is the second species of green-but-empty guard, introduced and caught inside one PR. Mutation matrix, all nine dead, each mutant `bash -n`-checked first: M1 revert detection disabled (behaviour on main today) -> 6 failed M2 waiver stops checking the replacement for a revert -> 1 failed M3 depth-1 un-revert dropped -> 1 failed M4 revert trailer match dropped -> 10 failed M5 no-op detection disabled -> 2 failed M6 waiver stops checking the replacement for a no-op -> 1 failed M7 emptiness test inverted to always-true (over-broad) -> 10 failed M8 the "silently removed" disclaimer deleted -> 1 failed M9 the "HEAD is not main" disclaimer deleted -> 2 failed restored -> 31 passed 2898 passed (+1), 4 deselected; ruff check and ruff format --check clean; `bash -n` clean. --- # Update — brought onto current main This branch was written when main was 100 commits younger. Rebasing it changed three things about what it says, and one thing about what the repository has to record. ### `.github/relanded-prs.tsv` gains two rows, because the new check fires Run against milestone 2.2.0 on the rebased branch, the gate refuses the release and names the live pair: ::error::2 PR(s) in milestone '2.2.0' reached main and were REVERTED, so v2.2.0 would publish without them: #261 d0118fc reverted by 504905c #259 90c96f0 reverted by 504905c That is the finding working, not a regression. Both changes did come back: #259 as #297 and #261 as #280, each merged onto current main. The rows record that, and the waiver re-checks each replacement itself — ancestor, not reverted, not an empty diff — rather than taking the row's word for it: 2 PR(s) cleared by a recorded re-land: #261 -> re-landed by #280 (b231522) #259 -> re-landed by #297 (774745e) OK: every merged PR in milestone '2.2.0' is in the tree at <sha>. Without the rows, 2.2.0 would have been green-lit as containing two changes whose every line had been removed and restored under other numbers. ### #277 is closed, not merged The header and the `contributes_nothing` docstring described it in the present tense as a no-op that merges cleanly and carries nothing — true when this was written, when #277 was open. It was closed unmerged, so the no-op class now has no merged instance in this history. The check stays, because nothing but somebody noticing stopped that merge, and both comments now say which of the two it is instead of pointing at a PR that is not live. ### The "329 first-parent commits" figure is dropped, both places Same reason #424 gave for dropping the milestone count it replaced: main only grows, so a number written into a comment is exact the day it is typed and misleading after the next merge. The claim it supported — zero commits in this history have an empty first-parent diff — is re-measured on every run of `test_no_commit_in_this_repositorys_history_is_a_false_positive`, which is the copy that cannot go stale. ### Conflict resolution `tests/unit/test_check_milestone_landed.py` conflicted at the end of the file: this branch appended its 16 new tests there and #362 appended two of its own (`test_a_full_page_of_results_is_refused_not_checked_partially`, `test_one_short_of_the_cap_is_still_checked`). Both blocks kept; no test from either side dropped or edited. `check-milestone-landed.sh` auto-merged — #424 touched only the `PR_LIMIT` comment, which this branch does not. ### Verified on the merged tree pytest tests 3218 passed, 1 skipped, 4 deselected ruff check / format clean bash -n clean milestone gate, 2.2.0 exit 0, both re-lands reported by name
What is broken
.github/scripts/check-milestone-landed.sh:162-166asks for the milestone'smerged PRs with
gh pr list --state merged --search "milestone:${MILESTONE}" --limit 200 --json number,title,mergeCommit.--limitis a cap, not a request.ghreturns at most that many rows andsays nothing about the ones it dropped, so a milestone larger than 200 has its
200 most recent PRs checked and the script then prints, at line 342:
about a set nobody read. This script's header states the doctrine it violates
here — "A gate that quietly does nothing … is worse than no gate: it reports
success for a question it never asked" — and the absent-milestone branch is
fatal for precisely that reason. The truncation path has no such guard.
Measured
ghtruncates silently. Asked for 5 of a milestone that has 151, against thereal API — stdout
5, and stderr was captured to a file that came back zerobytes. No marker in the JSON either.
The search API has its own ceiling, which
ghalso does not report. Asked for1100 against a large public repository with more merged PRs than that, the
answer was exactly
1000.How close this repository is to the old cap today — asking for the same
milestone with a limit of 1000 returns
151. That is 151 of 200: 49 PRs ofheadroom, on a milestone that grew by 11 in a single day.
The change
The limit becomes 1000 — the point where
gh pr list --searchtruncatesregardless, so the guard fires exactly when truncation starts rather than at a
number of our own choosing. A refusal is placed before the ancestry loop: a
truncated list cannot be made trustworthy by examining the part of it that
arrived. It counts the rows that came back and calls
failwhen that countreaches the limit.
The message says that raising the number does not help, because the ceiling is
the API's, and gives the date-sliced manual query instead.
Behaviour on this repository is unchanged — the real gate still passes, with
Checked 151 merged PR(s), the recorded re-land of #245 by #256 cleared, andOK: every merged PR in milestone '2.1.0' is an ancestor of 031ef69…, rc 0.Positive control
The two new tests are each other's control. The limit is read out of the
script rather than restated in the test, so raising it there without moving
the guard fails these instead of leaving them measuring a boundary that has
moved.
Without the second row the first would pass for "many rows" rather than for the
boundary, and an off-by-one (
-gt) would refuse every large but completemilestone — blocking a release for a truncation that did not happen.
The rows carry
noneas the merge commit, which the loop skips withoutspawning git, so both tests measure the guard rather than the loop.
Mutation matrix
Four directions on the single guard call site, each run against the whole
test file, with
__pycache__cleared between runs andbash -nchecked aftereach edit:
if … failblock removed-ge→-lt, every token kept-ge→-gt(the off-by-one)No surviving cells.
What this matrix cannot reach: the limit value itself. The tests drive the
script through a fake
ghthat ignores--limit, and the test reads whateverlimit the script declares — so setting it back to 200 would keep the suite green
with the guard correctly watching 200. The claim that 1000 is the API ceiling is
verified against the live API above and is not, and cannot be, pinned offline.
Verification
uv run pytest tests -q→3064 passed, 4 deselecteduv run ruff check src tests→All checks passed!uv run ruff format --check src tests→177 files already formattedbash -n .github/scripts/check-milestone-landed.sh→ rc 0Cost: the boundary control adds about 4s (999 iterations of the reporting loop,
which spawns three processes per row inside
sanitize_title). The guard itselfspawns no new subprocess.
Why 2.2.0
Nothing a 2.1.0 user receives is wrong, and nothing about cutting 2.1.0
changes: the milestone holds 151 merged PRs, under the old cap, and the gate
returns the same verdict before and after this change. This is release
infrastructure hardening against a milestone that has not happened yet.
Overlap
#291 also modifies
check-milestone-landed.sh(reverts, no-op merges, and theHEAD disclaimer). It does not touch the query or its limit, so the two are
independent in intent but will touch the same file — whichever lands second
will want a rebase.