From df016b18fb9ecab3e09a155355ffca274589f7d5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 14 Jul 2026 21:34:13 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ci):=20=E6=96=B0=E5=A2=9EProduction?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5+=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_production_healthcheck.sh | 483 +++++++++++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 scripts/ci_production_healthcheck.sh diff --git a/scripts/ci_production_healthcheck.sh b/scripts/ci_production_healthcheck.sh new file mode 100644 index 000000000..7d76c7651 --- /dev/null +++ b/scripts/ci_production_healthcheck.sh @@ -0,0 +1,483 @@ +#!/bin/bash +# =========================================== +# CI Production 健康检查 + 自动回滚脚本(SSH 部署模式) +# =========================================== +# +# 在 CI Runner 上执行,通过公网 URL 检查 Production 部署健康状态。 +# 不健康则通过 SSH 自动回滚到上一个版本的镜像。 +# +# 用法: +# ./ci_production_healthcheck.sh +# +# 环境变量: +# PROD_API_URL - Production API 公网地址 (默认 https://api.xiaoxiajianji.com) +# PROD_WEB_URL - Production Web 公网地址 (默认 https://saas.xiaoxiajianji.com) +# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 180) +# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false) +# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false) +# CI_NOTIFY_WEBHOOK - 通知 Webhook URL +# +# PRODUCTION_SSH_HOST - 生产服务器 SSH 地址 +# PRODUCTION_SSH_USER - SSH 用户名 (默认 root) +# PRODUCTION_SSH_PORT - SSH 端口 (默认 22222) +# PRODUCTION_SSH_KEY - SSH 私钥内容 +# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要) +# +# GITHUB_SHA - 当前 commit SHA +# GITHUB_REF_NAME - tag 名 (如 v0.1.100) +# GITHUB_RUN_ID - CI Run ID +# GITHUB_REPOSITORY - 仓库名 +# GITHUB_ACTOR - 提交者 + +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +# 配置 +PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}" +PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}" +HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-180}" +SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}" +SKIP_NOTIFY="${SKIP_NOTIFY:-false}" + +PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}" +PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}" +PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}" + +REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}" +REGISTRY_USER="${REGISTRY_USER:-xiaoxia}" + +# 颜色 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_step() { echo -e "${BLUE}[STEP]${NC} $1"; } + +# =========================================== +# SSH 工具函数 +# =========================================== +SSH_KEY_PATH="" + +setup_ssh() { + # 查找或创建 SSH 密钥 + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder" + elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then + SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder" + elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then + SSH_KEY_PATH="$HOME/.ssh/prod_deploy_key" + mkdir -p "$HOME/.ssh" + printf '%s\n' "$PRODUCTION_SSH_KEY" > "$SSH_KEY_PATH" + chmod 600 "$SSH_KEY_PATH" + else + log_error "没有可用的 SSH 密钥" + return 1 + fi + + ssh-keyscan -p "$PRODUCTION_SSH_PORT" -H "$PRODUCTION_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true + log_info "SSH 已配置: ${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}:${PRODUCTION_SSH_PORT}" +} + +run_ssh() { + local cmd="$1" + ssh -p "$PRODUCTION_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \ + "${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}" "$cmd" +} + +# =========================================== +# 1. 记录部署前各服务的镜像版本(用于回滚) +# =========================================== +ROLLBACK_API_TAG="" +ROLLBACK_WORKER_TAG="" +ROLLBACK_WEB_TAG="" + +save_rollback_target() { + log_step "记录当前生产环境各服务镜像版本(回滚目标)..." + + # 通过 SSH 获取当前运行的容器镜像 + local api_image worker_image web_image + api_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-api-production 2>/dev/null || echo ''") + worker_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-worker-production 2>/dev/null || echo ''") + web_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-web-production 2>/dev/null || echo ''") + + # 提取 tag(镜像名是 xiaoxia-saas-api:v0.1.100 格式) + ROLLBACK_API_TAG=$(echo "$api_image" | sed 's/.*://' || echo "") + ROLLBACK_WORKER_TAG=$(echo "$worker_image" | sed 's/.*://' || echo "") + ROLLBACK_WEB_TAG=$(echo "$web_image" | sed 's/.*://' || echo "") + + log_info " API: ${ROLLBACK_API_TAG:-未知}" + log_info " Worker: ${ROLLBACK_WORKER_TAG:-未知}" + log_info " Web: ${ROLLBACK_WEB_TAG:-未知}" + + # 验证三个服务版本是否一致 + if [ -n "$ROLLBACK_API_TAG" ] && [ -n "$ROLLBACK_WORKER_TAG" ] && [ -n "$ROLLBACK_WEB_TAG" ]; then + if [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WORKER_TAG" ] && [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WEB_TAG" ]; then + log_info " ✅ 三个服务版本一致: $ROLLBACK_API_TAG" + export ROLLBACK_TAG="$ROLLBACK_API_TAG" + else + log_warn " ⚠️ 三个服务版本不一致,回滚时将分别使用各自版本" + export ROLLBACK_API_TAG ROLLBACK_WORKER_TAG ROLLBACK_WEB_TAG + export ROLLBACK_TAG_MIXED="true" + fi + else + log_warn " ⚠️ 未能获取全部服务版本,回滚功能可能受限" + fi +} + +# =========================================== +# 2. 健康检查(公网视角) +# =========================================== +health_check() { + local timeout="$HEALTH_CHECK_TIMEOUT" + local start_time + start_time=$(date +%s) + + log_step "公网健康检查(超时 ${timeout}s)..." + log_info " API: ${PROD_API_URL}/health" + log_info " Web: ${PROD_WEB_URL}/" + + local api_ok=false + local web_ok=false + local api_docs_ok=false + local login_api_ok=false + + while [ $(( $(date +%s) - start_time )) -lt "$timeout" ]; do + # 检查 API health + if [ "$api_ok" = false ] && curl -sf --max-time 10 "${PROD_API_URL}/health" >/dev/null 2>&1; then + log_info "✅ API 健康检查通过" + api_ok=true + fi + + # 检查 Web 首页 + if [ "$web_ok" = false ] && curl -sf --max-time 10 "$PROD_WEB_URL/" >/dev/null 2>&1; then + log_info "✅ Web 前端检查通过" + web_ok=true + fi + + # 检查 API docs + if [ "$api_docs_ok" = false ]; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${PROD_API_URL}/docs" 2>/dev/null || echo "000") + if [ "$HTTP_CODE" = "200" ]; then + log_info "✅ API Docs 检查通过" + api_docs_ok=true + fi + fi + + # 检查登录 API + if [ "$login_api_ok" = false ]; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \ + "${PROD_API_URL}/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"smoke@test.com","password":"wrong"}' 2>/dev/null || echo "000") + if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then + log_info "✅ 登录 API 检查通过(HTTP $HTTP_CODE,符合预期)" + login_api_ok=true + fi + fi + + # 都通过了就退出 + if [ "$api_ok" = true ] && [ "$web_ok" = true ] && [ "$api_docs_ok" = true ] && [ "$login_api_ok" = true ]; then + log_info "🎉 所有健康检查通过!" + return 0 + fi + + sleep 5 + done + + # 超时了 + log_error "❌ 健康检查超时 (${timeout}s)" + [ "$api_ok" = false ] && log_error " - API health 未通过" + [ "$web_ok" = false ] && log_error " - Web 前端未通过" + [ "$api_docs_ok" = false ] && log_error " - API Docs 未通过" + [ "$login_api_ok" = false ] && log_error " - 登录 API 未通过" + + return 1 +} + +# =========================================== +# 3. 执行回滚(SSH 重新部署旧版本) +# =========================================== +do_rollback() { + log_step "执行回滚:通过 SSH 重新部署旧版本镜像..." + + local rollback_tag="${ROLLBACK_TAG:-}" + if [ -z "$rollback_tag" ] && [ "${ROLLBACK_TAG_MIXED:-}" != "true" ]; then + log_error "没有可回滚的版本记录,无法自动回滚" + return 1 + fi + + # 如果版本不一致,用 API 的版本作为回滚目标 + if [ -z "$rollback_tag" ]; then + rollback_tag="$ROLLBACK_API_TAG" + fi + + if [ -z "$rollback_tag" ]; then + log_error "无法确定回滚版本" + return 1 + fi + + log_info "回滚目标版本: $rollback_tag" + + # 通过 SSH 在生产服务器上执行回滚部署 + # 复用 Registry 方式部署脚本的逻辑,用旧版本 tag 重新部署 + local rollback_script=$(cat << 'ROLLBACK_EOF' +#!/bin/sh +set -eu + +IMAGE_TAG="$1" +REGISTRY_TOKEN="$2" +REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}" +REGISTRY_USER="${REGISTRY_USER:-xiaoxia}" + +ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}" +GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}" +LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}" + +echo "=== Rollback to $IMAGE_TAG ===" + +# 登录 Registry +if [ -n "$REGISTRY_TOKEN" ]; then + REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1) + printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true +fi + +# Pull 旧版本镜像 +LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}" +LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}" +LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}" + +docker pull "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" +docker pull "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" +docker pull "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" + +docker tag "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" "$LOCAL_API" +docker tag "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" "$LOCAL_WORKER" +docker tag "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" "$LOCAL_WEB" + +echo "Rollback images pulled." + +# 停止当前容器 +docker rm -f xiaoxia-api-production 2>/dev/null || true +docker rm -f xiaoxia-worker-production 2>/dev/null || true +docker rm -f xiaoxia-web-production 2>/dev/null || true + +LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3" + +# 启动 API(回滚不跑 migration,因为新版本可能加了字段,回滚后代码是旧的但数据还在) +echo "Starting API (rollback)..." +docker run -d \ + --name xiaoxia-api-production \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-production \ + -p 127.0.0.1:8001:8000 \ + -e APP_ENV=production \ + -e APP_VERSION="$IMAGE_TAG" \ + -e GENERATED_FILES_DIR=/app/generated \ + -e GENERATED_FILES_URL_PREFIX=/generated-files \ + -e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \ + -v "$GENERATED_DIR:/app/generated" \ + --restart unless-stopped \ + --cpus 2 \ + --memory 2g \ + --health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \ + --health-interval 30s \ + --health-timeout 10s \ + --health-retries 3 \ + --health-start-period 40s \ + $LOG_OPTS \ + "$LOCAL_API" + +# 启动 Worker +echo "Starting Worker (rollback)..." +docker run -d \ + --name xiaoxia-worker-production \ + --env-file "$ENV_FILE" \ + --network xiaoxia-net-production \ + -e APP_ENV=production \ + -e APP_VERSION="$IMAGE_TAG" \ + -e WORKER_CONCURRENCY=1 \ + -e WORKER_MAX_TASKS_PER_CHILD=100 \ + -e GENERATED_FILES_DIR=/app/generated \ + -e GENERATED_FILES_URL_PREFIX=/generated-files \ + -e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \ + -v "$GENERATED_DIR:/app/generated" \ + --restart unless-stopped \ + --cpus 2 \ + --memory 2g \ + --health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \ + --health-interval 30s \ + --health-timeout 10s \ + --health-retries 3 \ + --health-start-period 30s \ + $LOG_OPTS \ + "$LOCAL_WORKER" + +# 启动 Web +LEGACY_VOLUME="" +if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then + LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro" +fi + +echo "Starting Web (rollback)..." +docker run -d \ + --name xiaoxia-web-production \ + --network xiaoxia-net-production \ + -p 127.0.0.1:3002:80 \ + --restart unless-stopped \ + --cpus 0.5 \ + --memory 512m \ + $LEGACY_VOLUME \ + --health-cmd "wget --spider -q http://127.0.0.1:80" \ + --health-interval 30s \ + --health-timeout 5s \ + --health-retries 3 \ + $LOG_OPTS \ + "$LOCAL_WEB" + +# 等待 API 健康 +echo "Waiting for API (rollback)..." +i=0 +while [ "$i" -lt 40 ]; do + if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then + echo "API healthy (rollback)." + break + fi + i=$((i + 1)) + sleep 3 +done + +# 等待 Web 健康 +echo "Waiting for Web (rollback)..." +i=0 +while [ "$i" -lt 15 ]; do + if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then + echo "Web healthy (rollback)." + break + fi + i=$((i + 1)) + sleep 2 +done + +echo "=== Rollback complete: $IMAGE_TAG ===" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production +ROLLBACK_EOF +) + + # 将脚本 base64 编码后通过 SSH 执行 + local script_b64 + script_b64=$(echo "$rollback_script" | base64 -w 0) + + log_info "在生产服务器上执行回滚脚本..." + if run_ssh "echo '$script_b64' | base64 -d | sh -s -- '$rollback_tag' '${REGISTRY_TOKEN:-}'" 2>&1; then + log_info "✅ 回滚命令执行完成" + return 0 + else + log_error "❌ 回滚命令执行失败" + return 1 + fi +} + +# =========================================== +# 4. 发送通知 +# =========================================== +send_notification() { + local status="$1" # success / failure / rollback + local detail="$2" + + if [ "${SKIP_NOTIFY:-false}" = "true" ]; then + log_info "跳过通知(SKIP_NOTIFY=true)" + return 0 + fi + + local webhook="${CI_NOTIFY_WEBHOOK:-}" + if [ -z "$webhook" ]; then + log_warn "未配置 CI_NOTIFY_WEBHOOK,跳过通知" + return 0 + fi + + if [ -f "$SCRIPT_DIR/deploy_notify.py" ]; then + python3 "$SCRIPT_DIR/deploy_notify.py" \ + --status "$status" \ + --detail "$detail" \ + --webhook "$webhook" \ + --env production \ + 2>/dev/null || log_warn "通知发送失败(非致命)" + else + log_warn "找不到 deploy_notify.py,跳过通知" + fi + + # 标记:通知已由健康检查脚本发出,避免 CI 兜底通知重复发送 + echo "$status" > /tmp/prod_deploy_notification_sent +} + +# =========================================== +# 主流程 +# =========================================== +main() { + echo "" + echo "==========================================" + echo " CI Production 健康检查 + 自动回滚" + echo "==========================================" + echo "" + + local deploy_status="success" + local deploy_detail="" + + # 1. 设置 SSH + if ! setup_ssh; then + log_error "SSH 配置失败,无法执行回滚" + fi + + # 2. 记录部署前状态(回滚目标) + save_rollback_target || true + + # 3. 健康检查(公网视角) + if ! health_check; then + log_error "健康检查失败" + deploy_status="failure" + deploy_detail="公网健康检查超时,部署后服务未正常响应" + + # 自动回滚 + if [ "${SKIP_ROLLBACK:-false}" != "true" ]; then + log_warn "开始自动回滚..." + if do_rollback; then + deploy_status="rollback" + deploy_detail="健康检查失败,已自动回滚到上一版本 (${ROLLBACK_TAG:-未知})" + + # 回滚后再检查一下公网状态 + log_info "回滚完成,重新检查公网健康状态..." + if health_check; then + log_info "✅ 回滚后服务已恢复" + deploy_detail="${deploy_detail},回滚后服务已恢复" + else + log_error "⚠️ 回滚后健康检查仍未通过,请手动排查" + deploy_detail="${deploy_detail},但回滚后仍未恢复,请紧急排查" + fi + else + deploy_detail="健康检查失败且回滚失败,请手动排查" + fi + fi + + send_notification "$deploy_status" "$deploy_detail" + + # 失败时退出非零,让 CI Job 标记为失败 + exit 1 + fi + + # 4. 成功 + log_info "" + log_info "==================================" + log_info " ✅ Production 部署成功!" + log_info "==================================" + + deploy_detail="部署成功,所有健康检查通过 (${GITHUB_REF_NAME:-未知版本})" + send_notification "success" "$deploy_detail" +} + +main "$@" -- 2.54.0 From ed3ab3402811a42a887cfcc3e58445acbf61d667 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 14 Jul 2026 21:34:15 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(ci):=20=E5=8D=87=E7=BA=A7=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E9=80=9A=E7=9F=A5=E8=84=9A=E6=9C=AC=EF=BC=8C=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=A0=BC=E5=BC=8F=E4=B8=8ECI=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy_notify.py | 154 +++++++++++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 29 deletions(-) diff --git a/scripts/deploy_notify.py b/scripts/deploy_notify.py index 6e8139c89..03a058278 100644 --- a/scripts/deploy_notify.py +++ b/scripts/deploy_notify.py @@ -1,13 +1,17 @@ #!/usr/bin/env python3 """ -Staging 部署通知脚本 +部署通知脚本(支持 Staging / Production) +与 CI 通知(ci_notify_success.py / ci_notify_failure.py)卡片格式对齐。 发送部署结果通知到飞书 webhook(卡片格式)。 + 支持三种状态:success / failure / rollback +支持两种环境:staging / production 用法: - python3 deploy_notify.py --status success --detail "部署成功" --webhook - python3 deploy_notify.py --status rollback --detail "健康检查失败,已回滚" + python3 deploy_notify.py --status success --detail "部署成功" --env staging + python3 deploy_notify.py --status rollback --detail "健康检查失败,已回滚" --env production + python3 deploy_notify.py --status failure --detail "部署过程出错" --env production --failed-step "构建镜像" """ import argparse @@ -19,73 +23,139 @@ import urllib.request STATUS_CONFIG = { "success": { "emoji": "✅", - "title": "Staging 部署成功", + "title_suffix": "部署成功", "color": "green", + "button_text": "查看构建详情", + "button_type": "primary", }, "failure": { "emoji": "❌", - "title": "Staging 部署失败", + "title_suffix": "部署失败", "color": "red", + "button_text": "查看失败日志", + "button_type": "danger", }, "rollback": { "emoji": "↩️", - "title": "Staging 部署已回滚", + "title_suffix": "部署已回滚", "color": "yellow", + "button_text": "查看构建详情", + "button_type": "primary", + }, +} + +ENV_CONFIG = { + "staging": { + "label": "Staging", + "url_web": "https://staging.xiaoxiajianji.com", + "url_api": "https://staging-api.xiaoxiajianji.com", + }, + "production": { + "label": "Production", + "url_web": "https://saas.xiaoxiajianji.com", + "url_api": "https://api.xiaoxiajianji.com", }, } -def build_card(status: str, detail: str) -> dict: - """构建飞书卡片消息。""" +def build_card( + status: str, + detail: str, + env: str = "staging", + duration: str = "", + failed_step: str = "", + pr_url: str = "", +) -> dict: + """构建飞书卡片消息(与 ci_notify_*.py 风格一致)。""" cfg = STATUS_CONFIG.get(status, STATUS_CONFIG["failure"]) + env_cfg = ENV_CONFIG.get(env, ENV_CONFIG["staging"]) + title = f"{cfg['emoji']} {env_cfg['label']} {cfg['title_suffix']}" commit = os.environ.get("GITHUB_SHA", "unknown")[:8] - branch = os.environ.get("GITHUB_REF_NAME", "unknown") + ref = os.environ.get("GITHUB_REF_NAME", "unknown") actor = os.environ.get("GITHUB_ACTOR", "system") run_id = os.environ.get("GITHUB_RUN_ID", "-") repo = os.environ.get("GITHUB_REPOSITORY", "xiaoxia/xiaoxia-saas") + # 版本信息:tag 部署显示 tag,分支部署显示分支 + if ref.startswith("v"): + version_info = f"版本 {ref}" + else: + version_info = ref + + # 构建内容行(与 ci_notify_*.py 风格一致:**标签**: 值) + lines = [] + + # 详情行(部署特有) + if detail: + lines.append(f"**详情**: {detail}") + + lines.append(f"**环境**: {env_cfg['label']}") + lines.append(f"**版本**: {version_info}") + + # 失败阶段(失败/回滚时显示) + if failed_step and status in ("failure", "rollback"): + lines.append(f"**失败阶段**: {failed_step}") + + # 耗时(可选) + if duration: + lines.append(f"**耗时**: {duration}") + + lines.append(f"**提交**: {commit}") + lines.append(f"**提交者**: {actor}") + lines.append(f"**Run ID**: {run_id}") + elements = [ { "tag": "div", "text": { "tag": "lark_md", - "content": ( - f"**状态**: {cfg['emoji']} {cfg['title']}\n" - f"**详情**: {detail}\n" - f"**分支**: {branch}\n" - f"**提交**: {commit}\n" - f"**提交者**: {actor}\n" - ), + "content": "\n".join(lines), }, }, ] - # 如果有 run_id,加一个查看详情按钮 + # 查看详情按钮 + run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" if run_id and run_id != "-": - run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" elements.append( { "tag": "action", "actions": [ { "tag": "button", - "text": {"tag": "plain_text", "content": "查看构建详情"}, + "text": {"tag": "plain_text", "content": cfg["button_text"]}, "url": run_url, - "type": "primary", + "type": cfg["button_type"], } ], } ) - # 加 Staging 访问链接 + # PR 链接(可选) + if pr_url: + elements.append( + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "查看 PR"}, + "url": pr_url, + "type": "default", + } + ], + } + ) + + # 访问地址(部署特有) elements.append( { "tag": "note", "elements": [ { "tag": "plain_text", - "content": "Staging: https://staging.xiaoxiajianji.com", + "content": f"Web: {env_cfg['url_web']} | API: {env_cfg['url_api']}", } ], } @@ -97,7 +167,7 @@ def build_card(status: str, detail: str) -> dict: "header": { "title": { "tag": "plain_text", - "content": f"{cfg['emoji']} {cfg['title']}", + "content": title, }, "status": cfg["color"], }, @@ -106,9 +176,17 @@ def build_card(status: str, detail: str) -> dict: } -def send_notification(webhook: str, status: str, detail: str) -> bool: +def send_notification( + webhook: str, + status: str, + detail: str, + env: str = "staging", + duration: str = "", + failed_step: str = "", + pr_url: str = "", +) -> bool: """发送通知到 webhook。""" - payload = build_card(status, detail) + payload = build_card(status, detail, env, duration, failed_step, pr_url) data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( @@ -121,7 +199,7 @@ def send_notification(webhook: str, status: str, detail: str) -> bool: try: with urllib.request.urlopen(req, timeout=10) as resp: resp.read() - print(f"通知已发送: {status}") + print(f"通知已发送: {env} {status}") return True except Exception as e: print(f"通知发送失败: {e}", file=sys.stderr) @@ -129,7 +207,7 @@ def send_notification(webhook: str, status: str, detail: str) -> bool: def main(): - parser = argparse.ArgumentParser(description="Staging 部署通知") + parser = argparse.ArgumentParser(description="部署通知脚本") parser.add_argument( "--status", required=True, @@ -137,6 +215,15 @@ def main(): help="部署状态", ) parser.add_argument("--detail", default="", help="详情描述") + parser.add_argument( + "--env", + default="staging", + choices=["staging", "production"], + help="部署环境 (默认 staging)", + ) + parser.add_argument("--duration", default="", help="部署耗时") + parser.add_argument("--failed-step", default="", help="失败阶段") + parser.add_argument("--pr-url", default="", help="PR 链接") parser.add_argument( "--webhook", default=os.environ.get("CI_NOTIFY_WEBHOOK", ""), @@ -146,10 +233,19 @@ def main(): args = parser.parse_args() if not args.webhook: - print("未配置 webhook URL,跳过通知") + print("未配置 CI_NOTIFY_WEBHOOK,跳过通知") + print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK") return 0 - send_notification(args.webhook, args.status, args.detail) + send_notification( + webhook=args.webhook, + status=args.status, + detail=args.detail, + env=args.env, + duration=args.duration, + failed_step=args.failed_step, + pr_url=args.pr_url, + ) return 0 -- 2.54.0 From 62ab361940536460471e207cf5cc0da477415b39 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 14 Jul 2026 21:34:17 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(ci):=20deploy-production=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5+=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E6=BB=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci-cd.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index d6a350642..66f060c0f 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -1051,11 +1051,30 @@ jobs: run: "set -eux\nproduction_host=\"${PRODUCTION_SSH_HOST:-47.98.113.167}\"\nproduction_user=\"${PRODUCTION_SSH_USER:-root}\"\necho \"Host: $production_host\"\necho \"User: $production_user\"\necho \"Home: $HOME\"\necho \"Whoami: $(whoami)\"\n\nmkdir -p ~/.ssh\n\n# 查找可用的SSH密钥\nkey_path=\"\"\nif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then\n key_path=\"/root/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (builder key)\"\nelif [ -f \"$HOME/.ssh/xiaoxia_runtime_builder\" ]; then\n key_path=\"$HOME/.ssh/xiaoxia_runtime_builder\"\n echo \"Using key: $key_path (home key)\"\nelif [ -n \"${PRODUCTION_SSH_KEY:-}\" ]; then\n key_path=\"$HOME/.ssh/id_ed25519\"\n printf '%s\\n' \"$PRODUCTION_SSH_KEY\" > \"$key_path\"\n chmod 600 \"$key_path\"\n echo \"Using key from PRODUCTION_SSH_KEY secret\"\nelse\n echo \"ERROR: No SSH key available\"\n ls -la ~/.ssh/ 2>/dev/null || true\n ls -la /root/.ssh/ 2>/dev/null || true\n exit 1\nfi\n\nssh-keyscan -p 22222 -H \"$production_host\"\ \ >> ~/.ssh/known_hosts 2>/dev/null\necho \"SSH keyscan done\"\n\n# 测试SSH连接\nssh -p 22222 -i \"$key_path\" -o StrictHostKeyChecking=no \"${production_user}@${production_host}\" \"echo SSH_CONNECTION_OK && hostname\"\necho \"SSH connection verified\"\n\n# Registry 方式部署脚本(base64 编码避免转义问题)\nDEPLOY_B64=\"IyEvYmluL3NoCnNldCAtZXUKCiMgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0KIyBQcm9kdWN0aW9uIOmDqOe9suiEmuacrCAtIFJlZ2lzdHJ5IOaWueW8jwojIOeUqOazle+8mklNQUdFX1RBRz08dmVyc2lvbj4gUkVHSVNUUllfVE9LRU49PHRva2VuPiBzaCBkZXBsb3ktcHJvZHVjdGlvbi1yZWdpc3RyeS5zaAojID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CgpJTUFHRV9UQUc9IiR7SU1BR0VfVEFHOi19IgpSRUdJU1RSWT0iJHtSRUdJU1RSWTotZ2l0LnhpYW94aWFqaWFuamkuY29tL3hpYW94aWEveGlhb3hpYS1zYWFzfSIKUkVHSVNUUllfVVNFUj0iJHtSRUdJU1RSWV9VU0VSOi14aWFveGlhfSIKUkVHSVNUUllfVE9LRU49IiR7UkVHSVNUUllfVE9LRU46LX0iCgpFTlZfRklMRT0iJHtFTlZfRklMRTotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudn0iCkdFTkVSQVRFRF9ESVI9IiR7R0VORVJBVEVEX0RJUjotL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vZ2VuZXJhdGVkfSIKTEVHQUNZX0FTU0VUU19ESVI9IiR7TEVHQUNZX0FTU0VUU19ESVI6LS92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL2xlZ2FjeS1hc3NldHN9IgoKaWYgWyAteiAiJElNQUdFX1RBRyIgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBJTUFHRV9UQUcgaXMgcmVxdWlyZWQiCiAgZXhpdCAxCmZpCgp0ZXN0IC1mICIkRU5WX0ZJTEUiCm1rZGlyIC1wICIkR0VORVJBVEVEX0RJUiIKbWtkaXIgLXAgIiRMRUdBQ1lfQVNTRVRTX0RJUiIKCiMgLS0tLSDnmbvlvZUgUmVnaXN0cnkgLS0tLQppZiBbIC1uICIkUkVHSVNUUllfVE9LRU4iIF07IHRoZW4KICBlY2hvICJMb2dnaW5nIGluIHRvIHJlZ2lzdHJ5OiAkUkVHSVNUUlkiCiAgUkVHSVNUUllfSE9TVD0kKGVjaG8gIiRSRUdJU1RSWSIgfCBjdXQgLWQvIC1mMSkKICBwcmludGYgJXMgIiRSRUdJU1RSWV9UT0tFTiIgfCBkb2NrZXIgbG9naW4gIiRSRUdJU1RSWV9IT1NUIiAtdSAiJFJFR0lTVFJZX1VTRVIiIC0tcGFzc3dvcmQtc3RkaW4gMj4vZGV2L251bGwgfHwgewogICAgZWNobyAiV0FSTjogZG9ja2VyIGxvZ2luIGZhaWxlZCwgd2lsbCB0cnkgdG8gcHVsbCBhbnl3YXkiCiAgfQpmaQoKIyAtLS0tIFB1bGwg5LiJ6ZWc5YOPIC0tLS0KUkVHSVNUUllfQVBJPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtYXBpOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV09SS0VSPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd29ya2VyOiR7SU1BR0VfVEFHfSIKUkVHSVNUUllfV0VCPSIke1JFR0lTVFJZfS94aWFveGlhLXNhYXMtd2ViOiR7SU1BR0VfVEFHfSIKCkxPQ0FMX0FQST0ieGlhb3hpYS1zYWFzLWFwaToke0lNQUdFX1RBR30iCkxPQ0FMX1dPUktFUj0ieGlhb3hpYS1zYWFzLXdvcmtlcjoke0lNQUdFX1RBR30iCkxPQ0FMX1dFQj0ieGlhb3hpYS1zYWFzLXdlYjoke0lNQUdFX1RBR30iCgplY2hvICJQdWxsaW5nIEFQSSBpbWFnZS4uLiIKZG9ja2VyIHB1bGwgIiRSRUdJU1RSWV9BUEkiCmVjaG8gIlB1bGxpbmcgV29ya2VyIGltYWdlLi4uIgpkb2NrZXIgcHVsbCAiJFJFR0lTVFJZX1dPUktFUiIKZWNobyAiUHVsbGluZyBXZWIgaW1hZ2UuLi4iCmRvY2tlciBwdWxsICIkUkVHSVNUUllfV0VCIgoKIyAtLS0tIFJlLXRhZyDmiJDmnKzlnLDlkI0gLS0tLQpkb2NrZXIgdGFnICIkUkVHSVNUUllfQVBJIiAiJExPQ0FMX0FQSSIKZG9ja2VyIHRhZyAiJFJFR0lTVFJZX1dPUktFUiIgIiRMT0NBTF9XT1JLRVIiCmRvY2tlciB0YWcgIiRSRUdJU1RSWV9XRUIiICIkTE9DQUxfV0VCIgplY2hvICJBbGwgaW1hZ2VzIHB1bGxlZCBhbmQgdGFnZ2VkLiIKCiMgLS0tLSDlpIfku73ml6fniYggYXNzZXRz77yI6YOo572y5pyf6Ze057yT5a2Y55So5oi35LiNIDQwNO+8iSAtLS0tCmVjaG8gIkJhY2tpbmcgdXAgbGVnYWN5IGFzc2V0cyBmcm9tIGN1cnJlbnQgd2ViIGNvbnRhaW5lci4uLiIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBfdG1wZGlyPSIvdG1wL2xlZ2FjeS1hc3NldHMtJCQiCiAgcm0gLXJmICIkX3RtcGRpciIKICBta2RpciAtcCAiJF90bXBkaXIiCiAgZG9ja2VyIGNwIHhpYW94aWEtd2ViLXByb2R1Y3Rpb246L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy8uICIkX3RtcGRpci8iIDI+L2Rldi9udWxsIHx8IHRydWUKICAjIOWQiOW5tuWIsCBMRUdBQ1lfQVNTRVRTX0RJUu+8iOS/neeVmeaJgOacieWOhuWPsueJiOacrOeahCBhc3NldHPvvIkKICBpZiBbIC1kICIkX3RtcGRpciIgXSAmJiBbICIkKGxzIC1BICIkX3RtcGRpciIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgICBjcCAtYW4gIiRfdG1wZGlyIi8uICIkTEVHQUNZX0FTU0VUU19ESVIiLyAyPi9kZXYvbnVsbCB8fCB0cnVlCiAgICBlY2hvICJMZWdhY3kgYXNzZXRzIGJhY2tlZCB1cDogJChscyAiJF90bXBkaXIiIHwgd2MgLWwpIGZpbGVzIgogIGZpCiAgcm0gLXJmICIkX3RtcGRpciIKZWxzZQogIGVjaG8gIk5vIGV4aXN0aW5nIHdlYiBjb250YWluZXIsIHNraXBwaW5nIGxlZ2FjeSBhc3NldHMgYmFja3VwIgpmaQoKIyDmuIXnkIbotoXov4cgNyDlpKnnmoTml6cgYXNzZXRzIOaWh+S7tu+8iOmBv+WFjeaXoOmZkOWinumVv++8iQppZiBbIC1kICIkTEVHQUNZX0FTU0VUU19ESVIiIF07IHRoZW4KICBmaW5kICIkTEVHQUNZX0FTU0VUU19ESVIiIC10eXBlIGYgLW10aW1lICs3IC1kZWxldGUgMj4vZGV2L251bGwgfHwgdHJ1ZQogIGVjaG8gIkxlZ2FjeSBhc3NldHMgY2xlYW51cCBkb25lIChyZXRhaW4gNyBkYXlzKSIKZmkKCiMgLS0tLSDnoa7kv53ln7rnoYDorr7mlr3lrrnlmajlnKjov5DooYwgLS0tLQplY2hvICJDaGVja2luZyBpbmZyYXN0cnVjdHVyZSBjb250YWluZXJzLi4uIgpmb3IgYyBpbiB4aWFveGlhLXBvc3RncmVzLXByb2R1Y3Rpb24geGlhb3hpYS1yZWRpcy1wcm9kdWN0aW9uOyBkbwogIGlmICEgZG9ja2VyIGluc3BlY3QgIiRjIiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIkVSUk9SOiBSZXF1aXJlZCBjb250YWluZXIgbm90IGZvdW5kOiAkYyIKICAgIGV4aXQgMQogIGZpCiAgc3RhdGU9JChkb2NrZXIgaW5zcGVjdCAtZiAne3suU3RhdGUuU3RhdHVzfX0nICIkYyIpCiAgaWYgWyAiJHN0YXRlIiAhPSAicnVubmluZyIgXTsgdGhlbgogICAgZWNobyAiRVJST1I6IENvbnRhaW5lciBub3QgcnVubmluZzogJGMgKCRzdGF0ZSkiCiAgICBleGl0IDEKICBmaQpkb25lCgojIC0tLS0g56Gu5L+d55Sf5Lqn572R57uc5a2Y5ZyoIC0tLS0KZG9ja2VyIG5ldHdvcmsgY3JlYXRlIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQoKIyAtLS0tIOaJp+ihjOaVsOaNruW6kyBNaWdyYXRpb24gLS0tLQplY2hvICJSdW5uaW5nIGRhdGFiYXNlIG1pZ3JhdGlvbnMuLi4iCmRvY2tlciBydW4gLS1ybSBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgIiRMT0NBTF9BUEkiIHNoIC1jICJjZCAvYXBwICYmIGFsZW1iaWMgdXBncmFkZSBoZWFkIgplY2hvICJNaWdyYXRpb25zIGNvbXBsZXRlZC4iCgojIC0tLS0g5YGc5q2i5pen5a655ZmoIC0tLS0KZWNobyAiU3RvcHBpbmcgb2xkIGNvbnRhaW5lcnMuLi4iCmRvY2tlciBybSAtZiB4aWFveGlhLWFwaS1wcm9kdWN0aW9uIDI+L2Rldi9udWxsIHx8IHRydWUKZG9ja2VyIHJtIC1mIHhpYW94aWEtd29ya2VyLXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgcm0gLWYgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCgojIC0tLS0g5pel5b+X6YWN572u77yI5omA5pyJ5a655Zmo5YWx55So77yJIC0tLS0KTE9HX09QVFM9Ii0tbG9nLWRyaXZlciBqc29uLWZpbGUgLS1sb2ctb3B0IG1heC1zaXplPTUwbSAtLWxvZy1vcHQgbWF4LWZpbGU9MyIKCiMgLS0tLSDlkK/liqggQVBJIC0tLS0KZWNobyAiU3RhcnRpbmcgQVBJIGNvbnRhaW5lci4uLiIKZG9ja2VyIHJ1biAtZCBcCiAgLS1uYW1lIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24gXAogIC0tZW52LWZpbGUgIiRFTlZfRklMRSIgXAogIC0tbmV0d29yayB4aWFveGlhLW5ldC1wcm9kdWN0aW9uIFwKICAtcCAxMjcuMC4wLjE6ODAwMTo4MDAwIFwKICAtZSBBUFBfRU5WPXByb2R1Y3Rpb24gXAogIC1lIEFQUF9WRVJTSU9OPSIkSU1BR0VfVEFHIiBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX0RJUj0vYXBwL2dlbmVyYXRlZCBcCiAgLWUgR0VORVJBVEVEX0ZJTEVTX1VSTF9QUkVGSVg9L2dlbmVyYXRlZC1maWxlcyBcCiAgLWUgUFVCTElDX0FQSV9CQVNFX1VSTD1odHRwczovL2FwaS54aWFveGlhamlhbmppLmNvbSBcCiAgLXYgIiRHRU5FUkFURURfRElSOi9hcHAvZ2VuZXJhdGVkIiBcCiAgLS1yZXN0YXJ0IHVubGVzcy1zdG9wcGVkIFwKICAtLWNwdXMgMiBcCiAgLS1tZW1vcnkgMmcgXAogIC0taGVhbHRoLWNtZCAicHl0aG9uIC1jIFwiaW1wb3J0IHVybGxpYi5yZXF1ZXN0OyB1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJywgdGltZW91dD01KVwiIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDEwcyBcCiAgLS1oZWFsdGgtcmV0cmllcyAzIFwKICAtLWhlYWx0aC1zdGFydC1wZXJpb2QgNDBzIFwKICAkTE9HX09QVFMgXAogICIkTE9DQUxfQVBJIgoKIyAtLS0tIOWQr+WKqCBXb3JrZXIgLS0tLQplY2hvICJTdGFydGluZyBXb3JrZXIgY29udGFpbmVyLi4uIgpkb2NrZXIgcnVuIC1kIFwKICAtLW5hbWUgeGlhb3hpYS13b3JrZXItcHJvZHVjdGlvbiBcCiAgLS1lbnYtZmlsZSAiJEVOVl9GSUxFIiBcCiAgLS1uZXR3b3JrIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gXAogIC1lIEFQUF9FTlY9cHJvZHVjdGlvbiBcCiAgLWUgQVBQX1ZFUlNJT049IiRJTUFHRV9UQUciIFwKICAtZSBXT1JLRVJfQ09OQ1VSUkVOQ1k9MSBcCiAgLWUgV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9MTAwIFwKICAtZSBHRU5FUkFURURfRklMRVNfRElSPS9hcHAvZ2VuZXJhdGVkIFwKICAtZSBHRU5FUkFURURfRklMRVNfVVJMX1BSRUZJWD0vZ2VuZXJhdGVkLWZpbGVzIFwKICAtZSBQVUJMSUNfQVBJX0JBU0VfVVJMPWh0dHBzOi8vYXBpLnhpYW94aWFqaWFuamkuY29tIFwKICAtdiAiJEdFTkVSQVRFRF9ESVI6L2FwcC9nZW5lcmF0ZWQiIFwKICAtLXJlc3RhcnQgdW5sZXNzLXN0b3BwZWQgXAogIC0tY3B1cyAyIFwKICAtLW1lbW9yeSAyZyBcCiAgLS1oZWFsdGgtY21kICJzaCAtYyBcImdyZXAgLXEgY2VsZXJ5IC9wcm9jLzEvY21kbGluZSB8fCBleGl0IDFcIiIgXAogIC0taGVhbHRoLWludGVydmFsIDMwcyBcCiAgLS1oZWFsdGgtdGltZW91dCAxMHMgXAogIC0taGVhbHRoLXJldHJpZXMgMyBcCiAgLS1oZWFsdGgtc3RhcnQtcGVyaW9kIDMwcyBcCiAgJExPR19PUFRTIFwKICAiJExPQ0FMX1dPUktFUiIKCiMgLS0tLSDlkK/liqggV2ViIC0tLS0KIyBMZWdhY3kgYXNzZXRzIOaMgui9veWIsCAvdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLWxlZ2FjeS9hc3NldHMvCiMgbmdpbngg6YWN572u5LitIGFzc2V0cyBsb2NhdGlvbiDmnIkgZmFsbGJhY2sg6YC76L6RCkxFR0FDWV9WT0xVTUU9IiIKaWYgWyAtZCAiJExFR0FDWV9BU1NFVFNfRElSIiBdICYmIFsgIiQobHMgLUEgIiRMRUdBQ1lfQVNTRVRTX0RJUiIgMj4vZGV2L251bGwpIiBdOyB0aGVuCiAgTEVHQUNZX1ZPTFVNRT0iLXYgJHtMRUdBQ1lfQVNTRVRTX0RJUn06L3Vzci9zaGFyZS9uZ2lueC9odG1sL2Fzc2V0cy1sZWdhY3kvYXNzZXRzOnJvIgogIGVjaG8gIldlYiBjb250YWluZXI6IGxlZ2FjeSBhc3NldHMgbW91bnRlZCAoZmFsbGJhY2spIgplbHNlCiAgZWNobyAiV2ViIGNvbnRhaW5lcjogbm8gbGVnYWN5IGFzc2V0cyB0byBtb3VudCIKZmkKCmVjaG8gIlN0YXJ0aW5nIFdlYiBjb250YWluZXIuLi4iCmRvY2tlciBydW4gLWQgXAogIC0tbmFtZSB4aWFveGlhLXdlYi1wcm9kdWN0aW9uIFwKICAtLW5ldHdvcmsgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiBcCiAgLXAgMTI3LjAuMC4xOjMwMDI6ODAgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgLS1jcHVzIDAuNSBcCiAgLS1tZW1vcnkgNTEybSBcCiAgJExFR0FDWV9WT0xVTUUgXAogIC0taGVhbHRoLWNtZCAid2dldCAtLXNwaWRlciAtcSBodHRwOi8vMTI3LjAuMC4xOjgwIiBcCiAgLS1oZWFsdGgtaW50ZXJ2YWwgMzBzIFwKICAtLWhlYWx0aC10aW1lb3V0IDVzIFwKICAtLWhlYWx0aC1yZXRyaWVzIDMgXAogICRMT0dfT1BUUyBcCiAgIiRMT0NBTF9XRUIiCgojIC0tLS0g562J5b6FIEFQSSDlgaXlurcgLS0tLQplY2hvICJXYWl0aW5nIGZvciBBUEkgdG8gYmVjb21lIGhlYWx0aHkuLi4iCmk9MAp3aGlsZSBbICIkaSIgLWx0IDQwIF07IGRvCiAgaWYgY3VybCAtc2YgLS1tYXgtdGltZSA1IGh0dHA6Ly8xMjcuMC4wLjE6ODAwMS9oZWFsdGggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgICBlY2hvICJBUEkgaXMgaGVhbHRoeSEiCiAgICBicmVhawogIGZpCiAgaT0kKChpICsgMSkpCiAgZWNobyAiICBXYWl0aW5nLi4uICgkaS80MCkiCiAgc2xlZXAgMwpkb25lCgppZiBbICIkaSIgLWdlIDQwIF07IHRoZW4KICBlY2hvICJFUlJPUjogQVBJIGRpZCBub3QgYmVjb21lIGhlYWx0aHkgd2l0aGluIDEyMHMiCiAgZG9ja2VyIGxvZ3MgLS10YWlsIDUwIHhpYW94aWEtYXBpLXByb2R1Y3Rpb24KICBleGl0IDEKZmkKCiMgLS0tLSDnrYnlvoUgV2ViIOWBpeW6tyAtLS0tCmVjaG8gIldhaXRpbmcgZm9yIFdlYiB0byBiZWNvbWUgaGVhbHRoeS4uLiIKaT0wCndoaWxlIFsgIiRpIiAtbHQgMTUgXTsgZG8KICBpZiBjdXJsIC1zZiAtLW1heC10aW1lIDUgaHR0cDovLzEyNy4wLjAuMTozMDAyLyA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICAgIGVjaG8gIldlYiBpcyBoZWFsdGh5ISIKICAgIGJyZWFrCiAgZmkKICBpPSQoKGkgKyAxKSkKICBlY2hvICIgIFdhaXRpbmcuLi4gKCRpLzE1KSIKICBzbGVlcCAyCmRvbmUKCmlmIFsgIiRpIiAtZ2UgMTUgXTsgdGhlbgogIGVjaG8gIkVSUk9SOiBXZWIgZGlkIG5vdCBiZWNvbWUgaGVhbHRoeSB3aXRoaW4gMzBzIgogIGRvY2tlciBsb2dzIC0tdGFpbCAzMCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uCiAgZXhpdCAxCmZpCgojIC0tLS0g5riF55CG5pen6ZWc5YOPIC0tLS0KZWNobyAiQ2xlYW5pbmcgdXAgb2xkIGltYWdlcy4uLiIKZG9ja2VyIGltYWdlIHBydW5lIC1hZiAtLWZpbHRlciAidW50aWw9MTY4aCIgMj4vZGV2L251bGwgfHwgdHJ1ZQpkb2NrZXIgYnVpbGRlciBwcnVuZSAtYWYgLS1maWx0ZXIgInVudGlsPTE2OGgiIDI+L2Rldi9udWxsIHx8IHRydWUKCmVjaG8gIiIKZWNobyAiPT09IFByb2R1Y3Rpb24gZGVwbG95bWVudCBjb21wbGV0ZSA9PT0iCmVjaG8gIkFQSTogICAgICBodHRwOi8vMTI3LjAuMC4xOjgwMDEiCmVjaG8gIldlYjogICAgICBodHRwOi8vMTI3LjAuMC4xOjMwMDIiCmVjaG8gIlZlcnNpb246ICAkSU1BR0VfVEFHIgpkb2NrZXIgcHMgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319XHR7ey5JbWFnZX19IiB8IGdyZXAgcHJvZHVjdGlvbgo=\"\ \n\necho \"$DEPLOY_B64\" | base64 -d | ssh -p 22222 -i \"$key_path\" \"$production_user@$production_host\" \"IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh\"\n" - - name: Production smoke test (健康检查门禁) + - name: Production health check + auto rollback if: success() shell: sh - run: "set -eu\nAPI_BASE=\"https://api.xiaoxiajianji.com\"\nWEB_BASE=\"https://saas.xiaoxiajianji.com\"\n\necho \"=== 生产部署门禁:外部健康检查 ===\"\necho \"等待服务启动稳定(30s)...\"\nsleep 30\n\necho \"--- Check 1: API health endpoint ---\"\nfor i in $(seq 1 20); do\n HEALTH=$(curl -sf --max-time 10 \"${API_BASE}/health\") && break\n echo \" Attempt $i/20: not ready yet, waiting 5s...\"\n sleep 5\ndone\nif [ -z \"$HEALTH\" ]; then\n echo \"FAIL: API /health unreachable after 100s\"\n echo \"生产环境健康检查未通过,部署失败!\"\n echo \"(回滚机制待接入,当前需手动回滚)\"\n exit 1\nfi\necho \"API health OK: $HEALTH\"\n\necho \"--- Check 2: API login endpoint (expect 401/422) ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 -X POST \"${API_BASE}/api/v1/auth/login\" -H \"Content-Type: application/json\" -d '{\"email\":\"smoke@test.com\",\"password\":\"wrong\"}')\nif [ \"$HTTP_CODE\" != \"401\" ] && [ \"$HTTP_CODE\" != \"422\" ]; then\n echo \"FAIL: login returned HTTP $HTTP_CODE\ - \ (expected 401 or 422)\"\n exit 1\nfi\necho \"Login API OK: HTTP $HTTP_CODE\"\n\necho \"--- Check 3: Web frontend ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"${WEB_BASE}/\")\nif [ \"$HTTP_CODE\" != \"200\" ]; then\n echo \"FAIL: web frontend returned HTTP $HTTP_CODE (expected 200)\"\n exit 1\nfi\necho \"Web frontend OK: HTTP $HTTP_CODE\"\n\necho \"\"\necho \"=== ✅ 生产环境健康检查全部通过 ===\"\necho \"Version: ${GITHUB_REF_NAME}\"\n" + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} + PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} + PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }} + PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: 'set -eu + + echo "==========================================" + + echo " Production 健康检查 + 自动回滚" + + echo "==========================================" + + echo "" + + + bash scripts/ci_production_healthcheck.sh + + ' - name: Job duration summary if: always() shell: sh -- 2.54.0