ci-triage
GitHub通过查询 GitHub Commit 的 check-runs API,准确诊断 CI 状态及失败原因,避免因 run-level conclusion 汇总丢失导致的漏报。
Trigger Scenarios
Install
npx skills add nubjs/nub --skill ci-triage -g -y
SKILL.md
Frontmatter
{
"name": "ci-triage",
"description": "Find and diagnose RED CI with the gh CLI — answer \"is anything failing?\" and \"why?\" correctly, without being handed a URL. Invoke (via the Skill tool) whenever you need to check whether a branch, commit, or PR is green, whenever someone reports failing CI, before cutting a release, and before claiming any commit is green. THE FAILURE THIS SKILL EXISTS TO PREVENT: `gh run list` reports a RUN-level `conclusion`, and a run whose rollup is `cancelled` can contain a job whose conclusion is `failure` — so filtering runs on `conclusion==\"failure\"` silently misses real red, and taking the latest run per workflow hides an older failed run on the same SHA. The authoritative instrument is the commit's CHECK-RUNS, which is what the GitHub UI renders. Pairs with `ci-watch` (blocking until a run is terminal) and `ci-adhoc-test` (running a probe on a real OS)."
}
Investigating CI
ci-watch answers "has it finished, and did it pass?" for one run you are waiting on. This skill answers the two questions you get asked cold: "is anything red?" and "why is this red?" — starting from a branch name or a commit, never from a URL someone hands you.
The rule
Never answer "is it green?" from run-level conclusion. Query the commit's check-runs.
A GitHub Actions run rolls up its jobs into one conclusion, and that rollup is lossy in the exact case that matters:
| What happened | Run conclusion |
Job conclusions | gh run list shows |
|---|---|---|---|
| A job failed | failure |
one failure |
✅ red — you see it |
| A job failed inside a run that also got cancelled | cancelled |
one failure |
❌ nothing — filtered out |
Superseded by cancel-in-progress |
cancelled |
all cancelled |
❌ nothing (correctly) |
The middle row is real and it is what the UI paints red on the commit. It happens whenever a workflow has an aggregation/gate job that runs after its matrix legs: concurrency: cancel-in-progress cancels the legs, the gate still runs, sees cancelled, and fails. The run rolls up cancelled; the gate job is failure.
The one command that is always correct
SHA=$(git rev-parse origin/main) # or a PR head, or any commit
gh api repos/nubjs/nub/commits/$SHA/check-runs --paginate \
--jq '.check_runs[] | select(.conclusion != "success" and .conclusion != "skipped" and .conclusion != "neutral")
| "\(.conclusion // .status) | \(.name) | \(.html_url)"'
Empty output means genuinely green. Anything else is exactly what a human sees in the Actions tab, with a clickable URL per finding. This endpoint is the source of truth because it is what the UI renders — run-level data is downstream of it.
Counting, and the --paginate trap
--paginate runs --jq once PER PAGE. A per-item filter like the one above is fine — each page emits its own lines and they concatenate. But any aggregation (length, add, group_by) silently returns one result per page instead of a total:
# WRONG — prints "0" then "1" on a 2-page response. Reads like 0 if you only look at line 1.
gh api repos/nubjs/nub/commits/$SHA/check-runs --paginate --jq '[.check_runs[]|select(.conclusion=="failure")]|length'
Use --slurp to get one array across pages, and pipe to external jq — --slurp is rejected together with --jq:
# RIGHT — one number for the whole commit.
gh api repos/nubjs/nub/commits/$SHA/check-runs --paginate --slurp \
| jq '[.[].check_runs[] | select(.conclusion=="failure")] | length'
This is not hypothetical: a release commit here carried 192 check-runs across 2 pages, and the naive count printed 0 on the first line.
Diagnosing one failure
Given a failing check, get the run, then the job, then the log — in that order.
gh run view <run-id> --json name,status,conclusion,headBranch,headSha,event,createdAt
gh run view <run-id> --json jobs --jq '.jobs[] | "\(.conclusion // .status) | \(.name)"'
gh run view <run-id> --log-failed > /tmp/fail.log 2>&1; echo "EXIT=$?"
sed -e 's/\x1b\[[0-9;]*m//g' /tmp/fail.log | grep -viE '^\s*$' | tail -40
Strip ANSI escapes (sed -e 's/\x1b\[[0-9;]*m//g') or the log is unreadable. --log-failed returns only failing steps, which is usually 40 lines rather than 40,000.
Read the event and createdAt fields before concluding anything. They tell you whether the run was superseded, and by what.
Superseded-run triage — is this red REAL?
A red check on a commit is not automatically a broken commit. Decide with this:
- List every run of that workflow on that SHA. More than one is the tell.
gh run list --workflow <file>.yml --limit 30 \ --json databaseId,status,conclusion,headSha,headBranch,event,createdAt \ --jq '.[] | select(.headSha=="'"$SHA"'") | "id=\(.databaseId) | \(.conclusion // .status) | \(.event) | \(.createdAt)"' - If a later run of the same workflow on the same SHA is fully green, the code is fine — the red one was superseded. The commit still shows red, which is a workflow defect worth fixing, not a code defect.
- Check the concurrency group.
group: ${{ github.workflow }}-${{ github.ref }}means aschedulerun and apushrun onmainshare a group and will cancel each other, because both carryrefs/heads/main. That is a common and surprising source of self-inflicted red.
The gate-job anti-pattern, and its fix
A gate job that aggregates matrix legs must treat cancelled as not a verdict. This is wrong:
[[ "$smoke" == "success" || "$smoke" == "skipped" ]] || { echo "smoke failed"; exit 1; }
A superseded run sets smoke=cancelled, so the gate fails and paints the commit red forever. Accept cancelled too — it does not mask a real failure, because a genuinely failing leg aggregates to failure, not cancelled:
[[ "$smoke" == "success" || "$smoke" == "skipped" || "$smoke" == "cancelled" ]] || { echo "smoke failed"; exit 1; }
What NOT to do
- Do not filter
gh run listonconclusion=="failure"and call the absence of hits "green". That is the whole bug this skill exists for. - Do not take the latest run per workflow (
group_by(.name) | max_by(.createdAt)). A later scheduled run can hide an earlier failed push run on the same SHA. - Do not trust
--branch mainalone to find everything; a check can be attached by an app or aworkflow_run. Check-runs on the SHA catch all of them. - Do not conclude "not failing" from a bot issue being absent. The trunk-red bot files on its own schedule and its silence proves nothing.
- Do not read an exit code through a pipe (
gh ... | headthen$?). Redirect, capture$?on its own line, then inspect.
Reporting a finding
State the commit, the check name, the run URL, whether a later run on the same SHA passed, and the mechanism. "CI is red" without those is not a diagnosis. If a superseded run is the cause, say so plainly and name the fix — the commit is still red in the UI and someone has to look at it.
Version History
- 0695080 Current 2026-08-06 12:45


