- 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).
- user-invocable
- false
## 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:
```bash
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:
```bash
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
# This block's own subprocess ends here, so the decision below must be made
# now, in-block — a later block could not read $REPO_CONTEXT at all.
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:
```bash
# Illustrative shape only — not run standalone. Each executable block below
# embeds this same pattern with REPO_OVERRIDE set to a literal, since none of
# them can inherit Step 1's parsing.
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:
```bash
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
# Step 3, same invocation: RUN_ID is bound and validated above.
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
# Portability gate — GNU sed required: displayTitle, headBranch, and job/step
# names are attacker-controllable and go through the same redaction pipeline
# as log content below (4a), which relies on GNU-only sed (`\x01`, `\|` BRE
# alternation, the `I` flag). A non-GNU sed silently fails to match these
# instead of erroring, so detect it here (mirroring 4a's gate) and refuse
# rather than risk displaying an unredacted credential.
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
# Redact secrets in run metadata BEFORE fence-escaping — same 13+-pattern
# pipeline 4a applies to log content (see 4a's comment for the
# PROTECT/RESTORE provenance rationale; mirrors hooks/scripts/lib/redact.sh).
# displayTitle, headBranch, and job/step names are user-authored strings that
# can carry an accidentally-committed credential as easily as a log line can.
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' \
GitHubで見る