工程(ci远端化·阶段四五): 远端CI触发链落地——新增.gitea/workflows/ci.yml(push触发,runs-on=ci-mac,域分流经tools/ci/domain_route.sh:游戏码批=全量/文档批=--docs秒级/纯闸件批=skip,乙案语义); ci_watch.sh整体重写为远端模式(轮询Gitea commit-status API,python urllib,令牌GITEA_TOKEN或git凭据库,回执合同不变:ci-status.json+ci-history.jsonl,红态red_gates=失败job名单); post-merge钩子改推送触发(push失败落push_failed判定防静默)+后台挂ci_watch; 退役本地CI件(ci_runner.sh+post-commit钩子,git rm); 新增tools/ci/mac_setup.sh自举件(专用账户ci-runner随机密码不可登录/Godot4.7.2+gdstyle v0.4.0 darwin装/usr/local/bin+Pillow+LaunchDaemon系统级常驻,镜像路线退役后环境配方的仓库化); 依据=用户裁CI迁远端Mac host模式
This commit is contained in:
1 parent
c28d01ccb0
commit
0019ef1514
7 files changed
+385
-162
No files matched your search
@@ -0,0 +1,36 @@
|
||||
# 远端 CI 工作流(2026-11-03 用户裁:CI 迁远端 Mac runner,host 模式直跑)。
|
||||
# 触发:任何 push;跑什么由 tools/ci/domain_route.sh 按改动域分流(乙案)——
|
||||
# 游戏码批=两域全量(含渲染闸);文档批=文档域秒级闸;纯闸件/管线批=零触发。
|
||||
# 运行环境:Mac host 模式(tools/ci/mac_setup.sh 自举),GODOT_BIN/GDSTYLE_BIN 由自举件安装到位。
|
||||
# 闸清单与域划分唯一出处 = tools/run_all_gates.sh(本文件只负责触发与分流,不另列闸件)。
|
||||
name: ci
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
runs-on: ci-mac
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: 检出
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 域分流需要 base..head 的完整 diff
|
||||
|
||||
- name: 域分流(乙案)
|
||||
id: route
|
||||
run: bash tools/ci/domain_route.sh "${{ github.event.before }}" "${{ github.sha }}"
|
||||
|
||||
- name: 导入缓存刷新(闸前置——.godot 不入 git,冷检出一付)
|
||||
if: steps.route.outputs.mode != 'skip'
|
||||
run: /usr/local/bin/godot --headless --path game --editor --quit
|
||||
|
||||
- name: 跑闸
|
||||
if: steps.route.outputs.mode != 'skip'
|
||||
env:
|
||||
GODOT_BIN: /usr/local/bin/godot
|
||||
GDSTYLE_BIN: /usr/local/bin/gdstyle
|
||||
run: |
|
||||
MODE="${{ steps.route.outputs.mode }}"
|
||||
if [ "$MODE" = "full" ]; then ARGS=""; else ARGS="--docs"; fi
|
||||
echo "[ci] 域=$MODE 跑闸参数=$ARGS"
|
||||
bash tools/run_all_gates.sh $ARGS
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# 乙案域分流——push 触发的远端 CI 按改动域选闸(2026-11-03 用户裁)。
|
||||
# 用法: tools/ci/domain_route.sh <base_sha> <head_sha>
|
||||
# 输出: stdout 打印 full|docs|skip;GITHUB_OUTPUT 在位时另写 mode= 行(Actions 步骤输出)。
|
||||
# 规则(唯一事实源,乙案语义):
|
||||
# 1. diff 含 game/ 下非 .md 文件(行为载体:代码/场景/数据表/素材)→ full(两域全量,渲染闸随 code 域)
|
||||
# 2. 否则 diff 非空(design/ 文档、reports/、根 md、game/ 下 .md 等一切非代码面)→ docs
|
||||
# (乙案:文档批只跑文档域秒级闸——gdd_lint/doc_link/doc_term/pipeline/doc_ref/capability+熵增)
|
||||
# 例外:diff 只含 tools/ 与 .gitea/(闸件与管线自身改动)→ skip——闸件改动不占合回全量,
|
||||
# 质量义务=落批人工确认可跑通(run_all_gates.sh 头注既有口径,不在远端重复烧引擎)。
|
||||
# 3. diff 空 → skip。
|
||||
# 判定面说明:game/tests/** 与 game/sim/** 属 game/ 行为载体(.md 除外)→ full,
|
||||
# 与 run_all_gates.sh 关联档口径一致(测量面批 02 照跑全套件)。
|
||||
set -u
|
||||
base="${1:-}"
|
||||
head="${2:-HEAD}"
|
||||
[ -n "$base" ] || { echo "用法: domain_route.sh <base_sha> <head_sha>" >&2; exit 2; }
|
||||
head="$(git rev-parse --verify "${head}^{commit}" 2>/dev/null)" || { echo "坏 head: $head" >&2; exit 2; }
|
||||
# base 不可用(新分支 zero-sha/被 force-push 掉)→ 回落 HEAD~1;孤提交(无父)→ docs 保守最小跑。
|
||||
git cat-file -e "$base^{commit}" 2>/dev/null || base="$(git rev-parse "$head~1" 2>/dev/null || true)"
|
||||
if [ -z "$base" ]; then
|
||||
mode="docs"
|
||||
else
|
||||
files="$(git diff --name-only "$base" "$head" 2>/dev/null)"
|
||||
if [ -z "$files" ]; then
|
||||
mode="skip"
|
||||
elif echo "$files" | grep -vE '\.md$' | grep -qE '^game/'; then
|
||||
mode="full"
|
||||
else
|
||||
non_tools="$(echo "$files" | grep -vE '^(tools/|\.gitea/)' || true)"
|
||||
if [ -n "$non_tools" ]; then mode="docs"; else mode="skip"; fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'mode=%s\n' "$mode" >> "$GITHUB_OUTPUT"; fi
|
||||
echo "$mode"
|
||||
exit 0
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env bash
|
||||
# mac_setup.sh — CI runner 自举件(Mac 本机 host 模式;2026-11-03 用户裁,取代 Docker 镜像路线)。
|
||||
# 一条命令把一台 macOS 变成 gplan 的远端 CI runner:专用低权限账户+Godot+gdstyle+Pillow+act_runner 常驻。
|
||||
# 用法(sudo 必须——建账户与写 /Library/LaunchDaemons 都要 root):
|
||||
# sudo bash tools/ci/mac_setup.sh [--godot-zip <本地已下载的 macOS 版 zip 路径>]
|
||||
# 环境前提:
|
||||
# ① macOS(Intel/Apple Silicon 皆可,Godot 走 universal 包)② Xcode Command Line Tools 已装(python3/git 在位:
|
||||
# 缺则先 xcode-select --install)③ 调用账户的 ~/gitea-runner/act_runner 已就位(gitea.com gitea/runner
|
||||
# releases 的 darwin 包,版本与注册时一致)且该目录含已注册的 .runner(注册动作一次性,见 Gitea 文档)。
|
||||
# 安全设计(用户裁「收紧 runner 权限」):
|
||||
# - 所有作业以专用账户 ci-runner 运行:无 admin、无 sudo、可写面仅其家目录与仓库工作区;
|
||||
# 与日常账户隔离——测试代码摸不到日常环境(威胁模型=测试写坏环境,非恶意第三方件)。
|
||||
# - runner 以 LaunchDaemon(系统级)常驻:开机自启、不依赖任何人登录、崩溃自动拉起。
|
||||
# 幂等:重复执行只补缺件,不重复建账户/不重复下载(本地已有同版本产物即跳过)。
|
||||
set -euo pipefail
|
||||
|
||||
GODOT_ZIP=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--godot-zip) GODOT_ZIP="${2:-}"; shift 2 ;;
|
||||
*) die "未知参数: $1(用法见头注)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
GODOT_VER="4.7.2-stable"
|
||||
GODOT_URL_BASE="https://github.com/godotengine/godot/releases/download/${GODOT_VER}-stable/Godot_${GODOT_VER}-stable_macos.universal.zip"
|
||||
GDSTYLE_VER="0.4.0"
|
||||
GDSTYLE_URL="https://github.com/atelico/gdstyle/releases/download/v${GDSTYLE_VER}/gdstyle-x86_64-apple-darwin.tar.gz"
|
||||
MIRRORS=("" "https://gh-proxy.net/" "https://gh-proxy.com/") # 直连优先,镜像回落
|
||||
CI_USER="ci-runner"
|
||||
CI_HOME="/Users/${CI_USER}"
|
||||
|
||||
log() { echo "[mac_setup] $*"; }
|
||||
die() { echo "[mac_setup] ✗ $*" >&2; exit 1; }
|
||||
|
||||
[ "$(uname -s)" = "Darwin" ] || die "仅支持 macOS"
|
||||
[ "$(id -u)" -eq 0 ] || die "请用 sudo 运行(建账户/写 LaunchDaemon 需要 root)"
|
||||
for t in git curl python3 unzip dscl; do command -v "$t" >/dev/null 2>&1 || die "缺 $t(先 xcode-select --install)"; done
|
||||
INVOKER="${SUDO_USER:-$(whoami)}"
|
||||
RUNNER_SRC="/Users/${INVOKER}/gitea-runner"
|
||||
[ -x "${RUNNER_SRC}/act_runner" ] || die "缺 ${RUNNER_SRC}/act_runner——先从 gitea.com/gitea/runner releases 下载 darwin 包(版本须与 .runner 注册时一致)"
|
||||
[ -f "${RUNNER_SRC}/.runner" ] || die "缺 ${RUNNER_SRC}/.runner——act_runner 尚未注册(register 子命令,见 Gitea 文档)"
|
||||
|
||||
# ── 1. 专用账户(sysadminctl 一条建全:随机未告知密码=无法交互登录,纯服务账户)──
|
||||
if dscl . -read "${CI_HOME}" NFSHomeDirectory >/dev/null 2>&1; then
|
||||
log "账户 ${CI_USER} 已存在,跳过创建"
|
||||
else
|
||||
_randpw="$(uuidgen | tr -d '-')"
|
||||
/usr/sbin/sysadminctl -addUser "$CI_USER" -password "$_randpw" -home "$CI_HOME" \
|
||||
|| die "账户创建失败(sysadminctl)——检查磁盘/SecureToken 设置"
|
||||
log "账户 ${CI_USER} 已创建(随机密码未落盘=无法交互登录,纯服务账户)"
|
||||
fi
|
||||
|
||||
# ── 2. Godot macOS 版 → /usr/local/bin/godot ──
|
||||
if /usr/local/bin/godot --version >/dev/null 2>&1 && /usr/local/bin/godot --version 2>/dev/null | grep -q "${GODOT_VER}"; then
|
||||
log "Godot ${GODOT_VER} 已在位,跳过"
|
||||
else
|
||||
ZIP="$GODOT_ZIP"
|
||||
SRC_ZIP="/Users/${INVOKER}/godot-linux/Godot_${GODOT_VER}-stable_macos.universal.zip"
|
||||
if [ -n "$ZIP" ] && [ -s "$ZIP" ]; then
|
||||
log "使用本地 zip: $ZIP"
|
||||
elif [ -z "$ZIP" ] && [ -s "$SRC_ZIP" ]; then
|
||||
log "复用已下载 zip: $SRC_ZIP"
|
||||
ZIP="$SRC_ZIP"
|
||||
else
|
||||
ZIP="$(mktemp "${TMPDIR:-/tmp}/godot_mac_XXXXXX.zip")"
|
||||
_ok=0
|
||||
for m in "${MIRRORS[@]}"; do
|
||||
url="${m}${GODOT_URL_BASE}"
|
||||
log "下载 Godot(${m:+经 $m}):${GODOT_URL_BASE}"
|
||||
if curl -fL --retry 2 -m 1800 -o "$ZIP" "$url"; then _ok=1; break; fi
|
||||
log "该源失败,换下一源"
|
||||
done
|
||||
[ "$_ok" = 1 ] || die "全部下载源失败——请手动下载 ${GODOT_URL_BASE} 后重跑本脚本加 --godot-zip <zip路径>"
|
||||
fi
|
||||
TMPD="$(mktemp -d)"
|
||||
unzip -q -o "$ZIP" -d "$TMPD"
|
||||
install -m 0755 "${TMPD}/Godot.app/Contents/MacOS/Godot" /usr/local/bin/godot
|
||||
xattr -dr com.apple.quarantine /usr/local/bin/godot 2>/dev/null || true
|
||||
rm -rf "$TMPD"
|
||||
log "Godot 已装:$(/usr/local/bin/godot --version 2>/dev/null | head -1)"
|
||||
fi
|
||||
|
||||
# ── 3. gdstyle v0.4.0 darwin → /usr/local/bin/gdstyle(S13 静态扫描闸件,版本钉死)──
|
||||
if /usr/local/bin/gdstyle --version >/dev/null 2>&1; then
|
||||
log "gdstyle 已在位,跳过"
|
||||
else
|
||||
TGZ="$(mktemp "${TMPDIR:-/tmp}/gdstyle_XXXXXX.tar.gz")"
|
||||
_ok=0
|
||||
for m in "${MIRRORS[@]}"; do
|
||||
log "下载 gdstyle v${GDSTYLE_VER} darwin(${m:+经 $m})"
|
||||
if curl -fL --retry 2 -m 600 -o "$TGZ" "${m}${GDSTYLE_URL}"; then _ok=1; break; fi
|
||||
done
|
||||
[ "$_ok" = 1 ] || die "gdstyle 下载失败——手动下载 ${GDSTYLE_URL} 解压后放 /usr/local/bin/gdstyle 再重跑"
|
||||
TMPD="$(mktemp -d)"
|
||||
tar -xzf "$TGZ" -C "$TMPD"
|
||||
BIN="$(find "$TMPD" -type f -name 'gdstyle*' | head -1)"
|
||||
[ -n "$BIN" ] || die "gdstyle 压缩包内未找到二进制"
|
||||
install -m 0755 "$BIN" /usr/local/bin/gdstyle
|
||||
xattr -dr com.apple.quarantine /usr/local/bin/gdstyle 2>/dev/null || true
|
||||
rm -rf "$TMPD"
|
||||
log "gdstyle 已装:/usr/local/bin/gdstyle"
|
||||
fi
|
||||
|
||||
# ── 4. Pillow(渲染闸 shot_checker/golden_compare 依赖,装给 ci-runner 用户)──
|
||||
if sudo -u "$CI_USER" python3 -c "import PIL" >/dev/null 2>&1; then
|
||||
log "Pillow 已在位(ci-runner 用户),跳过"
|
||||
else
|
||||
log "为 ci-runner 安装 Pillow(阿里源)"
|
||||
sudo -u "$CI_USER" python3 -m pip install --user pillow \
|
||||
-i https://mirrors.aliyun.com/pypi/simple/ --quiet \
|
||||
|| die "Pillow 安装失败"
|
||||
fi
|
||||
|
||||
# ── 5. runner 程序与注册文件就位(复制既有注册身份,免重新注册)──
|
||||
mkdir -p "${CI_HOME}/gitea-runner"
|
||||
cp -f "${RUNNER_SRC}/act_runner" "${CI_HOME}/gitea-runner/act_runner"
|
||||
cp -f "${RUNNER_SRC}/.runner" "${CI_HOME}/gitea-runner/.runner"
|
||||
cp -f "${RUNNER_SRC}/config.yaml" "${CI_HOME}/gitea-runner/config.yaml" 2>/dev/null || true
|
||||
# 标签切 host 模式:作业直接跑在本机(.runner 的 labels 是声明面,daemon 启动时重新 declare)
|
||||
python3 - "${CI_HOME}/gitea-runner/.runner" <<'PYEOF'
|
||||
import json, sys
|
||||
p = sys.argv[1]
|
||||
d = json.load(open(p))
|
||||
d["labels"] = ["ci-mac:host"]
|
||||
json.dump(d, open(p, "w"))
|
||||
print("labels ->", d["labels"])
|
||||
PYEOF
|
||||
sed -i '' 's/^ level: debug/ level: info/' "${CI_HOME}/gitea-runner/config.yaml" 2>/dev/null || true
|
||||
chown -R "${CI_USER}:staff" "${CI_HOME}/gitea-runner"
|
||||
|
||||
# ── 6. LaunchDaemon 常驻(系统级:开机自启、不依赖登录、崩溃自动拉起)──
|
||||
PLIST="/Library/LaunchDaemons/com.gitea.act-runner.plist"
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>com.gitea.act-runner</string>
|
||||
<key>UserName</key><string>${CI_USER}</string>
|
||||
<key>GroupName</key><string>staff</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${CI_HOME}/gitea-runner/act_runner</string>
|
||||
<string>daemon</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key><string>${CI_HOME}/gitea-runner</string>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardOutPath</key><string>${CI_HOME}/gitea-runner/daemon.log</string>
|
||||
<key>StandardErrorPath</key><string>${CI_HOME}/gitea-runner/daemon.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl bootout system/com.gitea.act-runner 2>/dev/null || true
|
||||
launchctl bootstrap system "$PLIST" || launchctl load -w "$PLIST"
|
||||
log "LaunchDaemon 已加载(com.gitea.act-runner,用户 ${CI_USER})"
|
||||
|
||||
# ── 7. 退役 Docker 版 runner(如存在)──
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
docker rm -f gitea-runner >/dev/null 2>&1 && log "Docker 版 runner 容器已退役" || log "无 Docker 版 runner 容器(或 docker 未运行)"
|
||||
fi
|
||||
|
||||
# ── 8. 验证 ──
|
||||
sleep 5
|
||||
if launchctl print "system/com.gitea.act-runner" 2>/dev/null | grep -q "state"; then
|
||||
log "runner 服务在跑"
|
||||
else
|
||||
die "runner 服务未起来——查 ${CI_HOME}/gitea-runner/daemon.log"
|
||||
fi
|
||||
log "全部完成。验证清单:"
|
||||
log " ① Gitea 网页 → 仓库设置 → Actions → Runners 应显示 mac-ci 在线(Idle)"
|
||||
log " ② sudo -u ${CI_USER} /usr/local/bin/godot --headless --version"
|
||||
log " ③ sudo -u ${CI_USER} python3 -c 'import PIL'"
|
||||
log " ④ 任意 push 触发一次跑批端到端验证"
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/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)
|
||||
# 回执只有确属本跑(commit 对齐)才采信——闸在起手自检即中止时不写新回执,
|
||||
# 盘上旧回执是上一跑的读数,当作本次红项会撒谎(2026-10-31 实跑缺陷修正)
|
||||
if r.get("commit") == tip:
|
||||
entry["red_gates"] = [g["name"] for g in r.get("gates", []) if str(g.get("exit")) != "0"]
|
||||
else:
|
||||
entry["note"] = "闸未跑完(起手自检或早期中止,回执为上一跑旧件)——红项读数不可用,看 key_lines 与 ci.log"
|
||||
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
|
||||
+118
-49
@@ -1,57 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# 回执等待件——合回后等 CI 回执的后台轮询器(2026-11-02 用户裁;规则面=process-conventions §1 第 3 条「回执等待纪律」)。
|
||||
# 用法: tools/ci_watch.sh <合并提交hash> [超时秒,默认 900]
|
||||
# 调用面: 合回落地先向用户呈报本批总结,随即以 run_in_background 后台任务运行本件;命令结束时
|
||||
# harness 唤醒会话——绿→删区收口;红→呈报红闸名单走修复批;超时→呈报「回执未落」(CI 可能崩溃)。
|
||||
# 「本次回执已落」认领判据(三条全满足,防认错轮):
|
||||
# ① 回执可整读——CI 写回执非原子,半截/读空静默重试;
|
||||
# ② 回执 commit 为传入提交或其后代——另一会话合回推进尖端触发补跑覆盖时按后代认领(coalesce 语义见 ci_runner.sh);
|
||||
# ③ 回执 timestamp 晚于合并提交时刻——排除合并前盘上的旧回执(旧尖端是合并提交的祖先,只查②会误领旧回执)。
|
||||
# 回执等待件——合回后等远端 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
|
||||
# 主工作区定位:CI 只在主工作区跑(post-merge 钩子限主区),从任务 worktree 内调用也要读主区回执——
|
||||
# 经 git 公共目录(.git)的上级定位主仓根,主区调用与 worktree 调用同一读数
|
||||
COMMON="$(git -C "$(dirname "$0")" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" \
|
||||
|| { echo "不在 git 仓库内"; exit 3; }
|
||||
REPO="$(dirname "$COMMON")"
|
||||
STATUS="$REPO/game/tests/.gates/ci-status.json"
|
||||
BASE="${1:-}"
|
||||
TIMEOUT="${2:-900}"
|
||||
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
|
||||
[ -n "$BASE" ] || { echo "用法: ci_watch.sh <合并提交hash> [超时秒]"; exit 3; }
|
||||
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; }
|
||||
BASE_CT="$(git -C "$REPO" log -1 --format=%ct "$BASE")"
|
||||
_deadline=$(( $(date +%s) + TIMEOUT ))
|
||||
while :; do
|
||||
_read="$(python -I -c "
|
||||
import json, sys, datetime
|
||||
try:
|
||||
d = json.load(open(sys.argv[1], encoding='utf-8'))
|
||||
ts = int(datetime.datetime.fromisoformat(d['timestamp']).timestamp())
|
||||
print(d.get('commit', ''), d.get('verdict', ''), ts)
|
||||
except Exception:
|
||||
pass
|
||||
" "$STATUS")"
|
||||
_com=""; _verdict=""; _ts=""
|
||||
if [ -n "$_read" ]; then
|
||||
set -- $_read
|
||||
[ $# -eq 3 ] && { _com="$1"; _verdict="$2"; _ts="$3"; }
|
||||
fi
|
||||
if [ -n "$_com" ] && [ -n "$_verdict" ] && [ -n "$_ts" ] \
|
||||
&& [ "$_ts" -gt "$BASE_CT" ] \
|
||||
&& git -C "$REPO" merge-base --is-ancestor "$BASE" "$_com" 2>/dev/null; then
|
||||
python -I -c "
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1], encoding='utf-8'))
|
||||
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)
|
||||
" "$STATUS"
|
||||
[ "$_verdict" = "green" ] && exit 0
|
||||
exit 1
|
||||
fi
|
||||
_now=$(date +%s); _left=$((_deadline - _now))
|
||||
[ "$_left" -le 0 ] && { echo "超时(${TIMEOUT}s)回执未落——CI 可能崩溃或未触发,查 game/tests/.gates/ 的 ci.lock 与 ci.log"; exit 2; }
|
||||
sleep $(( _left < INTERVAL ? _left : INTERVAL ))
|
||||
done
|
||||
|
||||
# 仓库全名(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
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 红态追绿钩子(本地 CI 触发钩子,2026-10-30 用户裁)——仅当最近一次 CI 判红时,本提交触发
|
||||
# 后台复验(tools/ci_runner.sh commit,判定在跑批件内);绿态与无状态零触发——
|
||||
# 小批直提不重复跑全量,同一份字节禁止第二次人手跑。主工作区限定。
|
||||
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)"
|
||||
( nohup bash "$REPO/tools/ci_runner.sh" commit >/dev/null 2>&1 & )
|
||||
exit 0
|
||||
+20
-5
@@ -1,12 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# 合回验证点(本地 CI 触发钩子,2026-10-30 用户裁)——主工作区 git merge 落地后无条件触发
|
||||
# 全量域闸后台跑批(tools/ci_runner.sh merge,约 4 分钟,不阻塞合并动作本身)。
|
||||
# 任务 worktree 内的合并不触发(CI 只认 master;worktree 批要提前验证就手动跑 ci_runner.sh)。
|
||||
# CI 红了不回滚合并——红项记入 game/tests/.gates/ci-status.json,修复提交经 post-commit 追绿。
|
||||
# 合回验证点(远端 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(防静默,下游开工读态即见)。
|
||||
# 任务 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)"
|
||||
( nohup bash "$REPO/tools/ci_runner.sh" merge >/dev/null 2>&1 & )
|
||||
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
|
||||
exit 0
|
||||
fi
|
||||
( nohup bash "$REPO/tools/ci_watch.sh" remote "$TIP" >/dev/null 2>&1 & )
|
||||
exit 0
|
||||
Reference in new issue
Block a user