工程+文档(CI 合回零等待): 轮询等待件与本地回执判定退役,改按需现查 run 终局态
- 缘由(实证): ci_watch.sh 以「commit status 离开 pending」作终局判据——run 37 开跑 36 秒即写 verdict=red,该 run 三分半后 conclusion=success(现查 API),同 commit 先红后绿;远端时代 16 条 红回执所引 run 现查 15 条真红 1 条假红。另两条稳定假红源: 超时档(零触发批远端不建任务——现查90abdd4a的 statuses=null,只要挂了轮询必等满 3000s 写红;runner 排队同形)与多路轮询重复覆写 (fb27dc2f三条红挤在 15 秒内)。修判据不如撤等待: 判据移到开工前一次现查,竞态与超时两类假红 结构性消失。 - tools/ci_check.sh(新): 单次读 run 自身 status/conclusion,未 completed 与零触发一律呈报未落定 不写红;红态列失败 job 与失败步;只呈报不落盘。退出码 0绿/1红/2未落定/3参数与令牌。 孤立验证四形态: fb27dc2f→红(run 51 conclusion=failure) / 90abdd4a→未落定(零触发) / f1840fbc→未落定(run 55 queued) / 39acdcaa→绿(run 48 success)。 - tools/hooks/post-merge: 只推送不再挂后台轮询;push_failed 判定不再落 ci-status.json,改大声呈报。 - 删 tools/ci_watch.sh 与 .zcode/hooks/ci-status-banner.sh(不入 git 面,主区直改)并从 .zcode/config.json 注销——该横幅在当前 harness 只挂 worktree-guard 的配置下本就不投递(本会话 ci-status 为 red 全程零横幅)。 - 口径同步: AGENTS.md 运行与验证首条、process-conventions §1 第3条(回执等待纪律→合回零等待纪律)与 第4条执法分层②、§8 第8条、test-conventions §3 与 §8.7 审计证据行(中/低档证据由落盘回执改为 可复核的现查读数=commit+run号+conclusion)、run_all_gates.sh 头注与 .gates gitignore 模板句、 迭代技能两镜像三处(入 git 面外另记 .zcode/.qoder)。 - 待办清单条目「ci_watch 中间态误写终局红回执」整条删除(以退役方式了结,非按原判据修复)。 - 在案真红一条随本批呈报未处理: run 51(迭代 51 合回fb27dc2f) 失败步=导入缓存刷新+跑闸, 归因待取 job 日志——红未修不开新批的门按用户发令特批越过。
This commit is contained in:
1 parent
f1840fbc80
commit
3c1e3ddf07
9 files changed
+120
-162
No files matched your search
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI 现查件——按需读远端 run 的终局态,只呈报、不落盘(2026-11-04 用户裁:回执等待件与本地缓存判定退役)。
|
||||
# 用法: tools/ci_check.sh [<提交hash>](缺省=当前 HEAD)
|
||||
# 判据:Gitea commit status 只用来取本次任务的 run 链接,绿红一律认 run 自身的 status/conclusion——
|
||||
# run 未 completed(排队/在跑/尝试级中间态)与零触发批(纯闸件/管线批不建任务)一律呈报「未落定」,不写红。
|
||||
# 调用面:开工前读态(红未修不开新批)、合回后想知道结果时手动查——合回流程不等待、不唤醒,呈报即回合止。
|
||||
# 授权: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")"
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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("远端无该提交的任务——零触发批(纯闸件/管线批不建任务),或推送未触发 CI")
|
||||
|
||||
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))
|
||||
|
||||
if rconclusion == "success":
|
||||
print("CI现查: 绿 | commit %s | run %s conclusion=success" % (short(base), run_id))
|
||||
sys.exit(0)
|
||||
|
||||
red_jobs = []
|
||||
try:
|
||||
jobs = get(api + "/actions/runs/%s/jobs" % run_id)
|
||||
for j in jobs.get("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))
|
||||
except Exception as e:
|
||||
red_jobs.append(("<job明细获取失败:%s>" % e, []))
|
||||
|
||||
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
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/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
|
||||
+8
-16
@@ -1,27 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# 合回验证点(远端 CI 触发钩子,2026-11-03 用户裁)——主工作区 git merge 落地后:
|
||||
# ① push origin master(触发远端 CI:Gitea 收到 push → 派发 mac-ci runner 按改动域跑闸)
|
||||
# ② 后台挂 ci_watch.sh remote 等回执(回执落 ci-status.json,agent 下次开工读态零改动)
|
||||
# 推送失败=远端验证不可能发生——大声呈报并落 push_failed 判定进 ci-status.json(防静默,下游开工读态即见)。
|
||||
# 合回验证触发件(远端 CI 推送钩子,2026-11-03 用户裁;2026-11-04 用户裁去掉回执等待)。
|
||||
# 主工作区 git merge 落地后做一件事:`git push origin master` 触发远端 CI(Gitea Actions,
|
||||
# runner=Mac 本机 host 模式,按改动域分流跑闸)。判定结果不在本钩子取——合回即呈报本批总结,
|
||||
# 回合止;要看结果随时跑 tools/ci_check.sh <合并提交>(只呈报、不落盘,2026-11-04 取代原后台轮询件)。
|
||||
# 推送失败=远端验证不可能发生——大声呈报并给出处置(先修推送再手动 git push,之后用现查件读态),禁静默。
|
||||
# 任务 worktree 内的合并不触发(CI 只认 master);CI 红了不回滚合并——修复批合回后本钩子再推再验(追绿同链)。
|
||||
set -u
|
||||
_GD="$(cd "$(git rev-parse --git-dir)" && pwd)"
|
||||
_CD="$(cd "$(git rev-parse --git-common-dir)" && pwd)"
|
||||
[ "$_GD" = "$_CD" ] || exit 0
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
REPO="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
TIP="$(git -C "$REPO" rev-parse HEAD)"
|
||||
export PYTHONIOENCODING=utf-8
|
||||
if ! git -C "$REPO" push origin master >/dev/null 2>&1; then
|
||||
echo "[post-merge] ✗ push origin master 失败——远端 CI 无法触发!先修推送(凭据/网络)后手动 git push,再手动挂 tools/ci_watch.sh remote $TIP"
|
||||
python -I -c "
|
||||
import json, os, datetime
|
||||
p = r'$REPO/game/tests/.gates/ci-status.json'
|
||||
d = {'commit': r'$TIP', 'verdict': 'push_failed', 'timestamp': datetime.datetime.now().astimezone().isoformat(timespec='seconds'),
|
||||
'duration_s': 0, 'note': 'post-merge 推送失败,远端 CI 未触发——修复推送后手动 git push + ci_watch remote', 'red_gates': [], 'key_lines': []}
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
json.dump(d, open(p, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
||||
" 2>/dev/null
|
||||
echo "[post-merge] ✗ push origin master 失败——远端 CI 无法触发!先修推送(凭据/网络),再手动 git push,然后用 tools/ci_check.sh $TIP 读态。"
|
||||
exit 0
|
||||
fi
|
||||
( nohup bash "$REPO/tools/ci_watch.sh" remote "$TIP" >/dev/null 2>&1 & )
|
||||
echo "[post-merge] ✓ 已推送 origin master(commit ${TIP:0:8})——远端 CI 已触发;结果不等待,随时用 tools/ci_check.sh ${TIP:0:8} 现查。"
|
||||
exit 0
|
||||
@@ -4,9 +4,9 @@
|
||||
# 漏跑 probe_gate --full 的成因:清单散落三份文档、彼此不一致、且无机器校验)。
|
||||
#
|
||||
# 跑完写回执 game/tests/.gates/receipt.json(commit+内容树哈希+各闸退出码+存量基线比对+时间戳);
|
||||
# 远端 CI(2026-11-03 用户裁)读回执判定,回执经 tools/ci_watch.sh remote 轮询 Gitea API 写 ci-status.json——
|
||||
# 强制验证点=合回 master 后 post-merge 钩子推送触发的远端跑批;本脚本手动调用(--code/--docs/全量档)只服务开发期按域跑批。
|
||||
# 跑闸时代码面带着未提交改动,回执会标 dirty_game(验的不是 HEAD 字节),CI 判定照常落账留痕。
|
||||
# 强制验证点=合回 master 后 post-merge 钩子推送触发的远端跑批;远端结果用 tools/ci_check.sh 现查(只呈报、
|
||||
# 不落盘——2026-11-04 用户裁去掉回执等待件与本地判定缓存)。本脚本手动调用(--code/--docs/全量档)只服务开发期按域跑批。
|
||||
# 跑闸时代码面带着未提交改动,回执会标 dirty_game(验的不是 HEAD 字节)。
|
||||
#
|
||||
# 用法:tools/run_all_gates.sh [--code|--docs|--quick]
|
||||
# --code 只跑代码域闸(00 冒烟测试/01 系统测试/02 单元与集成测试/03 场景探针/
|
||||
@@ -86,7 +86,7 @@ fi
|
||||
# 每次跑批前自愈重建,避免「清目录重跑」把 .gitignore 一起带走。
|
||||
mkdir -p "$STAMP_DIR"
|
||||
if [ ! -f "$STAMP_DIR/.gitignore" ]; then
|
||||
printf '# 本目录只入库 baseline.json(存量基线,需随代码走以保证各 worktree 判定口径一致)。\n# receipt.json 是每跑批覆写的当次回执、ci-status.json/ci-history.jsonl 是 CI 回执判定与流水账(判定由远端 CI 产生)、\n# ci.lock/ci.rerun 是跑批件锁与补跑标记、*.log 是跑批当场日志——均为中间产物,不入库。\nreceipt.json\nci-status.json\nci-history.jsonl\nci.lock\nci.rerun\n*.log\n' \
|
||||
printf '# 本目录只入库 baseline.json(存量基线,需随代码走以保证各 worktree 判定口径一致)。\n# receipt.json 是每跑批覆写的当次回执、ci-status.json/ci-history.jsonl 是已退役的 CI 回执判定与流水账(判定只在远端,本机残留)、\n# ci.lock/ci.rerun 是跑批件锁与补跑标记、*.log 是跑批当场日志——均为中间产物,不入库。\nreceipt.json\nci-status.json\nci-history.jsonl\nci.lock\nci.rerun\n*.log\n' \
|
||||
> "$STAMP_DIR/.gitignore"
|
||||
echo "[gates] 已自愈重建 $STAMP_DIR/.gitignore"
|
||||
fi
|
||||
|
||||
Reference in new issue
Block a user