Files
xiaoxia-saas/scripts/ci/ci_health_check.py
T
xiaoxia 6eac0b2cf2
Worker Base Image Build / Build Worker Base Images (worker-base-builder-cache, infra/docker/worker-base-builder.Dockerfile, worker-base-builder, builder) (push) Failing after 1m54s
Worker Base Image Build / Build Worker Base Images (worker-base-runtime-cache, infra/docker/worker-base-runtime.Dockerfile, worker-base-runtime, runtime) (push) Failing after 1m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 44s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
chore(ci): 同步main分支CI配置与scripts/ci脚本 - 与develop对齐
同步内容:
1. CI流水线配置(ci-pipeline.yml)与develop对齐
2. PR构建脚本docker_build_only.sh增加buildx→docker build回退
3. pre-build步骤worker基础镜像构建增加buildx回退
4. 单元测试脚本全量覆盖率改为仅报告不阻塞
5. diff-cover依赖加入requirements-dev.txt
6. worker base builder/runtime Dockerfile同步
7. test_config_oss.py clear=False→clear=True修复OSS污染
8. Frontend Lint增加prettier依赖
2026-07-24 10:36:29 +08:00

299 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""
CI 健康度快速检查脚本
- 统计最近 N 条 run 的成功率(按 workflow 分类)
- 列出失败的 run 和失败的 job/step
- 区分基础设施问题 vs 业务代码问题
- 输出简洁的健康度报告
用法:
python3 scripts/ci/ci_health_check.py [--limit 20] [--workflow ci-pipeline.yml] [--json]
环境变量:
GITEA_TOKEN API token(必需)
GITEA_API_URL Gitea API 地址,默认 https://git.xiaoxiajianji.com/api/v1
GITEA_REPO 仓库,默认 xiaoxia/xiaoxia-saas
"""
import argparse
import json
import os
import sys
import urllib.request
from datetime import datetime, timedelta, timezone
# ---- 基础设施问题关键词(命中即判定为基础设施问题)----
INFRA_KEYWORDS = [
# 网络/连接
"Couldn't connect to server",
"Connection refused",
"Connection reset",
"Connection timed out",
"Failed to connect to",
"network is unreachable",
"TLS handshake timeout",
"SSL certificate problem",
# 容器/Runner
"No such container",
"container already exists",
"docker: not found",
"no space left on device",
"out of memory",
"OOMKilled",
"pull access denied",
"manifest unknown",
"Error response from daemon",
"runner",
"runner is not online",
"no matching runners",
# Checkout/Git
"Could not resolve host",
"fatal: unable to access",
"The remote end hung up unexpectedly",
"early EOF",
"index-pack failed",
"git fetch",
"checkout failed",
"ETXTBSY",
"text file busy",
# 镜像/环境
"No module named pip",
"pip: not found",
"command not found: python",
"python3: not found",
"node: not found",
"npm: not found",
"exec format error",
"standard_init_linux.go",
# 系统/资源
"Input/output error",
"device or resource busy",
"No space left on device",
"Disk full",
# 鉴权/配置
"401 Unauthorized",
"403 Forbidden",
"404 Not Found",
"identity_sign: private key",
"Permission denied",
]
def api_get(path: str) -> dict:
base = os.environ.get("GITEA_API_URL", "https://git.xiaoxiajianji.com/api/v1")
repo = os.environ.get("GITEA_REPO", "xiaoxia/xiaoxia-saas")
token = os.environ.get("GITEA_TOKEN", "")
url = f"{base}/repos/{repo}/{path}"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def get_run_jobs(run_id: int) -> list:
return api_get(f"actions/runs/{run_id}/jobs").get("jobs", [])
def get_job_log(job_id: int) -> str:
try:
return api_get(f"actions/jobs/{job_id}/logs")
except Exception:
return ""
def classify_failure(job: dict) -> str:
"""判断失败原因类型: infra / business / unknown"""
name = job.get("name", "")
# 仅根据 job 名称做初步分类(更精确需读日志,但代价高)
infra_jobs = ["Checkout", "Build", "Deploy", "Cleanup"]
business_jobs = [
"Unit Tests",
"Integration Tests",
"Frontend Lint",
"Frontend Unit Tests",
"Staging E2E",
"E2E",
"Validate Code Quality",
]
name_lower = name.lower()
if (
any(k.lower() in name_lower for k in infra_jobs)
and "Test" not in name
and "Lint" not in name
and "Validate" not in name
):
return "infra"
if any(k.lower() in name_lower for k in business_jobs):
return "business"
return "unknown"
def analyze_with_log(job_id: int) -> str:
"""通过日志关键词精确分类"""
log = get_job_log(job_id)
log_lower = log.lower()
for kw in INFRA_KEYWORDS:
if kw.lower() in log_lower:
return "infra"
return "business"
def fmt_time(t: str) -> str:
if not t or t.startswith("1970"):
return "-"
try:
dt = datetime.fromisoformat(t.replace("Z", "+00:00"))
bj = dt.astimezone(timezone(timedelta(hours=8)))
return bj.strftime("%m-%d %H:%M")
except Exception:
return t[:16]
def main():
parser = argparse.ArgumentParser(description="CI 健康度快速检查")
parser.add_argument("--limit", type=int, default=20, help="最近多少条 run")
parser.add_argument("--workflow", type=str, default="", help="只看某个 workflow")
parser.add_argument("--json", action="store_true", help="JSON 输出")
parser.add_argument("--deep", action="store_true", help="深度检查(读日志,较慢)")
args = parser.parse_args()
if not os.environ.get("GITEA_TOKEN"):
print("错误: 请设置 GITEA_TOKEN 环境变量", file=sys.stderr)
sys.exit(1)
# 1. 获取最近 run
runs = api_get(f"actions/runs?limit={args.limit}").get("workflow_runs", [])
if args.workflow:
runs = [r for r in runs if args.workflow in r.get("path", "")]
if not runs:
print("没有找到匹配的 run")
return
# 按 workflow 分组统计
wf_stats = {}
failed_runs = []
for r in runs:
path = r.get("path", "unknown")
# 提取 workflow 文件名,兼容各种 path 格式
if ".yml" in path or ".yaml" in path:
# ci-pipeline.yml@refs/heads/develop -> ci-pipeline.yml
wf = path.split("@")[0].split("/")[-1]
else:
wf = path.split("/")[-1] if "/" in path else path
if wf not in wf_stats:
wf_stats[wf] = {"total": 0, "success": 0, "failure": 0, "cancelled": 0, "others": 0}
wf_stats[wf]["total"] += 1
status = r.get("status", "")
conc = r.get("conclusion", "")
if status != "completed":
wf_stats[wf]["others"] += 1
continue
if conc == "success":
wf_stats[wf]["success"] += 1
elif conc == "failure":
wf_stats[wf]["failure"] += 1
failed_runs.append(r)
elif conc == "cancelled":
wf_stats[wf]["cancelled"] += 1
else:
wf_stats[wf]["others"] += 1
# 2. 失败 run 详情
failed_details = []
for r in failed_runs[:10]: # 最多看10个失败的
jobs = get_run_jobs(r["id"])
failed_jobs = [j for j in jobs if j.get("conclusion") == "failure"]
job_infos = []
for j in failed_jobs:
cat = classify_failure(j)
if args.deep and cat == "unknown":
cat = analyze_with_log(j["id"])
# 找失败的 step
failed_steps = []
for step in j.get("steps", []):
if step.get("conclusion") == "failure":
failed_steps.append(step.get("name", "?"))
job_infos.append(
{
"name": j.get("name", ""),
"category": cat,
"failed_steps": failed_steps,
"runner": j.get("runner_name", ""),
}
)
failed_details.append(
{
"id": r["id"],
"title": r.get("display_title", ""),
"branch": r.get("head_branch", ""),
"time": fmt_time(r.get("updated_at", "")),
"jobs": job_infos,
}
)
# 3. 输出
if args.json:
result = {"workflows": wf_stats, "failed_runs": failed_details}
print(json.dumps(result, ensure_ascii=False, indent=2))
return
# 文本报告
print("=" * 60)
print(" CI 健康度报告")
print("=" * 60)
print(f"统计范围: 最近 {len(runs)} 条 run")
print(f"时间: {datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')}")
print()
print("📊 各 Workflow 成功率:")
print("-" * 60)
for wf, s in sorted(wf_stats.items()):
total = s["total"]
succ = s["success"]
rate = (succ / total * 100) if total > 0 else 0
bar = "█" * int(rate / 5) + "░" * (20 - int(rate / 5))
icon = "🟢" if rate >= 90 else ("🟡" if rate >= 70 else "🔴")
print(f" {icon} {wf:35s} {rate:5.1f}% {bar} ({succ}/{total})")
if s["failure"]:
print(f" 失败: {s['failure']} 取消: {s['cancelled']} 进行中: {s['others']}")
if failed_details:
print()
print("❌ 失败详情:")
print("-" * 60)
for d in failed_details:
print(f" #{d['id']} [{d['time']}] {d['title'][:45]}")
print(f" 分支: {d['branch']}")
for j in d["jobs"]:
cat_icon = "🏗️" if j["category"] == "infra" else ("🐛" if j["category"] == "business" else "❓")
steps = ", ".join(j["failed_steps"][:3]) if j["failed_steps"] else "未知"
print(f" {cat_icon} {j['name'][:30]:30s} 失败步骤: {steps}")
if j["runner"]:
print(f" runner: {j['runner']}")
else:
print()
print("✅ 最近没有失败的 run")
# 总结
total_all = sum(s["total"] for s in wf_stats.values())
succ_all = sum(s["success"] for s in wf_stats.values())
fail_all = sum(s["failure"] for s in wf_stats.values())
infra_fail = sum(1 for d in failed_details for j in d["jobs"] if j["category"] == "infra")
biz_fail = sum(1 for d in failed_details for j in d["jobs"] if j["category"] == "business")
rate_all = (succ_all / total_all * 100) if total_all > 0 else 0
print()
print("=" * 60)
print(f" 总结: 总成功率 {rate_all:.1f}% ({succ_all}/{total_all})")
if fail_all > 0:
print(f" 失败job分类: 基础设施 {infra_fail} 个 | 业务代码 {biz_fail} 个")
if infra_fail > biz_fail:
print(" ⚠️ 主要是基础设施问题,建议优先排查 CI 环境")
else:
print(" 💡 主要是业务代码问题,建议关注业务侧修复")
print("=" * 60)
if __name__ == "__main__":
main()