refactor(auto-deploy): YAML config + post_deploy list + deploy failure mail
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 1s

- New config/deploy-targets.yaml: centralized deploy target config
- Rewrite auto-deploy in _handle_pr_closed to use YAML config
- Add _send_deploy_failure_mail helper (reuses deploy_failure template)
- Support post_deploy command list (not just pm2 restart)
- Docs-only changes skip post_deploy
- Add pyyaml to pyproject.toml dependencies
- Update design doc §23 with new architecture
This commit is contained in:
cfdaily
2026-06-12 13:45:31 +08:00
parent b80290fe78
commit 73454c0787
4 changed files with 104 additions and 38 deletions
+58 -25
View File
@@ -450,6 +450,18 @@ async def _handle_pr_synchronize(payload: Dict[str, Any]) -> None:
_send_mail(reviewer, title, text)
def _send_deploy_failure_mail(repo: str, pr_number: int, pr_title: str, reason: str) -> None:
"""CD 部署失败通知,复用 deploy_failure 模板"""
text = render_template("deploy_failure", {
"repo": repo,
"commit_sha": f"PR #{pr_number}",
})
title = f"部署失败: {repo} (auto-deploy, PR #{pr_number})"
full_text = f"{text}\n\n失败原因: {reason}"
for agent_id in ("jiangwei-infra", "pangtong-fujunshi"):
_send_mail(agent_id, title, full_text)
async def _handle_pr_closed(payload: Dict[str, Any]) -> None:
"""PR closed → 如果 merged,通知 PR 作者。"""
pr = payload.get("pull_request")
@@ -481,14 +493,26 @@ 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 + rsync + 按需 pm2 restart(仅 sanguo/sanguo_moziplus_v2
# 自动部署:git pull + rsync + 按需 post_deploy
try:
if repo != "sanguo/sanguo_moziplus_v2":
import yaml
# 加载部署配置
config_path = Path(__file__).parent.parent.parent / "config" / "deploy-targets.yaml"
if not config_path.exists():
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"))
install_repo_dir = os.path.join(install_dir, "sanguo_moziplus_v2")
with open(config_path, "r", encoding="utf-8") as f:
deploy_config = yaml.safe_load(f) or {}
targets = deploy_config.get("targets", {})
target = targets.get(repo)
if not target:
return # 该仓库不在部署配置中,跳过
dev_dir = os.path.expanduser(target["dev_dir"])
install_dir = os.path.expanduser(target.get("install_dir", target["dev_dir"]))
rsync_excludes = target.get("rsync_exclude", [])
# Step 1: git pull in dev dir
proc = await asyncio.create_subprocess_exec(
@@ -500,15 +524,19 @@ async def _handle_pr_closed(payload: Dict[str, Any]) -> None:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
if proc.returncode != 0:
logger.warning("Auto-deploy: git pull failed: %s", stderr.decode())
logger.warning("Auto-deploy: git pull failed for %s: %s", repo, stderr.decode())
return
logger.info("Auto-deploy: git pull success for %s", repo)
# Step 2: rsync to install dir
rsync_args = ["rsync", "-a"]
for exc in rsync_excludes:
rsync_args.extend(["--exclude", exc])
rsync_args.extend([f"{dev_dir}/", f"{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}/",
*rsync_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -516,35 +544,40 @@ async def _handle_pr_closed(payload: Dict[str, Any]) -> None:
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()}")
_send_deploy_failure_mail(repo, pr_number, pr_title, f"rsync 失败: {rsync_err.decode()}")
return
# Step 3: 判断是否需要重启
# Step 3: 判断是否需要执行 post_deploy
files = await _fetch_pr_files(repo, pr_number)
file_list = files[0]
needs_restart = any(
f.startswith("src/") or f.startswith("templates/") or f.startswith("frontend/") or f.endswith(".py")
for f in files[0]
for f in file_list
)
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)
post_deploy_cmds = target.get("post_deploy", [])
for cmd in post_deploy_cmds:
logger.info("Auto-deploy: executing post_deploy: %s", cmd)
deploy_proc = await asyncio.create_subprocess_exec(
"sh", "-c", cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, deploy_err = await asyncio.wait_for(deploy_proc.communicate(), timeout=30)
if restart_proc.returncode == 0:
logger.info("Auto-deploy: pm2 restart triggered (files: %s)", ", ".join(files[0][:5]))
if deploy_proc.returncode != 0:
logger.error("Auto-deploy: post_deploy failed: %s", deploy_err.decode())
_send_deploy_failure_mail(repo, pr_number, pr_title, f"post_deploy 失败 ({cmd}): {deploy_err.decode()}")
break
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()}")
logger.info("Auto-deploy: all post_deploy commands succeeded (files: %s)", ", ".join(file_list[:5]))
else:
logger.info("Auto-deploy: docs-only change, skip restart")
logger.info("Auto-deploy: docs-only change for %s, skip post_deploy", repo)
except asyncio.TimeoutError:
logger.error("Auto-deploy: timeout")
logger.error("Auto-deploy: timeout for %s", repo)
_send_deploy_failure_mail(repo, pr_number, pr_title, "部署超时")
except Exception as e:
logger.error("Auto-deploy: unexpected error: %s", e)