Skip to content

merge: an absent tick is not a passing tick - #35

Merged
ztrange merged 1 commit into
mainfrom
claude/angry-wescoff-12211e
Sep 4, 2026
Merged

ztrange merged 1 commit into
mainfrom
claude/angry-wescoff-12211e

Conversation

@ztrange

@ztrange ztrange commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Step 5 of the merge skill could merge a PR whose CI had not run. Two bugs, one shape: an absence of signal read as a positive answer. Both hit by hand while landing ps-mutuus/mutuus-changelog#52.

Bug 1 — [] documented as "no CI gate exists — proceed"

[] is also what GitHub returns for a minute or two after a force-push, while it re-associates workflow runs with the new head. Observed on #52 right after the step-4 push: rollup [], gh pr checks 52 printing no checks reported on the 'claude/gh-path-helper' branch, and gh run list showing a PR checks run already queued for that exact head sha.

Now: [] means re-query. "No CI" may only be concluded after a positive check that no run exists for the head sha — with the caveat, stated in the skill, that gh run list sees GitHub Actions only, so an external provider posting commit statuses appears in neither source until it registers. The wait loop requires total > 0 before it can conclude nothing is pending, so zero checks can never satisfy "all passed".

Bug 2 — .conclusion // .state never consulted .state

jq's // falls through on null and false; an in-progress check run has conclusion: "". So .state was dead code, and a pending check landed in an empty-string bucket no rule mentioned — easy to read as "not FAILURE, therefore fine".

The replacement branches on __typename rather than sniffing nulls, because the null-sniffing shorthand is a type test by accident: gh's export (cli/cli api/export_pr.go) emits disjoint key sets — a CheckRun has no state key at all, a StatusContext has no status/conclusion and names itself via context.

The half you asked me to verify — the legacy status context

The done clause as proposed in the report (.status == "COMPLETED" or .state != null) has the same bug one level down. A legacy status context that is still running is state: "PENDING" — non-null — so a pending external CI would have counted as passed. Reproduced against a fixture; the committed filter treats PENDING/EXPECTED as pending.

Enum values came from GraphQL introspection rather than memory, which also caught a third gap: the failing conclusions are wider than the proposed list — ACTION_REQUIRED, STARTUP_FAILURE and STALE were missing.

Enum Values Terminal / bad
StatusState EXPECTED ERROR FAILURE PENDING SUCCESS terminal: last three; bad: ERROR, FAILURE
CheckStatusState REQUESTED QUEUED IN_PROGRESS COMPLETED WAITING PENDING terminal: COMPLETED only
CheckConclusionState ACTION_REQUIRED TIMED_OUT CANCELLED FAILURE SUCCESS NEUTRAL SKIPPED STARTUP_FAILURE STALE bad: all but SUCCESS/NEUTRAL/SKIPPED

An unrecognised __typename falls into the else branch with no state and lands in pending. Unknown fails closed.

Verified

  • The 5a filter extracted verbatim from the committed file and run against Add VHS demo skill cli/cli#14334 — 37 rows, {"failed":[],"pending":[],"total":37}.
  • Fixtures: the observed #52 in-progress check run (conclusion:"", status:IN_PROGRESS) → pending; a PENDING legacy status context → pending; mixed QUEUED/FAILURE/SKIPPED/ERROR → correct buckets; empty rollup, null rollup, unknown typename → fail closed.
  • // on "": {"conclusion":"","state":null} | .conclusion // .state → "". Confirmed on jq 1.8.2.
  • The 5b cross-check run in both directions on cli/cli trunk — 0 for a foreign sha, 20 for the real head — so it is known to be able to return a negative and a positive, the discipline scripts/gh_path.py encodes for a different API.
  • The 5c loop driven by scripted rollups: an always-empty rollup now times out instead of passing.

Not verified

A live StatusContext row was never observed. That half is derived from gh's export shape and the StatusState enum, and is flagged under Limits in the skill so the next reader knows to check the raw rows once on a repo whose CI posts commit statuses. gh pr checks exit 8 remains documented-but-unobserved, unchanged.

README updated in the same commit.

Step 5 could merge a PR whose CI had not run. Two ways, both the same
shape — an absence of signal read as a positive answer. Hit while landing
ps-mutuus/mutuus-changelog#52.

1. `[]` was documented as "no CI gate exists — proceed". It is also what
   GitHub returns for a minute or two after a force-push, while it
   re-associates workflow runs with the new head. Observed on #52: rollup
   `[]` and `gh pr checks` printing "no checks reported" while a run was
   already queued for that exact head sha. `[]` now means re-query, and
   "no CI" may only be concluded after `gh run list` positively shows no
   run for the head — with the caveat, stated, that it sees Actions only.
   The wait loop now requires `total > 0` before it can conclude nothing
   is pending, so zero checks can never satisfy "all passed"; verified
   against a scripted always-empty rollup, which now times out instead of
   passing.

2. `.conclusion // .state` never consulted `.state`. jq's `//` falls
   through on null and false; an in-progress check run has
   `conclusion: ""`. Verified: `{"conclusion":"","state":null} | .conclusion
   // .state` yields `""`, which lands in a bucket no rule mentioned and
   reads as "not FAILURE, therefore fine".

The replacement branches on `__typename` rather than sniffing nulls. The
reason the null-sniffing shorthand is tempting and wrong: gh's export
(cli/cli `api/export_pr.go`) emits *disjoint* key sets — CheckRun has no
`state` key at all, StatusContext has no `status`/`conclusion` and names
itself via `context` — so `.state != null` is a type test, not a
completion test. That matters for the fix as first drafted, which used
`.state != null` for "done": a legacy status context that is still running
is `state: "PENDING"`, so a pending external CI would have counted as
passed. Terminal for a StatusContext is SUCCESS/FAILURE/ERROR only;
PENDING and EXPECTED are not. Enum values taken from GraphQL
introspection, not memory: StatusState, CheckStatusState (COMPLETED is
the only terminal one of six) and CheckConclusionState (the failing set
is wider than FAILURE — ACTION_REQUIRED, STARTUP_FAILURE and STALE were
missing).

An unknown `__typename` falls into the else branch with no state and
lands in `pending`. Unknown fails closed.

Verified: the 5a filter extracted verbatim from the doc and run against
cli/cli#14334 (37 rows, all green); against fixtures for in-progress
check runs, a pending legacy context, mixed failures, SKIPPED, empty and
null rollups; the 5b cross-check run in both directions on cli/cli trunk
(0 for a foreign sha, 20 for the real head) so it is known to be able to
answer negative and positive. Not observed: a live StatusContext row —
that half is derived from gh's export and the enum, and is flagged as
such under Limits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ztrange
ztrange merged commit 63a9545 into main Sep 4, 2026
@ztrange
ztrange deleted the claude/angry-wescoff-12211e branch September 4, 2026 18:50
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