#!/usr/bin/env bash # 本地 CI 跑批件——合回 master 后的全量验证执行器(git 钩子触发、后台运行,2026-10-30 用户裁)。 # 触发语义(钩子只负责拉起,判定在本件): # merge 模式=合回验证点:主工作区 git merge 落地后(tools/hooks/post-merge)对 master 尖无条件 # 跑一次全量域闸;任务 worktree 内的合并不触发(钩子里限主工作区)。 # commit 模式=红态追绿:仅当最近一次 CI 判红时(tools/hooks/post-commit)本提交触发复验; # 绿态与无状态零触发——小批直提不重复跑全量,有意义的重跑只剩「验证修复」一类。 # 并跑语义=跑完补跑(coalesce,2026-10-30 用户裁):跑批期间新触发不中断当前轮——锁被占时, # 在跑实例的尖端就是当前尖端则本触发无事可做,否则落 ci.rerun 标记;当前轮跑完见标记或尖端 # 前移,对最新尖端自动补跑一轮。绝不逐笔触发各跑一轮,最终只测最新尖端。 # 产物(均不入库,本目录 .gitignore 已收):ci-status.json=最新一跑判定(commit/verdict/红闸/ # 关键日志行/耗时),ci-history.jsonl=逐次流水账,ci.log=当轮全量闸完整输出, # ci.lock=锁目录(内含在跑尖端),ci.rerun=补跑标记。 # 用法: tools/ci_runner.sh merge|commit set -u REPO="$(cd "$(dirname "$0")/.." && pwd)" GATES_DIR="$REPO/game/tests/.gates" LOCK="$GATES_DIR/ci.lock" RERUN="$GATES_DIR/ci.rerun" STATUS="$GATES_DIR/ci-status.json" HISTORY="$GATES_DIR/ci-history.jsonl" LOG="$GATES_DIR/ci.log" GODOT="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}" export PYTHONIOENCODING=utf-8 MODE="${1:-commit}" # ── 触发判定 ── if [ "$MODE" = "commit" ]; then [ -f "$STATUS" ] || exit 0 grep -q '"verdict": "red"' "$STATUS" || exit 0 fi # ── 锁(mkdir 原子);被占时按在跑尖端判定:同尖=已在验这份字节,异尖=落补跑标记 ── TIP="$(git -C "$REPO" rev-parse HEAD)" if ! mkdir "$LOCK" 2>/dev/null; then if [ "$(cat "$LOCK/tip" 2>/dev/null)" = "$TIP" ]; then exit 0 fi touch "$RERUN" exit 0 fi trap 'rm -rf "$LOCK"' EXIT # ── 跑批主循环(跑完补跑)── while :; do rm -f "$RERUN" TIP="$(git -C "$REPO" rev-parse HEAD)" echo "$TIP" > "$LOCK/tip" _start="$(date +%s)" # 起手刷类名缓存:.godot 缓存不入 git,与合并后代码失配会假红假绿(在册坑)—— # 原「合并后零复跑」的秒级动作由本件接管,全量闸跑的就是刷新后的字节 "$GODOT" --headless --path "$REPO/game" --editor --quit >>"$LOG" 2>&1 bash "$REPO/tools/run_all_gates.sh" >"$LOG" 2>&1 RC=$? _end="$(date +%s)" # 判定与记录:读闸脚本写的回执(all_green/各闸退出码),红项关键行取自本轮 ci.log python - "$REPO" "$TIP" "$RC" "$((_end - _start))" <<'PY' import json, re, sys, datetime repo, tip, rc, dur = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]) gates_dir = repo + "/game/tests/.gates" entry = {"commit": tip, "timestamp": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), "exit_code": rc, "verdict": "green" if rc == 0 else "red", "duration_s": dur, "red_gates": [], "key_lines": []} try: with open(gates_dir + "/receipt.json", encoding="utf-8") as f: r = json.load(f) entry["red_gates"] = [g["name"] for g in r.get("gates", []) if str(g.get("exit")) != "0"] except Exception: pass try: with open(gates_dir + "/ci.log", encoding="utf-8", errors="replace") as f: lines = [l.rstrip() for l in f] entry["key_lines"] = [l for l in lines if re.search(r"FAIL|红项|Error|error", l)][-12:] except Exception: pass with open(gates_dir + "/ci-status.json", "w", encoding="utf-8") as f: json.dump(entry, f, ensure_ascii=False, indent=1) with open(gates_dir + "/ci-history.jsonl", "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") PY # 轮尾补跑判定:跑批期间有新触发(标记在)或尖端前移 → 对最新尖端再来一轮 _newtip="$(git -C "$REPO" rev-parse HEAD)" if [ ! -f "$RERUN" ] && [ "$_newtip" = "$TIP" ]; then exit 0 fi done