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
同步内容: 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依赖
299 lines
9.8 KiB
Python
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()
|