#!/usr/bin/env bash # ============================================================ # hyb-oa - 远端单机部署脚本(本地执行,rsync 同步) # ============================================================ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" DEPLOY_SERVER="${DEPLOY_SERVER:-}" REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}" REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}" ENABLE_WORKER="${ENABLE_WORKER:-0}" ENABLE_MINIO="${ENABLE_MINIO:-0}" SYNC_ENV="${SYNC_ENV:-0}" COMPOSE_FILE="deploy/trialrun/docker-compose.yml" LOCAL_ENV_FILE="deploy/trialrun/.env" ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js" RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js" EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js" PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs" STAGING_DIR="" cleanup() { if [[ -n "$STAGING_DIR" && -d "$STAGING_DIR" ]]; then rm -rf "$STAGING_DIR" fi } trap cleanup EXIT need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "[ERROR] 缺少命令: $1" >&2 exit 1 } } need_cmd rsync need_cmd ssh need_cmd node need_cmd git need_cmd tar need_cmd curl need_cmd mktemp if [[ -z "$DEPLOY_SERVER" ]]; then echo "[ERROR] 必须显式提供 DEPLOY_SERVER" >&2 exit 1 fi if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2 exit 1 fi echo ">>> 0. 校验 Git 来源与发布提交" PROVENANCE_JSON="$(node "${ROOT_DIR}/${PROVENANCE_SCRIPT}")" SOURCE_COMMIT="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.commit)' "$PROVENANCE_JSON")" SOURCE_SHORT_COMMIT="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.shortCommit)' "$PROVENANCE_JSON")" SOURCE_BRANCH="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(p.branch)' "$PROVENANCE_JSON")" SOURCE_PUBLISHED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.published))' "$PROVENANCE_JSON")" SOURCE_REMOTE_VERIFIED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.remoteVerified))' "$PROVENANCE_JSON")" SOURCE_OVERRIDE_USED="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.overrideUsed))' "$PROVENANCE_JSON")" SOURCE_OVERRIDE_REASON="$(node -e 'const p=JSON.parse(process.argv[1]); process.stdout.write(String(p.overrideReason || ""))' "$PROVENANCE_JSON")" echo "[deploy-source] commit=${SOURCE_COMMIT} branch=${SOURCE_BRANCH} published=${SOURCE_PUBLISHED} remoteVerified=${SOURCE_REMOTE_VERIFIED}" if [[ "$SOURCE_OVERRIDE_USED" == "true" || "$SOURCE_REMOTE_VERIFIED" != "true" ]]; then echo "[deploy-source][UNPUBLISHED-OVERRIDE] reason=${SOURCE_OVERRIDE_REASON} remoteVerified=${SOURCE_REMOTE_VERIFIED}" >&2 fi if [[ ! -f "${ROOT_DIR}/${COMPOSE_FILE}" ]]; then echo "[ERROR] 缺少 ${COMPOSE_FILE}" >&2 exit 1 fi if [[ ! -f "${ROOT_DIR}/${ENV_CONTRACT_SCRIPT}" ]]; then echo "[ERROR] 缺少 ${ENV_CONTRACT_SCRIPT}" >&2 exit 1 fi if [[ ! -f "${ROOT_DIR}/${RUNTIME_SMOKE_SCRIPT}" ]]; then echo "[ERROR] 缺少 ${RUNTIME_SMOKE_SCRIPT}" >&2 exit 1 fi if [[ ! -f "${ROOT_DIR}/${EXTERNAL_DEPS_SMOKE_SCRIPT}" ]]; then echo "[ERROR] 缺少 ${EXTERNAL_DEPS_SMOKE_SCRIPT}" >&2 exit 1 fi if [[ "${SYNC_ENV}" == "1" && ! -f "${ROOT_DIR}/${LOCAL_ENV_FILE}" ]]; then echo "[ERROR] SYNC_ENV=1 但本地不存在 ${LOCAL_ENV_FILE}" >&2 exit 1 fi echo ">>> 1. 校验部署环境契约" node "${ROOT_DIR}/${ENV_CONTRACT_SCRIPT}" echo ">>> 2. 从已验证提交生成不可变同步目录" STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hyb-oa-deploy.XXXXXX")" git -C "$ROOT_DIR" archive --format=tar "$SOURCE_COMMIT" | tar -xf - -C "$STAGING_DIR" echo ">>> 3. 创建远端目录" ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'" echo ">>> 4. 同步已验证提交到服务器" rsync -az --delete \ --exclude '.git' \ --exclude '.github' \ --exclude '.agent' \ --exclude '.agents' \ --exclude '.codex' \ --exclude '.claude' \ --exclude '.trellis' \ --exclude 'node_modules' \ --exclude 'client/node_modules' \ --exclude 'server/node_modules' \ --exclude 'client/test-results' \ --exclude 'client/playwright-report' \ --exclude 'artifacts' \ --exclude 'output' \ --exclude 'server/generated' \ --exclude 'deploy/trialrun/data' \ --exclude 'deploy/trialrun/backups' \ --exclude 'deploy/trialrun/.env' \ --exclude 'coverage' \ --exclude 'dist' \ --exclude '*.log' \ "${STAGING_DIR}/" "${DEPLOY_SERVER}:${REMOTE_DIR}/" if [[ "${SYNC_ENV}" == "1" ]]; then echo ">>> 5. 同步部署环境变量" ssh "${DEPLOY_SERVER}" "mkdir -p '$(dirname "${REMOTE_ENV_FILE}")'" rsync -az "${ROOT_DIR}/${LOCAL_ENV_FILE}" "${DEPLOY_SERVER}:${REMOTE_ENV_FILE}" else echo ">>> 5. 跳过 .env 同步(默认安全模式)" echo " 如果你确认本地 deploy/trialrun/.env 可直接上服务器,可用:SYNC_ENV=1 bash deploy/trialrun/deploy.sh" fi echo ">>> 6. 在服务器执行部署与 smoke" ssh "${DEPLOY_SERVER}" \ "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE' set -euo pipefail cd "$REMOTE_DIR" mkdir -p deploy/trialrun/data/postgres deploy/trialrun/data/storage deploy/trialrun/data/minio if [[ ! -f "$REMOTE_ENV_FILE" ]]; then echo "[ERROR] 远端缺少 $REMOTE_ENV_FILE,请先手工创建或使用 SYNC_ENV=1" >&2 exit 1 fi if docker compose version >/dev/null 2>&1; then compose_cmd=(docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE") elif command -v docker-compose >/dev/null 2>&1; then compose_cmd=(docker-compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE") else echo "[ERROR] 远端既没有 docker compose,也没有 docker-compose" >&2 exit 1 fi profile_args=() if [[ "$ENABLE_MINIO" == "1" ]]; then profile_args+=(--profile minio) fi if [[ "$ENABLE_WORKER" == "1" ]]; then profile_args+=(--profile worker) fi echo ">>> 4.1 校验远端 compose 配置" "${compose_cmd[@]}" "${profile_args[@]}" config >/dev/null echo ">>> 4.2 启动基础服务" "${compose_cmd[@]}" up -d db if [[ "$ENABLE_MINIO" == "1" ]]; then "${compose_cmd[@]}" "${profile_args[@]}" up -d minio minio_init fi echo ">>> 4.3 构建最新 init 镜像" "${compose_cmd[@]}" build init echo ">>> 4.4 预检新配置并执行 init" "${compose_cmd[@]}" run --rm -T --entrypoint sh init -lc 'cd /app/server && node -e '\''require("./src/config").getConfig()'\'' && npm run prisma:migrate:deploy && npm run seed:config && npm run seed:trialrun' >> 4.5 构建并切换应用服务" "${compose_cmd[@]}" "${profile_args[@]}" up -d --build server if [[ "$ENABLE_WORKER" == "1" ]]; then "${compose_cmd[@]}" "${profile_args[@]}" up -d worker fi echo ">>> 4.6 校验 Prisma migration status" if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status' &2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 fi echo ">>> 4.7 等待 /ready 并核对构建标识" ready_ok=0 for i in $(seq 1 60); do if ready_payload="$(curl -fsS http://127.0.0.1:3000/ready 2>/dev/null)"; then if READY_PAYLOAD="$ready_payload" EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" node -e ' const payload = JSON.parse(process.env.READY_PAYLOAD || "{}"); if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1); '; then ready_ok=1 break fi fi sleep 2 done if [[ "$ready_ok" != "1" ]]; then echo "[ERROR] /ready 在超时时间内未通过" >&2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 fi echo ">>> 4.8 执行 external deps smoke" if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-external-deps.js &2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 fi echo ">>> 4.9 执行 runtime smoke" if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-trialrun-runtime.js &2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 fi echo ">>> 4.10 当前服务状态" "${compose_cmd[@]}" ps REMOTE LIVE_SMOKE_READY=1 for required_key in LIVE_SMOKE_ADMIN_IDENTIFIER LIVE_SMOKE_ADMIN_PASSWORD LIVE_SMOKE_EMPLOYEE_IDENTIFIER LIVE_SMOKE_EMPLOYEE_PASSWORD; do if [[ -z "${!required_key:-}" ]]; then LIVE_SMOKE_READY=0 break fi done if [[ "$LIVE_SMOKE_READY" == "1" ]]; then echo ">>> 7. 执行部署后 live smoke" bash "${ROOT_DIR}/scripts/qa/run-web-gates.sh" smoke-live else echo ">>> 7. 跳过部署后 live smoke(未提供完整凭据)" fi PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" echo ">>> 8. 核对公网构建标识" PUBLIC_READY_PAYLOAD="$(curl -fsS "${PUBLIC_BASE_URL}/ready")" READY_PAYLOAD="$PUBLIC_READY_PAYLOAD" EXPECTED_BUILD_SHA="$SOURCE_SHORT_COMMIT" node -e ' const payload = JSON.parse(process.env.READY_PAYLOAD || "{}"); if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1); ' echo ">>> 9. 执行公网前端资源文案扫描" PUBLIC_EXPECTED_BUILD_SHA="$SOURCE_SHORT_COMMIT" \ PUBLIC_FORBIDDEN_BUILD_SHA="$SOURCE_COMMIT" \ node "${ROOT_DIR}/scripts/qa/check-public-bundle-copy.mjs" "$PUBLIC_BASE_URL" echo ">>> 10. 部署完成" echo " 来源提交: ${SOURCE_COMMIT}" echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke + 公网前端资源文案扫描" echo " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke" echo " 请确认 Caddy 或等效反向代理已把 OA 域名反代到 127.0.0.1:3000"