Skip to content

fix(ci): stop the validation resolver losing a race with its own PR run - #82

Merged
trakhimenok merged 1 commit into
mainfrom
cicd-resolver-race
Sep 7, 2026
Merged

trakhimenok merged 1 commit into
mainfrom
cicd-resolver-race

Conversation

@trakhimenok

Copy link
Copy Markdown
Member

Follow-up to #80/#81. I said there that the receipt never matched because of tree drift. That was wrong, and the evidence says so plainly.

What actually happens

Measured on sneat-co/sneat-go, merge run 34165753334:

event time
receipt artifact created 22:11:13
pull-request run concluded 22:11:18
resolver asked 22:11:21

Its verdict: expected one successful pull request validation receipt, found 0.

I then replayed the resolver's own API calls by hand. The run matches every filter it applies:

{"id":34165180456,"event":"pull_request","conclusion":"success",
 "head_branch":"sneatai-fastpath","head_sha":"6efb0b98…","head_repo":"sneat-co/sneat-go"}

and the receipt is there: {"name":"ci-validation-receipt","expired":false,"size_in_bytes":840}, including through the exact ?name=ci-validation-receipt query. GitHub's run index had simply not caught up three seconds after the run concluded.

Why it is structural, not bad luck

The merge run starts the moment auto-merge fires — which is when the required checks pass. The receipt is published by a job that only runs after those checks, so it is routinely the last thing in the run to exist. The optimization was losing a race with the very run it depends on, on every single merge. That is why reuse_exact_tree_validation has been enabled in sneat-go since it shipped and has never once fired.

The fix

Wait — but only for evidence that is visibly still arriving:

  • a pull-request run for that head that has not concluded yet, or
  • a successful run whose receipt has not appeared.

Bounded at 90 s, inside the resolver job's existing 3-minute timeout.

Every settled refusal is decided on the first look and waits for nothing: a direct push (no pull request now and never), a failed run, a tree mismatch, an ambiguous pair. So a genuine miss never costs the merge run more than the resolver's own runtime — which matters, because lint and tests wait on this job.

The runs query drops its server-side status=success filter and concludes in code instead: a run that is still finishing is exactly the signal that waiting is worthwhile, and that filter hid it.

Tests

go test ./... green. Two new ones:

  • the race itself — the first look sees an in-progress run and no artifact, the second sees both; the resolver must re-check and reuse. Fails on main today.
  • settled refusals must not wait: pollInterval is set to an hour, so any wait at all hangs the test rather than passing it. Covers a direct push, a failed run, and a tree mismatch.

Note this also unblocks validate_on_main from #81, which was losing the same race — its delegated path needs the same run listing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ETcdTvH64PAweR3aZ3smpL

The receipt was never being reused, and it was not tree drift. Measured on
sneat-co/sneat-go (merge run 34165753334): the receipt artifact was created
at 22:11:13, the pull-request run concluded at 22:11:18, and the resolver
asked at 22:11:21 -- three seconds later -- and was told there were no
successful pull-request runs. Verified afterwards through the same API calls
the resolver makes: the run matches every filter and the receipt artifact is
present and unexpired. GitHub's run index simply had not caught up.

This is structural, not bad luck. The merge run starts the moment auto-merge
fires, which is when the required checks pass -- and the receipt is published
by a job that only runs after them, so it is routinely the last thing to
exist. The optimization lost a race with the very run it depends on, on every
merge.

So wait, but only for evidence that is visibly still arriving: a pull-request
run for that head which has not concluded, or a successful run whose receipt
has not appeared. Bounded at 90 seconds inside the job's existing 3-minute
timeout. Every settled refusal -- a direct push, a failed run, a tree
mismatch, an ambiguous pair -- is decided on the first look and waits for
nothing, so a genuine miss never costs the merge run more than the resolver's
own runtime.

The runs query drops its server-side `status=success` filter and concludes in
code instead: a run that is still finishing is precisely the signal that
waiting is worthwhile, and that filter hid it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETcdTvH64PAweR3aZ3smpL
@trakhimenok
trakhimenok merged commit d3cec8b into main Sep 7, 2026
7 checks passed
@trakhimenok
trakhimenok deleted the cicd-resolver-race branch September 7, 2026 22:31
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