Uh oh!
There was an error while loading. Please reload this page.
(fix): bound untagged recovery to the head of main - #92
Conversation
An automation merge with no tag was read as an interrupted release no matter how old it was, and the merged-pull-request lookup walks the whole closed-PR history. A release that was deliberately abandoned therefore stayed recoverable forever, and the next run would tag and publish it from a commit main had long moved past. The window where recovery is warranted is the one between the merge and its tag, and in that window the merge is still the tip. Once main has moved on, the release was abandoned rather than interrupted, so leave it alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
562c9f6 to
e1c92f0Compareabnegate
commented
Aug 21, 2026
@greptileai review |
Greptile SummaryThe PR limits untagged recovery to automation merges at the current tip of
Confidence Score: 4/5The PR is not yet safe to merge because an otherwise valid tagged recovery can still be aborted by failure of the new head lookup. Recovery selection continues into untagged-merge processing after collecting a tagged candidate, so a coexisting eligible untagged merge invokes Repository::head() and can lose the tagged recovery if that request fails. Files Needing Attention: .github/scripts/src/Automation/RecoverySelector.php and .github/scripts/tests/Unit/Automation/RecoveryTest.php Important Files Changed
Prompt To Fix All With AI### Issue 1
.github/scripts/src/Automation/RecoverySelector.php:96
**Tagged recovery still reads head**
When a valid tagged candidate coexists with an eligible untagged automation merge, this loop invokes `head()` before returning either candidate. A failure of that additional GitHub request therefore aborts recovery of the already-tagged release.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (2): Last reviewed commit: "(fix): read the head only when recovery ..." | Re-trigger Greptile |
Uh oh!
There was an error while loading. Please reload this page.
Fetching the head of main before every selection gave the tagged recovery path a dependency it never uses, so a failure of that one request would abort recovery of a release that was already tagged and only needed finishing. Recovery is the mechanism that repairs interrupted releases; adding a new way for it to fail is the wrong direction. Resolve the head lazily, on the first untagged automation merge that actually needs comparing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
abnegate
commented
Aug 21, 2026
Greptile P2 (unnecessary head lookup dependency) — fixed in 70b8259. The thread auto-resolved when the push moved its anchor, so recording the reasoning here. Agreed, and it mattered more than a nit in this spot. The head is now resolved lazily, on the first untagged automation merge that actually needs comparing. Verification:
Note this PR was rebased onto current main earlier (after #90 and #91 merged) to resolve a |
| continue; | ||
| } | ||
| $tip ??= $head(); |
There was a problem hiding this comment.
Tagged recovery still reads head
When a valid tagged candidate coexists with an eligible untagged automation merge, this loop invokes head() before returning either candidate. A failure of that additional GitHub request therefore aborts recovery of the already-tagged release.
Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/scripts/src/Automation/RecoverySelector.php
Line: 96
Comment:
**Tagged recovery still reads head**
When a valid tagged candidate coexists with an eligible untagged automation merge, this loop invokes `head()` before returning either candidate. A failure of that additional GitHub request therefore aborts recovery of the already-tagged release.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Refuting this one, with the caveat that the underlying concern is fair — I just do not think it identifies a new failure mode.
The claim is that a failed head() can abort an otherwise valid tagged recovery. That is true, but recover() already depends on three unconditional API requests, each of which aborts tagged recovery the same way if it fails:
$tags = $this->repository->tags(); // paginated API callreturn RecoverySelector::select(
$tags,
$this->repository->releases($tags), // paginated API call$this->repository->mergedPullRequests(), // paginated API callfn (): string => $this->repository->head(), // only invoked if an untagged automation merge exists
);If releases() throws, the tagged candidate is lost in exactly the same way. Recovery has never been resilient to API failure and is not designed to be — every adapter method throws, the step fails, and the next scheduled or dispatched run retries from scratch. head() is now the most conditional of the four, not an outlier.
On the specific interleaving: when a tagged candidate and an eligible untagged merge coexist and head()succeeds, the untagged merge is either the tip — giving two candidates and a deliberate RecoveryException — or it is not, and it is filtered out. So the only cost of a failed head() is one retry of a run that would otherwise have proceeded.
I also considered making a head() failure non-fatal by skipping untagged candidates, and rejected it: that would suppress genuine ambiguity detection and let a tagged recovery proceed while an untagged one also existed. Failing closed is the safer behaviour and is what the code does now.
Happy to reconsider if you are pointing at something narrower that I have missed — in particular if there is a case where head() is invoked and no untagged automation merge exists, since that would be a real regression and is what the laziness change was meant to rule out.
There was a problem hiding this comment.
Your rebuttal is correct on all counts. The ??= ensures head() is only invoked when the loop body actually reaches an untagged automation merge — if no such merge exists, $tip remains null and head() is never called. So the concern I raised (that a failing head() could abort an already-valid tagged recovery) only materialises when an untagged automation merge exists alongside the tagged candidate, which is precisely the ambiguous state where failing closed is the right call anyway.
The symmetry argument is also solid: head() is now the most conditional of the four calls, not an outlier. I was incorrect to frame it as a new failure mode. Withdrawing the comment.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
Explains run 32448110295, which recovered a release that had been deliberately abandoned, and blocks the retry of the dependency release.
The defect
RecoverySelector::selecttreats any automation merge with no tag pointing at it as an interrupted release:There is no bound on how old that merge may be, and
mergedPullRequests()paginates the entire closed-PR history (state=closed&base=main, all pages). So an automation merge that never got a tag stays a recovery candidate forever.That is what happened: #89 was merged by the automation, its release failed, and the tag and draft were deleted. The merge remained, untagged, so the next dispatch read it as unfinished and began re-tagging
8a93696.Why this blocks the retry specifically
After #91 reverts #89, commit
8a93696is still in history and still untagged. Every subsequent run would re-tag it and publish an image built from the commit whose changes were just reverted — quietly undoing the point of the revert.The rule
Recovery is warranted in exactly one window: between the automation merge landing and its tag being created. In that window the merge is still the tip of
main. Once main has moved on, the release was abandoned rather than interrupted, and resurrecting it is wrong.An untagged automation merge is therefore recoverable only while
merge->target === head-of-main. The tagged path is untouched — a tag that exists but was never released still recovers as before, regardless of age.This required a new
Repository::head(); the GitHub adapter readsrepos/{repo}/commits/main.Verification
test_ignores_an_untagged_merge_main_has_moved_pastis the regression test. Confirmed red with the bound removed and green with it.test_resumes_proven_merge_when_cancelled_before_tag_creationnow passes the merge target as head, preserving its intent — an interrupted release at the tip still recovers.test_fails_closed_for_ambiguous_proven_untagged_mergespreviously used two merges with different targets, which the tip rule makes structurally impossible. It now uses two automation merges claiming the same tip commit, which still exercises fail-closed on ambiguity under the new rule.composer verify: Pint, PHPStan max, 153 tests / 1812 assertions, 91 parity contracts.Not verified
Repository::head()is new and its GitHub call is only covered by a mock.