| name | ci-diagnose |
| description | Diagnose a CI failure now — fetch the failed run, redact and match its logs against the F01-F12 pattern library, and report root cause with fixes. Use when a GitHub Actions run has failed and you want its root cause and a fix now (for the reference workflow guide, use the diagnose-ci skill). |
What It Does
Actively diagnoses a failed GitHub Actions run: validates prerequisites,
resolves the run, fetches the failed logs, redacts secrets and fences the
content, matches it against the F01-F12 failure-pattern library, and reports
the root cause with actionable fixes. This is the run diagnosis now skill;
the diagnose-ci skill is the reference workflow guide, not an executable
diagnosis.
When to Use
- A CI run failed and you want the root cause and a fix right now.
- The user asks to "diagnose the build", "why did CI fail?", or "what broke?".
Usage
The argument text after the skill name may contain a run ID (digits only) and
an optional --repo owner/name override. With no run ID, the latest failed run
is diagnosed.
Step 1: Validate Prerequisites
Check GitHub CLI authentication:
gh auth status 2>&1 | head -n 3
If not authenticated: "GitHub CLI not authenticated. Run: gh auth login".
Parse --repo first. If the argument text after the skill name contains
--repo owner/name, extract it into REPO_OVERRIDE and validate the format
now (exactly one /, alphanumeric plus hyphens, dots, and underscores);
report the format error and stop if it is invalid. An explicit override is a
complete repository context on its own, so when REPO_OVERRIDE is set,
skip the origin-remote detection below entirely and proceed to Step 2 —
otherwise the advertised override could never be used from outside a GitHub
checkout, which is exactly when it is most useful.
When no override was given, check repository context — resolve the origin
remote explicitly, accept only github.com remotes (SCP-like SSH, ssh://,
or HTTPS), and fail closed to NO_REMOTE on any command failure or
non-GitHub host:
REMOTE_URL=$(git remote get-url origin 2>/dev/null)
GIT_REMOTE_STATUS=$?
if [ "$GIT_REMOTE_STATUS" -ne 0 ] || [ -z "$REMOTE_URL" ]; then
REPO_CONTEXT="NO_REMOTE"
else
REPO_CONTEXT=$(printf '%s\n' "$REMOTE_URL" \
| grep -oE '^(git@github\.com:|https://github\.com/|ssh://git@github\.com(:[1-9][0-9]{0,4})?/)[^/]+/[^/]+$' \
| sed -E 's#^(git@github\.com:|https://github\.com/|ssh://git@github\.com(:[1-9][0-9]{0,4})?/)##; s/\.git$//')
[ -z "$REPO_CONTEXT" ] && REPO_CONTEXT="NO_REMOTE"
fi
if [ "$REPO_CONTEXT" = "NO_REMOTE" ]; then
echo "Not in a Git repository with a GitHub remote. Navigate to your project root, or pass --repo owner/name."
exit 1
fi
Stderr from git remote get-url is discarded (2>/dev/null), not piped into
the parser — an error message must never be mistaken for a repo slug. A
failed command or an empty URL yields NO_REMOTE directly. Any URL that
isn't a github.com SCP-like SSH (git@github.com:owner/repo(.git)), full
ssh:// (ssh://git@github.com/owner/repo(.git), optionally with a port —
ssh://git@github.com:2222/owner/repo(.git)), or HTTPS
(https://github.com/owner/repo(.git)) remote — including other hosts such
as GitLab, or a suffix-confusable host like github.com.evil.com — falls
through to NO_REMOTE as well. The host segment is matched as a literal
github\.com immediately followed by :, /, or an optional :PORT/, so a
lookalike host with github.com as a prefix never satisfies the pattern.
Bare ssh://github.com/... (no git@ userinfo) and the legacy git://
protocol are intentionally out of scope: GitHub requires the git user for
SSH, and it disabled the unauthenticated git:// protocol in 2021, so
neither form is a legitimate remote to accept here.
The block above already reports that message and stops (exit 1) when
$REPO_CONTEXT resolves to NO_REMOTE — this block only ever runs when no
--repo override was given (Step 1 skips it entirely otherwise), so the
message and the "no override" condition are one and the same check, made
in-block rather than deferred to a later step that could not read
$REPO_CONTEXT anyway.
Step 2: Resolve Run ID
REPO_OVERRIDE was parsed from the argument text by the model in Step 1
(that parsing gated the origin-remote check there), but Step 1 has no bash
block that assigns it — it was never bound as an actual shell variable, and
even if it had been, that binding would not survive into a later block's
fresh subprocess (see
docs/solutions/code-quality/bash-block-subshell-isolation-in-command-files.md).
Every executable block below that builds REPO_ARGS must therefore embed the
already-validated value as a literal itself — REPO_OVERRIDE="owner/name",
or an empty string if none was given — the same technique RUN_ID uses when
4a re-establishes it from Step 2's printed output. REPO_ARGS is then built
from it — reused by every gh run list/gh run view call below and in Step
4a — so each honors the override instead of the detected origin repo:
REPO_OVERRIDE="<the --repo value parsed in Step 1, or empty string if none>"
if [ -n "$REPO_OVERRIDE" ]; then
REPO_ARGS=(--repo "$REPO_OVERRIDE")
else
REPO_ARGS=()
fi
REPO_ARGS is empty when no override was given, so "${REPO_ARGS[@]}"
expands to nothing and each gh call falls back to gh's own repo detection
from the current directory. REPO_ARGS is a pure function of REPO_OVERRIDE
(itself just read from the argument text, no command execution) so re-embedding
the literal and rebuilding REPO_ARGS from it is cheap and safe to repeat
verbatim in every block below — unlike RUN_ID, which must not be rebuilt
(see Step 3).
Both branches below must leave RUN_ID bound to a value that has passed
^[1-9][0-9]{0,19}$ validation before it is ever passed to gh run view.
Resolving RUN_ID and fetching its run details (Step 3) must run as a
single Bash tool invocation. Each fenced snippet is a fresh subprocess — a
value assigned by command substitution in one is gone in the next (see
docs/solutions/code-quality/bash-block-subshell-isolation-in-command-files.md).
Capturing RUN_ID here and reading it from a separate Step 3 block would
leave $RUN_ID unbound when gh run view runs, regardless of how carefully
the capture itself is validated. The two paths below are therefore each
shown combined with the Step 3 fetch, not as a standalone block.
Explicit run ID. If the argument text after the skill name contains a run
ID (digits only), validate it against ^[1-9][0-9]{0,19}$ (no leading zeros,
max 9007199254740991), assign it to RUN_ID, and continue into the SAME
invocation as Step 3's fetch:
RUN_ID="<digits parsed from the argument text>"
if ! printf '%s' "$RUN_ID" | grep -qE '^[1-9][0-9]{0,19}$'; then
echo "Invalid run ID. Must be a positive integer (e.g., 123456789)"
exit 1
fi
REPO_OVERRIDE="<the --repo value parsed in Step 1, or empty string if none>"
if [ -n "$REPO_OVERRIDE" ]; then
REPO_ARGS=(--repo "$REPO_OVERRIDE")
else
REPO_ARGS=()
fi
RUN_DETAILS=$(gh run view "$RUN_ID" --json status,conclusion,jobs,headBranch,displayTitle,url,createdAt "${REPO_ARGS[@]}" 2>&1)
DETAILS_STATUS=$?
if [ "$DETAILS_STATUS" -ne 0 ]; then
echo "Could not fetch details for run $RUN_ID (gh exited $DETAILS_STATUS). Not diagnosing."
exit 1
fi
if sed --version </dev/null 2>/dev/null | grep -q 'GNU sed'; then
SED_CMD=sed
elif command -v gsed >/dev/null 2>&1 && gsed --version </dev/null 2>/dev/null | grep -q 'GNU sed'; then
SED_CMD=gsed
else
echo "Run-detail sanitization requires GNU sed; found only a non-GNU 'sed' and no 'gsed' on PATH. Install GNU sed (macOS: brew install gnu-sed) and retry. Refusing to display unredacted run metadata."
exit 1
fi
SAFE_DETAILS=$(
set -o pipefail
printf '%s\n' "$RUN_DETAILS" | "$SED_CMD" \
-e 's/\x01/?/g' \
-e 's/ghp_[A-Za-z0-9_]\{36,255\}/\x01REDACTED:github-token]/g' \
-e 's/ghs_[A-Za-z0-9_]\{36,255\}/\x01REDACTED:github-token]/g' \
-e 's/gho_[A-Za-z0-9_]\{36,255\}/\x01REDACTED:github-token]/g' \
-e 's/ghr_[A-Za-z0-9_]\{36,255\}/\x01REDACTED:github-token]/g' \
-e 's/ghu_[A-Za-z0-9_]\{36,255\}/\x01REDACTED:github-token]/g' \
-e 's/github_pat_[A-Za-z0-9_]\{22,255\}/\x01REDACTED:github-pat]/g' \
-e 's/AKIA[0-9A-Z]\{16\}/\x01REDACTED:aws-access-key]/g' \
-e 's/\(aws_secret_access_key\|AWS_SECRET_ACCESS_KEY\)[[:space:]]*[=:][[:space:]]*[A-Za-z0-9/+=]\{40,\}/\1=\x01REDACTED:aws-secret]/gI' \
-e 's/\(\(Authorization\|Proxy-Authorization\)[[:space:]]*:[[:space:]]*[A-Za-z][A-Za-z0-9_-]*\)[[:space:]]\+[^\x01[:space:]]\+\([[:space:]]\+[A-Za-z0-9_-]\+=[^\x01[:space:]]\+\)*/\1 \x01REDACTED]/gI' \
-e 's/\(\(Authorization\|Proxy-Authorization\)[[:space:]]*:[[:space:]]*\)[^\x01[:space:]]\+[[:space:]]*$/\1\x01REDACTED]/gI' \
-e 's/Bearer[[:space:]]\+[A-Za-z0-9._-]\{20,\}/Bearer [REDACTED]/g' \
-e 's/dckr_pat_[A-Za-z0-9_-]\{32,\}/\x01REDACTED:docker-token]/g' \
-e 's/npm_[A-Za-z0-9]\{36\}/\x01REDACTED:npm-token]/g' \
-e 's/pypi-[A-Za-z0-9_-]\{32,\}/\x01REDACTED:pypi-token]/g' \
-e 's/eyJ[A-Za-z0-9_-]\{10,500\}\.eyJ[A-Za-z0-9_-]\{10,500\}\.[A-Za-z0-9_-]\{10,500\}/\x01REDACTED:jwt]/g' \
-e 's/\(password\|passwd\|pwd\|secret\|token\|api_key\|apikey\|api-key\|auth\|credential\|private_key\|privatekey\|private-key\)[[:space:]]*[=:][[:space:]]*"\(\\.\|[^"\\]\)*"/\1=\x01REDACTED:quoted]/gI' \
-e "s/\(password\|passwd\|pwd\|secret\|token\|api_key\|apikey\|api-key\|auth\|credential\|private_key\|privatekey\|private-key\)[[:space:]]*[=:][[:space:]]*'\(\\\\.\\|[^'\\\\]\)*'/\1=\x01REDACTED:quoted]/gI" \
-e 's/\(-\{1,2\}\)\(password\|passwd\|pwd\|secret\|token\|api_key\|apikey\|api-key\|auth\|credential\|private_key\|privatekey\|private-key\)[[:space:]]\+"\(\\.\|[^"\\]\)*"/\1\2=\x01REDACTED:quoted]/gI' \