127 lines
5.9 KiB
Bash
Executable File
127 lines
5.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 回执等待件——合回后等远端 CI 回执的后台轮询器(2026-11-03 用户裁:CI 迁远端,本件改轮询 Gitea API)。
|
||
# 用法: tools/ci_watch.sh remote <推送提交hash> [超时秒,默认 3000]
|
||
# 调用面: post-merge 钩子推送 master 成功后自动后台挂起;人手等待亦可(agent 会话以 run_in_background 运行,
|
||
# 命令结束时 harness 唤醒——绿→删区收口;红→呈报红闸名单走修复批;超时→呈报「回执未落」)。
|
||
# 「本次回执已落」判据:Gitea commit status 的 state 离开 pending(success/failure/error),
|
||
# 且 status 的 target_url 指向本仓库 Actions(防认错仓)。远端跑批 20-35 分钟量级,默认超时给足 50 分钟。
|
||
# 产物:沿用本地 CI 时代的回执合同——game/tests/.gates/ci-status.json(commit/verdict/timestamp/
|
||
# duration_s/red_gates/key_lines/note)覆写 + ci-history.jsonl 追加;红态 red_gates=失败的 job 名单
|
||
# (来自 runs/<id>/jobs API),job 日志链接在 key_lines,细粒度红闸清单以 job 日志为准。
|
||
# 授权:GITEA_TOKEN 环境变量优先,否则读 git 凭据库(host=git.kuaimeo.com)。
|
||
# 退出码: 0=绿 1=红 2=超时/回执未落 3=参数错误
|
||
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")"
|
||
GATES_DIR="$REPO/game/tests/.gates"
|
||
STATUS="$GATES_DIR/ci-status.json"
|
||
HISTORY="$GATES_DIR/ci-history.jsonl"
|
||
|
||
MODE="${1:-}"
|
||
BASE="${2:-}"
|
||
TIMEOUT="${3:-3000}"
|
||
INTERVAL=30
|
||
case "$MODE" in
|
||
remote) ;;
|
||
*)
|
||
echo "用法: ci_watch.sh remote <推送提交hash> [超时秒](本地 CI 模式已退役——回执现由远端产生)"
|
||
exit 3 ;;
|
||
esac
|
||
[ -n "$BASE" ] || { echo "用法: ci_watch.sh remote <推送提交hash> [超时秒]"; exit 3; }
|
||
BASE="$(git -C "$REPO" rev-parse --verify "${BASE}^{commit}")" || { echo "提交不存在: $BASE"; exit 3; }
|
||
|
||
# 仓库全名(owner/repo)从 origin URL 解析(HTTPS 与 SSH 形式都兼容)
|
||
_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; }
|
||
|
||
# 令牌:环境变量优先,回落 git 凭据库
|
||
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"
|
||
|
||
export PYTHONIOENCODING=utf-8
|
||
python -I - "$API" "$BASE" "$TOKEN" "$TIMEOUT" "$INTERVAL" "$STATUS" "$HISTORY" <<'PYEOF'
|
||
import json, os, re, subprocess, sys, time, urllib.request, urllib.error, datetime
|
||
|
||
api, base, token, timeout, interval, status_path, history_path = sys.argv[1:8]
|
||
timeout, interval = int(timeout), int(interval)
|
||
deadline = time.time() + timeout
|
||
start = time.time()
|
||
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 combined_status(sha):
|
||
return get(api + "/commits/%s/status" % sha)
|
||
|
||
|
||
def write_receipt(verdict, note, red_gates, key_lines):
|
||
now = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
||
dur = int(time.time() - start)
|
||
d = {"commit": base, "verdict": verdict, "timestamp": now,
|
||
"duration_s": dur, "note": note, "red_gates": red_gates, "key_lines": key_lines}
|
||
os.makedirs(os.path.dirname(status_path), exist_ok=True)
|
||
tmp = status_path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(d, f, ensure_ascii=False, indent=1)
|
||
os.replace(tmp, status_path)
|
||
with open(history_path, "a", encoding="utf-8") as f:
|
||
f.write(json.dumps(d, ensure_ascii=False) + "\n")
|
||
return d
|
||
|
||
|
||
def emit(d):
|
||
print("CI回执:", d.get("verdict"), "| commit", d.get("commit", "")[:8], "| 耗时", d.get("duration_s"), "s")
|
||
if d.get("note"):
|
||
print("note:", d["note"])
|
||
for g in d.get("red_gates", []):
|
||
print("红闸:", g)
|
||
for l in d.get("key_lines", []):
|
||
print("key:", l)
|
||
|
||
|
||
last_err = ""
|
||
while True:
|
||
try:
|
||
s = combined_status(base)
|
||
state = s.get("state", "")
|
||
statuses = s.get("statuses", [])
|
||
target = next((x.get("target_url", "") for x in statuses if x.get("target_url")), "")
|
||
if state in ("success", "failure", "error", "warning"):
|
||
red = state != "success"
|
||
red_gates, run_id = [], None
|
||
m = re.search(r"/actions/runs/(\d+)", target)
|
||
if m and red:
|
||
run_id = m.group(1)
|
||
try:
|
||
jobs = get(api + "/actions/runs/%s/jobs" % run_id)
|
||
red_gates = sorted({j["name"] for j in jobs.get("jobs", []) if j.get("status") not in ("success", "skipped")})
|
||
except Exception as e:
|
||
red_gates = ["<job明细获取失败:%s>" % e]
|
||
note = "远端CI state=%s%s" % (state, ("(run %s)" % run_id) if run_id else "")
|
||
key_lines = ["job日志: %s" % target] if target else []
|
||
d = write_receipt("green" if not red else "red", note, red_gates, key_lines)
|
||
emit(d)
|
||
sys.exit(0 if not red else 1)
|
||
# pending:继续等
|
||
except urllib.error.HTTPError as e:
|
||
last_err = "HTTP %s" % e.code
|
||
except Exception as e:
|
||
last_err = str(e)
|
||
if time.time() > deadline:
|
||
note = "超时(%ds)远端回执未落——最后错误 %s;查 runner 在线态与 %s/actions" % (timeout, last_err or "无", api.rsplit("/repos/", 1)[0])
|
||
d = write_receipt("red", note, ["<回执未落>"], [])
|
||
emit(d)
|
||
sys.exit(2)
|
||
time.sleep(interval)
|
||
PYEOF
|