instar 1.3.977 → 1.3.978

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ # Side-Effects Review — CI greenness must use the LATEST run per check
2
+
3
+ ## Summary of the change
4
+
5
+ `StageTransitionValidator.ciIsGreen` iterated EVERY entry in GitHub's `statusCheckRollup` and refused
6
+ on any non-success conclusion. GitHub returns every run of every check, **including superseded ones**,
7
+ so a check that failed and was then re-run to green appears twice and the failed entry never
8
+ disappears.
9
+
10
+ Verified on the live PR #1641 (merged legitimately at 02:07:44Z):
11
+
12
+ ```
13
+ rollup entries: 31 | names appearing more than once: eli16 (2), UX impact declaration (2),
14
+ UX assertions (messaging E2E) (2), UX merge safety (2)
15
+ eli16 COMPLETED FAILURE completedAt 2026-07-26T01:44:01Z
16
+ eli16 COMPLETED SUCCESS completedAt 2026-07-26T01:45:32Z
17
+ ```
18
+
19
+ Branch protection merged the PR because it evaluates each check's CURRENT state. The tracker refused
20
+ it with `CI_NOT_GREEN` because it evaluated the stale run too — so **no correct merge whose CI had
21
+ ever been red could ever be recorded.**
22
+
23
+ Fix: `latestRunPerCheck` collapses the rollup to the newest run per check name (by `completedAt`,
24
+ falling back to `startedAt`) before `ciIsGreen` evaluates it. `GhPrView.statusCheckRollup`'s type
25
+ gains the `name`/`context`/`startedAt`/`completedAt` fields the dedupe needs; the route's existing
26
+ `gh pr view --json …statusCheckRollup` already returns them, so no query change.
27
+
28
+ ## Decision-point inventory
29
+
30
+ - `ciIsGreen` feeds the `building → merged` GATE. This change makes the gate ACCEPT a case it
31
+ previously refused, so it is the direction that needs the most scrutiny — see §1/§2.
32
+ - No other decision point, hook, sentinel, reaper, scheduler, migration or config surface is touched.
33
+ - `latestRunPerCheck` is a pure function over the rollup array.
34
+
35
+ ## 1. Over-block
36
+
37
+ Strictly reduced, and that is the intent: a merged PR with a superseded failure is now recordable.
38
+ Nothing that used to pass now fails.
39
+
40
+ The one preserved refusal worth naming: a **latest** run that is still in flight (`status` not
41
+ `COMPLETED`) is still not green, asserted in its own test, so the dedupe cannot be used to skip a
42
+ pending check by pairing it with an older success.
43
+
44
+ ## 2. Under-block — the part that matters
45
+
46
+ A dedupe is a potential laundering mechanism, so three properties are enforced and each is tested:
47
+
48
+ 1. **Latest is authoritative in BOTH directions.** SUCCESS followed by a later FAILURE is NOT green.
49
+ Without this, "keep the latest" would let anyone bury a red by arranging run order.
50
+ 2. **Unorderable runs FAIL CLOSED.** Equal or missing timestamps ⇒ the FAILING run wins. An undatable
51
+ success must never mask a red. (Three variants tested: no timestamps either way round, and equal
52
+ timestamps.)
53
+ 3. **Unnamed entries are keyed individually.** Bare status contexts with neither `name` nor `context`
54
+ are never collapsed into each other, so a failing one cannot be replaced by a passing one.
55
+
56
+ Residual, stated: the dedupe trusts GitHub's timestamps. A platform that reported them wrongly could
57
+ mis-order runs — but the fail-closed rule bounds the damage to the unorderable case, and the
58
+ alternative (ignoring the rollup entirely, or trusting merge status alone) is strictly weaker.
59
+
60
+ ## 3. Blast radius
61
+
62
+ One function plus one type widening in `src/core/StageTransitionValidator.ts`. `ciIsGreen` is private
63
+ to that module (`grep` for callers: only the `to === 'merged'` branch). The route passes the rollup
64
+ through unchanged. No response shape changes; no new code.
65
+
66
+ ## 4. Rollback plan
67
+
68
+ Single-commit revert; no state, config, migration or artifact. Reverting restores the previous
69
+ (over-refusing) behaviour immediately.
70
+
71
+ ## 5. Test coverage
72
+
73
+ `tests/unit/StageTransitionValidator.test.ts` (46 pass), five new cases: the live #1641 shape
74
+ (superseded FAILURE + later SUCCESS ⇒ green); the reverse (SUCCESS + later FAILURE ⇒ not green);
75
+ unorderable-runs-fail-closed across three variants; unnamed entries not collapsed; and an in-flight
76
+ latest run still not green.
77
+
78
+ No integration/e2e tier added: this is a pure predicate inside an existing gate whose route path is
79
+ already covered, and the decisive evidence is the end-to-end check below.
80
+
81
+ ## 6. End-to-end evidence, gathered BEFORE the fix and after
82
+
83
+ The validator's real logic was run against the two real merged PRs with the route's exact helper
84
+ wiring:
85
+
86
+ ```
87
+ before: item 1 (PR 1641) -> CI_NOT_GREEN
88
+ item 2 (PR 1644) -> MERGE_BASE_UNVERIFIABLE (stale local ref; resolved by git fetch)
89
+ after: item 1 (PR 1641) -> WOULD SUCCEED
90
+ item 2 (PR 1644) -> WOULD SUCCEED
91
+ ```
92
+
93
+ Note the second line: the `MERGE_BASE_UNVERIFIABLE` code shipped hours earlier in #1643 did its job
94
+ here on a real case — an unknown local SHA reported as *unverifiable* rather than as a false "not an
95
+ ancestor".
96
+
97
+ ## 7. Third instance in one code path, in one evening
98
+
99
+ The completion-recording path has now yielded three independent defects tonight, each the same
100
+ underlying error:
101
+
102
+ | | defect | what it reported |
103
+ |---|---|---|
104
+ | #1640 | the `gh` binary was unreachable | a flat refusal |
105
+ | #1643 | a SourceTreeGuard refusal was caught and returned as `false` | "the merge is not on main" |
106
+ | this | a superseded check run read as the check's current state | "CI is not green" |
107
+
108
+ All three presented identically from outside — an opaque refusal — and each masked the next. That is
109
+ worth recording as a property of the path rather than three coincidences: **a code path whose failures
110
+ all render as one indistinguishable refusal will hide its own defects in series.** The general fix is
111
+ the one #1643 started: distinguish "could not determine" from "determined, and the answer is no".
112
+
113
+ ## 8. How it was found
114
+
115
+ By refusing to assert. I was about to report that recording would work once the server updated, and
116
+ instead ran the validator's own logic against the real PRs. It returned two different refusals. That
117
+ cost two minutes and avoided discovering both blockers serially, each appearing to be a fresh
118
+ mystery. Habit worth keeping: **before claiming something will work once a dependency lands, execute
119
+ its logic now and read what it returns.**