#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" HOST="${STUDIO_HOST:-10.10.0.2}" REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}" APP_NAME="${STUDIO_APP_NAME:-memind_adm}" APP_DIR="${REMOTE_ROOT}/${APP_NAME}" INCOMING_DIR="${REMOTE_ROOT}/incoming/memind_adm" RELEASES_DIR="${REMOTE_ROOT}/releases" BACKUP_DIR="${REMOTE_ROOT}/backups/memind_adm" ARCHIVE_DIR="${REMOTE_ROOT}/archives" IGNORE_FILE="${ROOT}/scripts/.releaseignore-prod" WEB_HEALTH_URL="${STUDIO_WEB_HEALTH_URL:-http://127.0.0.1:5174/ops/}" API_HEALTH_URL="${STUDIO_API_HEALTH_URL:-http://127.0.0.1:8085/health}" RELEASE_TS="$(date +%Y%m%d-%H%M%S)" SHORT_SHA="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo no-git)" RELEASE_ID="${RELEASE_TS}-${SHORT_SHA}" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memindadm-release.XXXXXX")" BUNDLE_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.tar.gz" MANIFEST_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.manifest.txt" SHA_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.sha256" DRY_RUN=0 SKIP_BUILD=0 AUTO_YES=0 cleanup() { rm -rf "${TMP_DIR}" } trap cleanup EXIT usage() { cat <<'EOF' 用法: bash scripts/release-prod.sh [--dry-run] [--skip-build] [--yes] 说明: 1. 本地构建 dist 并打包运行产物(不含 src/ 等开发源码) 2. 上传到 103 3. 103 全量备份当前 memind_adm 4. 解包、安装依赖(不在 103 构建前端) 5. 原子切换 live 目录并重启验证 6. 旧 live 目录移入 archives,103 不再保留可编辑源码树 禁止: - 直接 rsync 到 103 - 在 103 生产目录手改源码后继续运行 - 在 103 保留 src/、vite.config.ts 等开发源码 EOF } while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1 ;; --skip-build) SKIP_BUILD=1 ;; --yes|-y) AUTO_YES=1 ;; -h|--help) usage exit 0 ;; *) echo "未知参数: $1" >&2 usage >&2 exit 1 ;; esac shift done say() { printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" } need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "缺少命令: $1" >&2 exit 1 } } need_cmd ssh need_cmd scp need_cmd tar need_cmd shasum if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then bash "${ROOT}/scripts/check-release-ready.sh" else echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2 exit 1 fi [[ -f "${IGNORE_FILE}" ]] || { echo "缺少忽略文件: ${IGNORE_FILE}" >&2 exit 1 } say "本地预检查" ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo release-ssh-ok" >/dev/null ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/package.json'" >/dev/null ssh -o BatchMode=yes "${HOST}" "MEMIND_LIB_ROOT=\$(grep -E '^MEMIND_LIB_ROOT=' '${APP_DIR}/.env' | tail -1 | cut -d= -f2-); test -n \"\${MEMIND_LIB_ROOT}\" && test -f \"\${MEMIND_LIB_ROOT}/user-auth.mjs\"" >/dev/null if [[ "${SKIP_BUILD}" -ne 1 ]]; then say "本地构建 dist(103 不再执行前端构建)" ( cd "${ROOT}" VITE_BASE_PATH="${VITE_BASE_PATH:-/ops}" npm run build ) fi [[ -d "${ROOT}/dist" ]] || { echo "缺少 dist/,请先本地构建或去掉 --skip-build" >&2 exit 1 } if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then say "发布确认" echo "目标主机: ${HOST}" echo "目标目录: ${APP_DIR}" echo "发布编号: ${RELEASE_ID}" echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)" echo "工作区状态:" git -C "${ROOT}" status --short --branch | sed -n '1,120p' read -r -p "确认继续发布到 103? [y/N] " confirm /dev/null || echo unknown)" echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)" echo "git_status_begin" git -C "${ROOT}" status --short --branch || true echo "git_status_end" } > "${MANIFEST_PATH}" say "打包当前工作区" tar \ --exclude-from="${IGNORE_FILE}" \ -czf "${BUNDLE_PATH}" \ -C "${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 "上传发布产物到 103" ssh -o BatchMode=yes "${HOST}" "mkdir -p '${INCOMING_DIR}' '${RELEASES_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'" scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${HOST}:${INCOMING_DIR}/" say "在 103 备份并发布" ssh -o BatchMode=yes "${HOST}" \ "RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' INCOMING_DIR='${INCOMING_DIR}' RELEASES_DIR='${RELEASES_DIR}' BACKUP_DIR='${BACKUP_DIR}' ARCHIVE_DIR='${ARCHIVE_DIR}' WEB_HEALTH_URL='${WEB_HEALTH_URL}' API_HEALTH_URL='${API_HEALTH_URL}' /bin/bash" <<'REMOTE_SCRIPT' set -euo pipefail RELEASE_DIR="${RELEASES_DIR}/memind-adm-${RELEASE_ID}" OLD_LIVE_DIR="${ARCHIVE_DIR}/memind_adm-source-before-${RELEASE_ID}" BUNDLE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.tar.gz" MANIFEST="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.manifest.txt" SHA_FILE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.sha256" BACKUP_TAR="${BACKUP_DIR}/memind-adm-${RELEASE_ID}-before.tar.gz" PERSISTED_ITEMS=( ".env" "adm-preview.log" "adm-api.log" ) say() { printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*" } rollback() { if [[ -d "${OLD_LIVE_DIR}" ]]; then rm -rf "${APP_DIR}" mv "${OLD_LIVE_DIR}" "${APP_DIR}" fi ADM_ROOT="${APP_DIR}" ADM_PORT=5174 ADM_API_PORT=8085 bash "${APP_DIR}/scripts/remote_restart.sh" >/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; } say "校验产物完整性" cd "${INCOMING_DIR}" shasum -a 256 -c "$(basename "${SHA_FILE}")" say "全量备份当前 live 目录" COPYFILE_DISABLE=1 tar -czf "${BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")" say "准备 release 目录" rm -rf "${RELEASE_DIR}" mkdir -p "${RELEASE_DIR}" tar -xzf "${BUNDLE}" -C "${RELEASE_DIR}" cp "${MANIFEST}" "${RELEASE_DIR}/.release-manifest.txt" for item in "${PERSISTED_ITEMS[@]}"; do if [[ -e "${APP_DIR}/${item}" ]]; then rm -rf "${RELEASE_DIR:?}/${item}" mkdir -p "$(dirname "${RELEASE_DIR}/${item}")" cp -a "${APP_DIR}/${item}" "$(dirname "${RELEASE_DIR}/${item}")/" fi done [[ -d "${RELEASE_DIR}/dist" ]] || { echo "release bundle missing dist/" >&2; exit 1; } [[ ! -d "${RELEASE_DIR}/src" ]] || { echo "release bundle must not contain src/" >&2; exit 1; } [[ ! -d "${RELEASE_DIR}/memind-lib" ]] || { echo "release bundle must not contain bundled memind-lib/" >&2; exit 1; } say "安装运行依赖" cd "${RELEASE_DIR}" export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/usr/local/bin:/usr/bin:/bin:${PATH}" if command -v npm >/dev/null 2>&1; then NPM_BIN="$(command -v npm)" elif [[ -x /opt/homebrew/opt/node@24/bin/npm ]]; then NPM_BIN="/opt/homebrew/opt/node@24/bin/npm" else echo "npm not found on remote" >&2 exit 1 fi if [[ -f package-lock.json ]]; then "${NPM_BIN}" ci --no-audit --no-fund else "${NPM_BIN}" install --no-audit --no-fund fi cat > "${RELEASE_DIR}/PRODUCTION_ARTIFACT_ONLY.md" <<'ARTIFACT' This directory is a deployed production artifact on 103. Do not edit source files here. Source of truth: local repos under /Users/john/PycharmProjects/test/. Last artifact release manifest: .release-manifest.txt ARTIFACT say "切换 live 目录" rm -rf "${OLD_LIVE_DIR}" mv "${APP_DIR}" "${OLD_LIVE_DIR}" mv "${RELEASE_DIR}" "${APP_DIR}" say "重启服务" ADM_ROOT="${APP_DIR}" ADM_PORT=5174 ADM_API_PORT=8085 bash "${APP_DIR}/scripts/remote_restart.sh" say "健康检查" api_code="$(curl -s -o /dev/null -w '%{http_code}' "${API_HEALTH_URL}" || true)" web_code="$(curl -s -o /dev/null -w '%{http_code}' "${WEB_HEALTH_URL}" || true)" if [[ "${api_code}" != "200" || "${web_code}" != "200" ]]; then echo "health check failed: api=${api_code} web=${web_code}" >&2 rm -rf "${APP_DIR}" mv "${OLD_LIVE_DIR}" "${APP_DIR}" ADM_ROOT="${APP_DIR}" ADM_PORT=5174 ADM_API_PORT=8085 bash "${APP_DIR}/scripts/remote_restart.sh" >/dev/null 2>&1 || true exit 1 fi say "检查 live 目录不再保留开发源码" if [[ -d "${APP_DIR}/src" || -f "${APP_DIR}/vite.config.ts" ]]; then echo "source tree still present in live dir after switch" >&2 exit 1 fi say "发布成功" rm -f "${BUNDLE}" "${MANIFEST}" "${SHA_FILE}" echo "backup_tar=${BACKUP_TAR}" echo "archived_source=${OLD_LIVE_DIR}" echo "live_dir=${APP_DIR}" REMOTE_SCRIPT say "发布完成,读取线上版本摘要" ssh -o BatchMode=yes "${HOST}" "sed -n '1,20p' '${APP_DIR}/.release-manifest.txt' && echo '---' && test ! -d '${APP_DIR}/src' && echo 'src=absent' && curl -s '${API_HEALTH_URL}' && echo && curl -sI '${WEB_HEALTH_URL}' | head -1"