#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" HOST="${H5_DEPLOY_HOST:-root@120.26.184.105}" REMOTE_ROOT="${REMOTE_ROOT:-/root/tkmind_go}" APP_DIR="${APP_DIR:-${REMOTE_ROOT}/ui/h5}" INCOMING_DIR="${INCOMING_DIR:-${REMOTE_ROOT}/incoming/memind-portal-runtime-105}" BACKUP_DIR="${BACKUP_DIR:-${REMOTE_ROOT}/backups}" ARCHIVE_DIR="${ARCHIVE_DIR:-${REMOTE_ROOT}/archives}" SERVICE_NAME="${SERVICE_NAME:-goose-h5}" HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/api/status}" RUNTIME_ROOT="${ROOT}/.runtime/portal" RELEASE_ID="${RELEASE_ID:-$(date +%Y%m%d-%H%M%S)}" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memind-portal-runtime-105.XXXXXX")" BUNDLE_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.tar.gz" MANIFEST_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.manifest.txt" SHA_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.sha256" DRY_RUN=0 SKIP_BUILD=0 SKIP_TESTS=0 AUTO_YES=0 cleanup() { rm -rf "${TMP_DIR}" } trap cleanup EXIT usage() { cat <<'EOF' Usage: bash scripts/release-portal-runtime-105.sh [--dry-run] [--skip-build] [--skip-tests] [--yes] Behavior: 1. Optionally runs targeted local tests 2. Builds the local Portal runtime artifact 3. Packages .runtime/portal into a tarball 4. Uploads the tarball to 105 5. Replaces the 105 live runtime while preserving config and persisted data Preserved on 105: .env, MindSpace/, data/, users/, logs/, public/plaza-covers/ EOF } say() { printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" } while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1 ;; --skip-build) SKIP_BUILD=1 ;; --skip-tests) SKIP_TESTS=1 ;; --yes) AUTO_YES=1 ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 1 ;; esac shift done cd "${ROOT}" say "检查 105 SSH" ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-105-ssh-ok" >/dev/null if [[ "${SKIP_TESTS}" -ne 1 ]]; then say "运行微信相关本地测试" node --test wechat-mp.test.mjs wechat-oauth.test.mjs wechat-pay.test.mjs user-auth.test.mjs capabilities.test.mjs llm-providers.test.mjs fi if [[ "${SKIP_BUILD}" -ne 1 ]]; then say "构建 Portal runtime" PORTAL_RUNTIME_NODE_TARGET=node22 PORTAL_RUNTIME_INSTALL_MODE=remote-npm-install \ node scripts/build-portal-runtime.mjs --skip-node-modules fi verify_runtime_artifact() { local missing=0 for required in server.mjs mindspace-sandbox-mcp.mjs tkmind-excel-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/load-env.mjs scripts/wechat-mp-menu.mjs; do if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2 missing=1 fi done if [[ "${missing}" -ne 0 ]]; then echo "请先执行: node scripts/build-portal-runtime.mjs" >&2 exit 1 fi } verify_runtime_artifact if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then say "发布确认" echo "目标主机: ${HOST}" echo "目标目录: ${APP_DIR}" echo "systemd 服务: ${SERVICE_NAME}" echo "发布编号: ${RELEASE_ID}" echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)" read -r -p "确认继续发布到 105? [y/N] " confirm /dev/null || echo unknown)" echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)" echo "artifact=.runtime/portal" echo "target=105" echo "persisted_items=.env, MindSpace, data, users, logs, public/plaza-covers" } > "${MANIFEST_PATH}" say "打包 Portal runtime artifact" tar -czf "${BUNDLE_PATH}" -C "${RUNTIME_ROOT}" . checksum="$(shasum -a 256 "${BUNDLE_PATH}" | awk '{print $1}')" printf '%s %s\n' "${checksum}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}" if [[ "${DRY_RUN}" -eq 1 ]]; then say "dry-run 完成" ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" exit 0 fi say "上传 Portal runtime 到 105" ssh -o BatchMode=yes "${HOST}" "mkdir -p '${INCOMING_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'" scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${HOST}:${INCOMING_DIR}/" say "在 105 切换到新的 runtime" ssh -o BatchMode=yes "${HOST}" \ "RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' INCOMING_DIR='${INCOMING_DIR}' BACKUP_DIR='${BACKUP_DIR}' ARCHIVE_DIR='${ARCHIVE_DIR}' HEALTH_URL='${HEALTH_URL}' SERVICE_NAME='${SERVICE_NAME}' /bin/bash" <<'REMOTE_SCRIPT' set -euo pipefail RUNTIME_DIR="${APP_DIR}.runtime-${RELEASE_ID}" OLD_LIVE_DIR="${ARCHIVE_DIR}/h5-before-${RELEASE_ID}" BUNDLE="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.tar.gz" MANIFEST="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.manifest.txt" SHA_FILE="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.sha256" FULL_BACKUP_TAR="${BACKUP_DIR}/h5-full-${RELEASE_ID}-before.tar.gz" PERSIST_BACKUP_TAR="${BACKUP_DIR}/h5-persisted-${RELEASE_ID}-before.tar.gz" PERSISTED_ITEMS=( ".env" "MindSpace" "data" "users" "logs" "public/plaza-covers" ) say() { printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*" } rollback() { if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then mv "${OLD_LIVE_DIR}" "${APP_DIR}" fi systemctl restart "${SERVICE_NAME}" >/dev/null 2>&1 || true } trap 'rollback' ERR [[ -f "${BUNDLE}" ]] || { echo "missing bundle: ${BUNDLE}" >&2; exit 1; } [[ -f "${MANIFEST}" ]] || { echo "missing manifest: ${MANIFEST}" >&2; exit 1; } [[ -f "${SHA_FILE}" ]] || { echo "missing sha file: ${SHA_FILE}" >&2; exit 1; } [[ -f "${APP_DIR}/.env" ]] || { echo "missing required config: ${APP_DIR}/.env" >&2; exit 1; } say "校验产物完整性" cd "${INCOMING_DIR}" shasum -a 256 -c "$(basename "${SHA_FILE}")" say "备份当前 live 全目录" COPYFILE_DISABLE=1 tar -czf "${FULL_BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")" say "单独备份持久目录" tmp_persist_dir="$(mktemp -d "${TMPDIR:-/tmp}/memind-105-persisted.XXXXXX")" for item in "${PERSISTED_ITEMS[@]}"; do if [[ -e "${APP_DIR}/${item}" ]]; then mkdir -p "${tmp_persist_dir}/$(dirname "${item}")" cp -a "${APP_DIR}/${item}" "${tmp_persist_dir}/$(dirname "${item}")/" fi done COPYFILE_DISABLE=1 tar -czf "${PERSIST_BACKUP_TAR}" -C "${tmp_persist_dir}" . rm -rf "${tmp_persist_dir}" say "停止旧 Portal 服务" systemctl stop "${SERVICE_NAME}" lsof -tiTCP:8080 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true sleep 2 say "准备新的 runtime live 目录" rm -rf "${RUNTIME_DIR}" mkdir -p "${RUNTIME_DIR}" tar -xzf "${BUNDLE}" -C "${RUNTIME_DIR}" cp "${MANIFEST}" "${RUNTIME_DIR}/.release-manifest.txt" for item in "${PERSISTED_ITEMS[@]}"; do if [[ -e "${APP_DIR}/${item}" ]]; then rm -rf "${RUNTIME_DIR:?}/${item}" mkdir -p "$(dirname "${RUNTIME_DIR}/${item}")" cp -a "${APP_DIR}/${item}" "$(dirname "${RUNTIME_DIR}/${item}")/" fi done say "在 105 安装 Linux 运行依赖" cd "${RUNTIME_DIR}" rm -rf node_modules package-lock.json npm install --omit=dev --no-package-lock say "归档旧 live 并切换新 runtime" rm -rf "${OLD_LIVE_DIR}" mv "${APP_DIR}" "${OLD_LIVE_DIR}" mv "${RUNTIME_DIR}" "${APP_DIR}" say "启动新的 Portal runtime" systemctl restart "${SERVICE_NAME}" say "健康检查" for _ in $(seq 1 60); do portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)" if [[ "${portal_code}" == "200" ]]; then break fi sleep 2 done portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)" if [[ "${portal_code}" != "200" ]]; then echo "health check failed: portal=${portal_code}" >&2 exit 1 fi say "runtime 切换完成" REMOTE_SCRIPT say "105 runtime 发布完成"