diff --git a/.github/scripts/labels-reconcile.sh b/.github/scripts/labels-reconcile.sh index 03a9164..514479a 100644 --- a/.github/scripts/labels-reconcile.sh +++ b/.github/scripts/labels-reconcile.sh @@ -88,18 +88,28 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE # pick the very run it superseded — reporting the old SUCCESS while a # replacement was still running, which is #136 again. # - # So: take the newest timestamp a run actually carries, discarding both - # spellings of absent (null, and the zero sentinel). An entry that carries - # no usable timestamp at all sorts LAST rather than first — something we - # cannot date is most likely the thing just created, and treating it as - # newest keeps an undateable in-flight run from being discarded in favour - # of a stale success. Every ambiguity here resolves toward "not settled". + # So: date a run by when it BEGAN, discarding both spellings of absent + # (null, and the zero sentinel) and falling back only if it never recorded + # a beginning. NOT by the newest stamp of any kind: `max` compares the + # completion of a finished run against the start of a live one, which are + # different quantities and not an ordering on runs. A run cancelled by the + # concurrency group does not stop the instant its replacement starts — the + # runner has to wind down — so predecessor.completedAt > successor.startedAt + # is the ordinary case, and `max` dated the dead predecessor newer than the + # live run that replaced it, narrowing both failures above without closing + # them. The list is already in preference order, so `first` IS that rule. + # + # An entry that carries no usable timestamp at all sorts LAST rather than + # first — something we cannot date is most likely the thing just created, + # and treating it as newest keeps an undateable in-flight run from being + # discarded in favour of a stale success. Every ambiguity resolves toward + # "not settled". | [ (.statusCheckRollup // [])[] | { ctx: [.workflowName // "", .name // .context // ""], at: ([.startedAt, .createdAt, .completedAt] | map(select(type == "string" and . != "" and (startswith("0001-01-01") | not))) - | max // ""), + | first // ""), outcome: ((.conclusion // .state // "") | ascii_upcase) } ] | group_by(.ctx) | map(sort_by([(.at == ""), .at]) | last | .outcome) as $latest diff --git a/CHANGELOG.md b/CHANGELOG.md index da6488c..d6b9e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,12 +64,18 @@ actually cutting it, and this file starts there. a replacement mid-flight read `SUCCESS` — the original bug restored, pointing a human at a disabled merge button — and a `CANCELLED` original whose replacement was still running read `FAILURE`, the flap the collapse exists to - prevent. So a run is dated by the newest timestamp it actually carries, with - both spellings of absent discarded (`null`, and the zero sentinel), and an - entry that carries no usable timestamp at all sorts **last** rather than - first: something undateable is most likely the thing just created, and every - ambiguity here resolves toward "not settled" rather than toward a stale - success. + prevent. So a run is dated by when it **began**, with both spellings of + absent discarded (`null`, and the zero sentinel) and a fallback only for a + run that never recorded a beginning — not by the newest stamp of any kind, + which compares the completion of a finished run against the start of a live + one. Those are different quantities, and a run cancelled by the concurrency + group does not stop the instant its replacement starts: the runner winds + down, so a predecessor routinely finishes *after* its successor began, and + dating by "newest stamp" let the dead run out-rank the live one that + replaced it. An entry carrying no usable timestamp at all sorts **last** + rather than first: something undateable is most likely the thing just + created, and every ambiguity here resolves toward "not settled" rather than + toward a stale success. `UNKNOWN` mergeability is deliberately not treated as unmergeable: GitHub reports it for about a minute after every merge while it recomputes, and @@ -83,9 +89,9 @@ actually cutting it, and this file starts there. **clears** it once the PR stops being mergeable-by-a-human. Ported from heavy-duty/box#137 so the three repos' reconcilers stay byte-identical; both live shapes, the mixed round, the in-flight run superseding a finished one — - in both spellings of an absent completion, and in both directions — and the - whole check-outcome enum are pinned in `test/labels-reconcile.sh` - (fixtures 19 → 49). + in both spellings of an absent completion, in both directions, and across the + wind-down window where the two overlap — and the whole check-outcome enum are + pinned in `test/labels-reconcile.sh` (fixtures 19 → 51). ## 0.1.1 — 2026-07-19 diff --git a/test/labels-reconcile.sh b/test/labels-reconcile.sh index 7a532ed..b68de99 100644 --- a/test/labels-reconcile.sh +++ b/test/labels-reconcile.sh @@ -291,6 +291,25 @@ expect "a finished re-run supersedes an earlier in-flight run" SUCCESS \ "$(rollup "[$(inflight_ build 2026-07-20T15:19:00Z),\ $(run_ build SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)" +# -- the wind-down window. A predecessor cancelled by the concurrency group +# does not stop the instant its replacement starts, so its completion +# routinely lands AFTER the successor's start — on box's aa5a6ba the +# replacement started 15:19:38 and the run it cancelled finished 15:19:51. +# Dating by "newest stamp of any kind" compares the dead run's completion +# against the live run's start, which is not an ordering on runs, and the +# predecessor wins. Every fixture above spaces completion before start, so +# none of them can see it. run_() cannot express the overlap either — it +# carries no startedAt — hence the explicit payloads. +overlap_() { jq -n --arg n "$1" --arg o "$2" --arg s "$3" --arg c "$4" \ + '{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o, + startedAt:$s, completedAt:$c}'; } +expect "a predecessor finishing after its replacement started is still older (CANCELLED)" PENDING \ + "$(rollup "[$(overlap_ scope CANCELLED 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\ + $(inflight_ scope 2026-07-20T15:19:38Z)]" | checks_state)" +expect "...and the same when it finished green — mid-flight is not mergeable" PENDING \ + "$(rollup "[$(overlap_ build SUCCESS 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\ + $(inflight_ build 2026-07-20T15:19:38Z)]" | checks_state)" + # -- the classifier feeds the state machine: a cancelled required check must # take the PR off the human's plate, which is the whole point of #136. DRAFT=false HEAD_SHA=head1 REQUESTED="$HUMAN" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE