#!/usr/bin/env bash # CI 现查件——按需读远端 run 的终局态,只呈报、不落盘(2026-11-04 用户裁:回执等待件与本地缓存判定退役)。 # 用法: tools/ci_check.sh [<提交hash>](缺省=当前 HEAD) # 判据:Gitea commit status 只用来取本次任务的 run 链接,绿红一律认 run 自身的 status/conclusion, # 且绿必须坐实「跑闸步真跑过」——零触发批的 run 照样建、照样 success,但跑闸步是 skipped, # 一条闸都没跑,读成绿就是假绿(2026-11-04 run 55 实证)。run 未 completed(排队/在跑/尝试级中间态)呈报未落定。 # 调用面:开工前读态(红未修不开新批)、合回后想知道结果时手动查——合回流程不等待、不唤醒,呈报即回合止。 # 授权:GITEA_TOKEN 环境变量优先,否则读 git 凭据库(host=git.kuaimeo.com)。 # 退出码: 0=绿(跑闸步真跑过) 1=红 2=未落定/不可判/查不到 3=参数或令牌错误 4=未验(零触发批,跑闸步全跳过) set -u COMMON="$(git -C "$(dirname "$0")" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" \ || { echo "不在 git 仓库内"; exit 3; } REPO="$(dirname "$COMMON")" BASE="${1:-}" [ -n "$BASE" ] || BASE="$(git -C "$REPO" rev-parse HEAD)" BASE="$(git -C "$REPO" rev-parse --verify "${BASE}^{commit}")" || { echo "提交不存在: $BASE"; exit 3; } _origin="$(git -C "$REPO" remote get-url origin 2>/dev/null || true)" REPO_SLUG="$(printf '%s' "$_origin" | grep -oE '[^/:]+/[^/]+\.git$' | sed 's/\.git$//')" [ -n "$REPO_SLUG" ] || { echo "无法从 origin 解析 owner/repo: $_origin"; exit 3; } TOKEN="${GITEA_TOKEN:-}" if [ -z "$TOKEN" ]; then TOKEN="$(printf 'protocol=https\nhost=git.kuaimeo.com\n\n' | git credential fill 2>/dev/null | sed -n 's/^password=//p' | head -1)" fi [ -n "$TOKEN" ] || { echo "取不到 Gitea 令牌(设 GITEA_TOKEN 或先 git credential approve 存储)"; exit 3; } API="https://git.kuaimeo.com/api/v1/repos/$REPO_SLUG" WEB="https://git.kuaimeo.com/$REPO_SLUG" export PYTHONIOENCODING=utf-8 python -I - "$API" "$WEB" "$BASE" "$TOKEN" <<'PYEOF' import json, re, sys, urllib.request, urllib.error api, web, base, token = sys.argv[1:5] H = {"Authorization": "token " + token} def get(url, t=20): req = urllib.request.Request(url, headers=H) return json.load(urllib.request.urlopen(req, timeout=t)) def short(sha): return sha[:8] def undetermined(msg): print("CI现查: 未落定 | commit %s | %s" % (short(base), msg)) sys.exit(2) def unknown(msg): print("CI现查: 不可判 | commit %s | %s" % (short(base), msg)) sys.exit(2) try: s = get(api + "/commits/%s/status" % base) except urllib.error.HTTPError as e: undetermined("查 commit status 失败 HTTP %s" % e.code) except Exception as e: undetermined("查 commit status 失败:%s" % e) statuses = s.get("statuses") or [] target = next((x.get("target_url", "") for x in statuses if x.get("target_url")), "") if not statuses: undetermined("远端无该提交的 status——该批没推上去(或推的不是 master);零触发批有 run,走 run 侧读数") m = re.search(r"/actions/runs/(\d+)", target or "") if not m: undetermined("commit status 无 run 链接(state=%s),查 %s/actions" % (s.get("state"), web)) run_id = m.group(1) try: run = get(api + "/actions/runs/%s" % run_id) except Exception as e: undetermined("查 run %s 失败:%s" % (run_id, e)) rstatus, rconclusion = run.get("status"), run.get("conclusion") if rstatus != "completed": undetermined("run %s status=%s(runner 需开机在线;队列与日志见 %s/actions)" % (run_id, rstatus, web)) try: jobs = get(api + "/actions/runs/%s/jobs" % run_id).get("jobs", []) or [] except Exception as e: unknown("查 run %s 的 job 明细失败:%s——取不到步级读数就不下绿" % (run_id, e)) gate_steps = [x.get("conclusion") for j in jobs for x in (j.get("steps") or []) if "跑闸" in (x.get("name") or "")] if rconclusion == "success": if not jobs: unknown("run %s conclusion=success 但 job 列表为空,无法确认闸是否真跑" % run_id) if not gate_steps: unknown("run %s 里没有「跑闸」步——工作流结构已改,本件的步级判据需随动(查 %s/actions/runs/%s)" % (run_id, web, run_id)) if all(c == "skipped" for c in gate_steps): print("CI现查: 未验 | commit %s | run %s conclusion=success 但跑闸步全为 skipped——零触发批,远端一条闸都没跑" % (short(base), run_id)) sys.exit(4) print("CI现查: 绿 | commit %s | run %s conclusion=success,跑闸步 %d 条真跑过(闸况明细查 job 日志)" % (short(base), run_id, len(gate_steps))) sys.exit(0) red_jobs = [] for j in jobs: if j.get("conclusion") in ("success", "skipped", None): continue bad_steps = [x.get("name") for x in (j.get("steps") or []) if x.get("conclusion") not in ("success", "skipped")] red_jobs.append((j.get("name"), bad_steps)) if not red_jobs: red_jobs.append(("" % rconclusion, [])) print("CI现查: 红 | commit %s | run %s conclusion=%s" % (short(base), run_id, rconclusion)) for name, steps in red_jobs: print(" 红 job:%s" % name) for st in steps: print(" 失败步:%s" % st) print(" 日志:%s" % (target or "%s/actions/runs/%s" % (web, run_id))) sys.exit(1) PYEOF