diff --git a/docs/design/23-toolchain-pr-lifecycle.md b/docs/design/23-toolchain-pr-lifecycle.md index 336f3b2..bcae021 100644 --- a/docs/design/23-toolchain-pr-lifecycle.md +++ b/docs/design/23-toolchain-pr-lifecycle.md @@ -141,15 +141,21 @@ async def _handle_pull_request(payload: Dict[str, Any]) -> None: **触发**:`_handle_pr_closed` 合并事件处理完成后 **逻辑**: -1. `git pull origin main`(安装目录) -2. 获取 PR 变更文件列表(复用 `_fetch_pr_files`) -3. 判断是否需要重启:文件路径包含 `src/`、`templates/`、`frontend/` 或 `*.py` 后缀 → 重启 -4. 纯 `docs/` 变更 → 只 pull 不重启 -5. 部署失败仅 log,不影响 Mail 通知 +1. 仓库白名单检查(仅 `sanguo/sanguo_moziplus_v2`) +2. `git pull origin main`(开发目录 `~/.openclaw/sanguo_projects/sanguo_moziplus_v2/`) +3. `rsync` 同步到安装目录(排除 `.git`/`node_modules`/`__pycache__`) +4. 获取 PR 变更文件列表(复用 `_fetch_pr_files`) +5. 判断是否需要重启:文件路径包含 `src/`、`templates/`、`frontend/` 或 `*.py` 后缀 → 重启 +6. 纯 `docs/` 变更 → 只 pull + rsync 不重启 +7. rsync 或 pm2 restart 失败 → 通知 `jiangwei-infra` +8. 部署失败仅 log + Mail 通知,不影响合并通知 **设计决策**: +- **git pull 在开发目录**(有 `.git`),rsync 到安装目录:安装目录无 `.git`,直接 git pull 必然失败 +- **全异步**:所有子进程调用使用 `asyncio.create_subprocess_exec`,不阻塞 event loop +- **仓库白名单**:只对 `sanguo/sanguo_moziplus_v2` 触发自动部署,其他仓库忽略 +- **部署失败通知**:rsync 或 pm2 restart 失败时发 Mail 给 `jiangwei-infra`(S1) - 不做优雅等待(sentinel file 方案):daemon 正在执行任务时重启,已 spawn 的子进程独立运行不受影响,最坏情况是当前 tick 中断、下一轮 PM2 拉起后继续 -- 部署失败不阻塞通知:Mail 通知和部署解耦,保证信息触达 ### 不做的事 diff --git a/src/api/toolchain_routes.py b/src/api/toolchain_routes.py index 27bcb1d..dc2bc1b 100644 --- a/src/api/toolchain_routes.py +++ b/src/api/toolchain_routes.py @@ -481,40 +481,70 @@ async def _handle_pr_closed(payload: Dict[str, Any]) -> None: title = f"PR 已合并: {pr_title} ({repo}#{pr_number})" _send_mail(pr_author, title, text) - # 自动部署:git pull + 按需 pm2 restart + # 自动部署:git pull + rsync + 按需 pm2 restart(仅 sanguo/sanguo_moziplus_v2) try: - import subprocess - import os + if repo != "sanguo/sanguo_moziplus_v2": + return + dev_dir = os.path.expanduser("~/.openclaw/sanguo_projects/sanguo_moziplus_v2") install_dir = os.environ.get("SANGUO_PROJECTS_DIR", os.path.expanduser("~/.sanguo_projects")) - repo_dir = os.path.join(install_dir, "sanguo_moziplus_v2") + install_repo_dir = os.path.join(install_dir, "sanguo_moziplus_v2") - # git pull - pull_result = subprocess.run( - ["git", "pull", "origin", "main"], - cwd=repo_dir, capture_output=True, text=True, timeout=30 + # Step 1: git pull in dev dir + proc = await asyncio.create_subprocess_exec( + "git", "pull", "origin", "main", + cwd=dev_dir, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - if pull_result.returncode == 0: - logger.info("Auto-deploy: git pull success for %s", repo) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) - # 判断是否需要重启:获取 PR 变更文件列表 - files = await _fetch_pr_files(repo, pr_number) - needs_restart = any( - f.startswith("src/") or f.startswith("templates/") or f.startswith("frontend/") or f.endswith(".py") - for f in files[0] + if proc.returncode != 0: + logger.warning("Auto-deploy: git pull failed: %s", stderr.decode()) + return + + logger.info("Auto-deploy: git pull success for %s", repo) + + # Step 2: rsync to install dir + rsync_proc = await asyncio.create_subprocess_exec( + "rsync", "-a", "--exclude=.git", "--exclude=node_modules", "--exclude=__pycache__", + f"{dev_dir}/", f"{install_repo_dir}/", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, rsync_err = await asyncio.wait_for(rsync_proc.communicate(), timeout=60) + + if rsync_proc.returncode != 0: + logger.error("Auto-deploy: rsync failed: %s", rsync_err.decode()) + _send_mail("jiangwei-infra", f"[Auto-Deploy] rsync 失败 ({repo}#{pr_number})", + f"PR {pr_title} 合并后自动部署 rsync 失败。\n\nstderr: {rsync_err.decode()}") + return + + # Step 3: 判断是否需要重启 + files = await _fetch_pr_files(repo, pr_number) + needs_restart = any( + f.startswith("src/") or f.startswith("templates/") or f.startswith("frontend/") or f.endswith(".py") + for f in files[0] + ) + + if needs_restart: + restart_proc = await asyncio.create_subprocess_exec( + "pm2", "restart", "sanguo-moziplus-v2", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) + _, restart_err = await asyncio.wait_for(restart_proc.communicate(), timeout=15) - if needs_restart: - restart_result = subprocess.run( - ["pm2", "restart", "sanguo-moziplus-v2"], - capture_output=True, text=True, timeout=15 - ) - if restart_result.returncode == 0: - logger.info("Auto-deploy: pm2 restart triggered (files: %s)", ", ".join(files[0][:5])) - else: - logger.error("Auto-deploy: pm2 restart failed: %s", restart_result.stderr) + if restart_proc.returncode == 0: + logger.info("Auto-deploy: pm2 restart triggered (files: %s)", ", ".join(files[0][:5])) + else: + logger.error("Auto-deploy: pm2 restart failed: %s", restart_err.decode()) + _send_mail("jiangwei-infra", f"[Auto-Deploy] pm2 restart 失败 ({repo}#{pr_number})", + f"PR {pr_title} 合并后 pm2 restart 失败。\n\nstderr: {restart_err.decode()}") else: - logger.warning("Auto-deploy: git pull failed: %s", pull_result.stderr) + logger.info("Auto-deploy: docs-only change, skip restart") + except asyncio.TimeoutError: + logger.error("Auto-deploy: timeout") except Exception as e: logger.error("Auto-deploy: unexpected error: %s", e)