文件
hyb-oa/deploy/trialrun/deploy.sh
T
wangsli f223bfb72f
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
fix: 隔离既有发布的数据库容器生命周期(09-05-existing-deploy-db-lifecycle-isolation)
2026-09-05 21:02:58 +08:00

507 行
20 KiB
Bash
可执行文件
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-hyb-oa-trialrun}"
POSTGRES_DATA_DIR="${POSTGRES_DATA_DIR:-}"
STORAGE_HOST_DIR="${STORAGE_HOST_DIR:-}"
DEPLOY_DATA_MODE="${DEPLOY_DATA_MODE:-}"
ENABLE_WORKER="${ENABLE_WORKER:-0}"
ENABLE_MINIO="${ENABLE_MINIO:-0}"
SYNC_ENV="${SYNC_ENV:-0}"
DEPLOY_DEFER_FINAL_SUCCESS="${DEPLOY_DEFER_FINAL_SUCCESS:-0}"
DEPLOY_STATUS_OUTPUT_MODE="${DEPLOY_STATUS_OUTPUT_MODE:-verbose}"
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"
REMOTE_PREFLIGHT_SCRIPT="deploy/trialrun/preflight-remote.sh"
STAGING_DIR=""
RSYNC_PROTECT_ARGS=()
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 [[ ! "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
echo "[ERROR] COMPOSE_PROJECT_NAME 只能包含小写字母、数字、下划线和连字符" >&2
exit 1
fi
validate_absolute_path() {
local name="$1"
local value="$2"
if [[ -z "$value" || "$value" != /* || ! "$value" =~ ^/[A-Za-z0-9._/-]+$ ]]; then
echo "[ERROR] $name 必须显式提供受限字符集的绝对路径" >&2
exit 1
fi
if [[ "$value" == "/" || "$value" == */ || "$value" == *"//"* \
|| "/$value/" == *"/../"* || "/$value/" == *"/./"* ]]; then
echo "[ERROR] $name 不得是根目录、尾随斜杠或包含空、.、.. 路径段" >&2
exit 1
fi
}
paths_overlap() {
local first="$1"
local second="$2"
[[ "$first" == "$second" || "$first" == "$second"/* || "$second" == "$first"/* ]]
}
validate_absolute_path REMOTE_DIR "$REMOTE_DIR"
validate_absolute_path REMOTE_ENV_FILE "$REMOTE_ENV_FILE"
validate_absolute_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"
validate_absolute_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"
if [[ "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR" \
|| "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR"/* \
|| "$STORAGE_HOST_DIR" == "$POSTGRES_DATA_DIR"/* ]]; then
echo "[ERROR] PostgreSQL 与附件宿主目录必须相互独立" >&2
exit 1
fi
if [[ "$DEPLOY_DATA_MODE" != "existing" && "$DEPLOY_DATA_MODE" != "initialize" ]]; then
echo "[ERROR] DEPLOY_DATA_MODE 必须显式设为 existing 或 initialize" >&2
exit 1
fi
if [[ "$DEPLOY_DEFER_FINAL_SUCCESS" != "0" && "$DEPLOY_DEFER_FINAL_SUCCESS" != "1" ]]; then
echo "[ERROR] DEPLOY_DEFER_FINAL_SUCCESS 只能为 0 或 1" >&2
exit 1
fi
if [[ "$DEPLOY_STATUS_OUTPUT_MODE" != "quiet" && "$DEPLOY_STATUS_OUTPUT_MODE" != "verbose" ]]; then
echo "[ERROR] DEPLOY_STATUS_OUTPUT_MODE 只能为 quiet 或 verbose" >&2
exit 1
fi
if paths_overlap "$REMOTE_DIR" "$POSTGRES_DATA_DIR"; then
echo "[ERROR] POSTGRES_DATA_DIR 不得与 REMOTE_DIR 同目录或相互嵌套,避免源码同步触碰数据库" >&2
exit 1
fi
if paths_overlap "$REMOTE_DIR" "$STORAGE_HOST_DIR"; then
echo "[ERROR] STORAGE_HOST_DIR 不得与 REMOTE_DIR 同目录或相互嵌套,避免源码同步触碰附件" >&2
exit 1
fi
if [[ "$REMOTE_ENV_FILE" == "$REMOTE_DIR" ]]; then
echo "[ERROR] REMOTE_ENV_FILE 不得等于 REMOTE_DIR" >&2
exit 1
fi
if [[ "$REMOTE_ENV_FILE" == "$REMOTE_DIR"/* ]]; then
REMOTE_ENV_RELATIVE="${REMOTE_ENV_FILE#"$REMOTE_DIR"/}"
RSYNC_PROTECT_ARGS+=(--filter="protect /${REMOTE_ENV_RELATIVE}")
RSYNC_PROTECT_ARGS+=(--exclude="/${REMOTE_ENV_RELATIVE}")
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_BUILD_ENV_LINES="$(node -e '
const p = JSON.parse(process.argv[1]);
for (const [key, value] of Object.entries(p.buildEnv ?? {})) {
process.stdout.write(`${key}=${value}\n`);
}
' "$PROVENANCE_JSON")"
declare -A SOURCE_BUILD_ENV=()
while IFS='=' read -r key value; do
[[ -n "$key" ]] || continue
case "$key" in
BUILD_SHA|VITE_BUILD_SHA) ;;
*) echo "[ERROR] 发布来源包含未知候选构建环境键" >&2; exit 1 ;;
esac
[[ -z "${SOURCE_BUILD_ENV[$key]+x}" ]] \
|| { echo "[ERROR] 发布来源包含重复候选构建环境键" >&2; exit 1; }
SOURCE_BUILD_ENV["$key"]="$value"
done <<< "$SOURCE_BUILD_ENV_LINES"
[[ "${#SOURCE_BUILD_ENV[@]}" -eq 2 \
&& -n "${SOURCE_BUILD_ENV[BUILD_SHA]:-}" \
&& -n "${SOURCE_BUILD_ENV[VITE_BUILD_SHA]:-}" ]] \
|| { echo "[ERROR] 发布来源缺少完整候选构建环境对象" >&2; exit 1; }
SOURCE_COMMIT="${SOURCE_BUILD_ENV[BUILD_SHA]}"
SOURCE_SHORT_COMMIT="${SOURCE_BUILD_ENV[VITE_BUILD_SHA]}"
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 [[ ! -f "${ROOT_DIR}/${REMOTE_PREFLIGHT_SCRIPT}" ]]; then
echo "[ERROR] 缺少 ${REMOTE_PREFLIGHT_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}" \
"REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' SYNC_ENV='${SYNC_ENV}' bash -s" \
< "${ROOT_DIR}/${REMOTE_PREFLIGHT_SCRIPT}"
echo ">>> 3.1 创建远端目录"
ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'"
echo ">>> 4. 同步已验证提交到服务器"
rsync -az --delete \
"${RSYNC_PROTECT_ARGS[@]}" \
--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}' COMPOSE_PROJECT_NAME='${COMPOSE_PROJECT_NAME}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' DEPLOY_STATUS_OUTPUT_MODE='${DEPLOY_STATUS_OUTPUT_MODE}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE'
set -euo pipefail
cd "$REMOTE_DIR"
if [[ "$DEPLOY_DATA_MODE" == "initialize" ]]; then
mkdir -p "$POSTGRES_DATA_DIR" "$STORAGE_HOST_DIR"
elif [[ "$DEPLOY_DATA_MODE" == "existing" ]]; then
if [[ ! -d "$POSTGRES_DATA_DIR" || ! -f "$POSTGRES_DATA_DIR/PG_VERSION" ]]; then
echo "[ERROR] existing 模式要求 POSTGRES_DATA_DIR 已存在且包含 PG_VERSION" >&2
exit 1
fi
if [[ ! -d "$STORAGE_HOST_DIR" ]]; then
echo "[ERROR] existing 模式要求 STORAGE_HOST_DIR 已存在" >&2
exit 1
fi
else
echo "[ERROR] DEPLOY_DATA_MODE 必须显式设为 existing 或 initialize" >&2
exit 1
fi
if [[ "$ENABLE_MINIO" == "1" ]]; then
mkdir -p deploy/trialrun/data/minio
fi
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 -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
elif command -v docker-compose >/dev/null 2>&1; then
compose_cmd=(docker-compose -p "$COMPOSE_PROJECT_NAME" -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
DEPLOY_RUNTIME_LOG_REF="artifacts/deployment-runtime"
DEPLOY_RUNTIME_LOG_DIR="$REMOTE_DIR/$DEPLOY_RUNTIME_LOG_REF"
install -d -m 0700 "$DEPLOY_RUNTIME_LOG_DIR"
run_logged_deploy_stage() {
local stage="$1"
shift
[[ "$stage" =~ ^[a-z0-9-]+$ ]] || {
echo "[deploy-stage][ERROR] 阶段名称格式无效" >&2
return 2
}
local log_ref="$DEPLOY_RUNTIME_LOG_REF/${stage}-${VITE_BUILD_SHA}.log"
local log_file="$REMOTE_DIR/$log_ref"
: > "$log_file"
chmod 0600 "$log_file"
echo "[deploy-stage] stage=$stage logRef=$log_ref"
if "$@" </dev/null >"$log_file" 2>&1; then
echo "[deploy-stage][PASS] stage=$stage logRef=$log_ref"
return 0
else
local status=$?
echo "[deploy-stage][ERROR] stage=$stage exit=$status logRef=$log_ref" >&2
return "$status"
fi
}
echo ">>> 4.1 校验远端 compose 配置"
"${compose_cmd[@]}" "${profile_args[@]}" config >/dev/null
if ! command -v timeout >/dev/null 2>&1; then
echo "[ERROR] db_health_timeout_unavailable:缺少有界就绪检查工具,停止发布" >&2
exit 1
fi
wait_for_database_health() {
if timeout --signal=KILL 120 bash -s -- "${compose_cmd[@]}" <<'DB_HEALTH'
set -euo pipefail
while true; do
if ! database_id="$("$@" ps -a -q db 2>/dev/null)"; then
echo "[ERROR] db_health_query_failed:无法查询目标数据库容器,停止发布"
exit 1
fi
if [[ ! "$database_id" =~ ^[a-f0-9]{12,64}$ ]]; then
echo "[ERROR] db_container_missing_or_ambiguous:目标数据库容器不存在或不唯一,停止发布"
exit 1
fi
if ! database_health="$(docker inspect --type container --format '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$database_id" 2>/dev/null)"; then
echo "[ERROR] db_health_query_failed:无法读取目标数据库健康状态,停止发布"
exit 1
fi
case "$database_health" in
'running|healthy') exit 0 ;;
'running|starting') sleep 2 ;;
*)
echo "[ERROR] db_not_healthy:数据库未运行、不健康或缺少健康检查,停止发布;不会自动启动或修复"
exit 1
;;
esac
done
DB_HEALTH
then
echo "[PASS] 目标数据库健康检查通过;本次等待上限 120 秒"
else
local health_status=$?
if [[ "$health_status" == "124" || "$health_status" == "137" ]]; then
echo "[ERROR] db_health_timeout:数据库就绪检查超过 120 秒,停止发布;不执行后续 init 或应用切换" >&2
fi
return 1
fi
}
echo ">>> 4.2 按部署模式检查基础服务"
if [[ "$DEPLOY_DATA_MODE" == "initialize" ]]; then
"${compose_cmd[@]}" up -d db
fi
wait_for_database_health
if [[ "$ENABLE_MINIO" == "1" ]]; then
"${compose_cmd[@]}" "${profile_args[@]}" up -d --no-deps minio minio_init
fi
echo ">>> 4.3 构建最新 init 镜像"
run_logged_deploy_stage "build-init" "${compose_cmd[@]}" build init
echo ">>> 4.4 预检新配置并执行 init"
wait_for_database_health
run_logged_deploy_stage "run-init" "${compose_cmd[@]}" run --rm --no-deps -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'
echo ">>> 4.5 构建并切换应用服务"
wait_for_database_health
run_logged_deploy_stage "build-server" "${compose_cmd[@]}" "${profile_args[@]}" build server
if [[ "$ENABLE_WORKER" == "1" ]]; then
wait_for_database_health
if ! worker_container_id="$("${compose_cmd[@]}" "${profile_args[@]}" ps -a -q worker)"; then
echo "[ERROR] worker_image_query_failed:无法检查 worker 容器,停止发布" >&2
exit 1
fi
worker_image_ready=0
if [[ -n "$worker_container_id" ]]; then
if [[ ! "$worker_container_id" =~ ^[a-f0-9]{12,64}$ ]] || ! worker_image_reference="$(docker inspect --type container --format '{{.Config.Image}}' "$worker_container_id" 2>/dev/null)"; then
echo "[ERROR] worker_image_query_failed:worker 容器不唯一或镜像引用不可读,停止发布" >&2
exit 1
fi
if [[ ! "$worker_image_reference" =~ ^[A-Za-z0-9][A-Za-z0-9._/:@-]*$ ]]; then
echo "[ERROR] worker_image_query_failed:worker 镜像引用无效,停止发布" >&2
exit 1
fi
if docker image inspect "$worker_image_reference" >/dev/null 2>&1; then
worker_image_ready=1
fi
fi
if [[ "$worker_image_ready" != "1" ]]; then
run_logged_deploy_stage "build-worker" "${compose_cmd[@]}" "${profile_args[@]}" build worker
fi
fi
wait_for_database_health
run_logged_deploy_stage "build-switch-server" "${compose_cmd[@]}" "${profile_args[@]}" up -d --no-build --no-deps server
if [[ "$ENABLE_WORKER" == "1" ]]; then
wait_for_database_health
"${compose_cmd[@]}" "${profile_args[@]}" up -d --no-build --no-deps worker
fi
echo ">>> 4.6 校验 Prisma migration status"
if [[ "${DEPLOY_STATUS_OUTPUT_MODE:-verbose}" == "quiet" ]]; then
if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status' </dev/null >/dev/null 2>&1; then
echo "[ERROR] Prisma migration status 未通过,停止部署(详情留在受控 preflight 现场)" >&2
exit 1
fi
echo "[PASS] Prisma migration status 命令完成(详情留在受控 preflight 现场)"
elif ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status' </dev/null; then
echo "[ERROR] Prisma migration status 未通过,停止部署" >&2
"${compose_cmd[@]}" logs --tail=200 server || true
exit 1
fi
echo ">>> 4.7 等待 /ready 并核对构建标识"
server_port_output="$("${compose_cmd[@]}" port server 3000)"
server_host_port="${server_port_output##*:}"
if [[ ! "$server_host_port" =~ ^[0-9]+$ ]]; then
echo "[ERROR] 无法从 Compose 解析 server 宿主端口" >&2
exit 1
fi
ready_ok=0
for i in $(seq 1 60); do
if ready_payload="$(curl -fsS "http://127.0.0.1:${server_host_port}/ready" 2>/dev/null)"; then
if printf '%s' "$ready_payload" | "${compose_cmd[@]}" exec -T \
-e EXPECTED_BUILD_SHA="${VITE_BUILD_SHA}" server node -e '
const fs = require("node:fs");
const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
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 </dev/null; then
echo "[ERROR] external deps smoke 失败,输出最近 server 日志" >&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 </dev/null; then
echo "[ERROR] runtime smoke 失败,输出最近 server 日志" >&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"
if [[ "$DEPLOY_DEFER_FINAL_SUCCESS" == "1" ]]; then
echo ">>> 10. 应用发布检查完成,等待外部备份合同刷新"
else
echo ">>> 10. 部署完成"
fi
echo " 来源提交: ${SOURCE_COMMIT}"
echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke + 公网前端资源文案扫描"
echo " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke"
echo " 请确认 Caddy 或等效反向代理已把 OA 域名反代到 Compose 暴露的回环端口"