Files
memind_adm/scripts/release-prod.sh

401 lines
13 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"
MEMIND_SRC="${TEST_MEMIND_ROOT:-${ROOT}/../Memind}"
MEMIND_LIB="${ROOT}/memind-lib"
MEMIND_BASE_REF="${MEMIND_BASE_REF:-origin/main}"
IMAGE_MAKE_RELEASE_ID="${IMAGE_MAKE_RELEASE_ID:-not-declared}"
PACKAGE_ONLY=0
OUTPUT_DIR=""
SKIP_BUILD=0
AUTO_YES=0
cleanup() {
rm -rf "${TMP_DIR}" "${MEMIND_LIB}"
}
trap cleanup EXIT
usage() {
cat <<'EOF'
用法:
bash scripts/release-prod.sh [--package-only|--dry-run] [--output-dir DIR] [--skip-build] [--yes]
说明:
1. 校验 memind_adm 与 Memind 共享源码均来自干净、可追溯的 commit
2. 本地构建 dist 并打包运行产物(不含 src/ 等开发源码)
3. --package-only / --dry-run 只生成持久候选包,不连接或修改 103
4. 正式模式上传到 103,完成全量备份、原子切换、重启和健康检查
5. 旧 live 目录移入 archives,103 不再保留可编辑源码树
环境:
TEST_MEMIND_ROOT 干净的 Memind 共享源码目录,默认 ../Memind
MEMIND_BASE_REF Memind 共享源码必须精确匹配的 ref,默认 origin/main
IMAGE_MAKE_RELEASE_ID 写入 manifest 的 image_make 依赖版本
禁止:
- 直接 rsync 到 103
- 在 103 生产目录手改源码后继续运行
- 在 103 保留 src/、vite.config.ts 等开发源码
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run|--package-only) PACKAGE_ONLY=1 ;;
--output-dir)
shift
[[ $# -gt 0 ]] || { echo "--output-dir 缺少目录参数" >&2; exit 2; }
OUTPUT_DIR="$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 rsync
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
git -C "${MEMIND_SRC}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "Memind 共享源码不是可追溯 Git 工作区: ${MEMIND_SRC}" >&2
exit 1
}
if [[ -n "$(git -C "${MEMIND_SRC}" status --porcelain=v1 --untracked-files=all)" ]]; then
echo "Memind 共享源码工作区不干净,禁止打包: ${MEMIND_SRC}" >&2
git -C "${MEMIND_SRC}" status --short --untracked-files=all >&2
exit 1
fi
git -C "${MEMIND_SRC}" rev-parse --verify --quiet "${MEMIND_BASE_REF}" >/dev/null || {
echo "Memind 共享源码缺少基线 ref: ${MEMIND_BASE_REF}" >&2
exit 1
}
MEMIND_GIT_HEAD="$(git -C "${MEMIND_SRC}" rev-parse HEAD)"
MEMIND_BASE_HEAD="$(git -C "${MEMIND_SRC}" rev-parse "${MEMIND_BASE_REF}")"
if [[ "${MEMIND_GIT_HEAD}" != "${MEMIND_BASE_HEAD}" ]]; then
echo "Memind 共享源码必须精确匹配 ${MEMIND_BASE_REF}" >&2
echo " HEAD=${MEMIND_GIT_HEAD}" >&2
echo " ${MEMIND_BASE_REF}=${MEMIND_BASE_HEAD}" >&2
exit 1
fi
for required_shared_file in \
user-auth.mjs \
asset-gateway.mjs \
image-make-admin-config.mjs \
image-make-client.mjs; do
[[ -f "${MEMIND_SRC}/${required_shared_file}" ]] || {
echo "Memind 共享源码缺少发布依赖: ${required_shared_file}" >&2
exit 1
}
done
[[ -f "${IGNORE_FILE}" ]] || {
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
exit 1
}
if [[ "${PACKAGE_ONLY}" -ne 1 ]]; then
say "103 正式发布预检查"
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
fi
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
say "本地构建 dist103 不再执行前端构建)"
(
cd "${ROOT}"
VITE_BASE_PATH="${VITE_BASE_PATH:-/ops}" npm run build
)
fi
[[ -d "${ROOT}/dist" ]] || {
echo "缺少 dist/,请先本地构建或去掉 --skip-build" >&2
exit 1
}
[[ -d "${MEMIND_SRC}" ]] || {
echo "缺少 Memind 共享模块源: ${MEMIND_SRC}" >&2
exit 1
}
MEMIND_GIT_BRANCH="$(git -C "${MEMIND_SRC}" branch --show-current 2>/dev/null || echo detached)"
say "打包 Memind 共享模块到 memind-lib"
rm -rf "${MEMIND_LIB}"
mkdir -p "${MEMIND_LIB}"
rsync -a \
--prune-empty-dirs \
--exclude '.git/' \
--exclude 'node_modules/' \
--include '*/' \
--include '*.mjs' \
--include '*.json' \
--include '*.js' \
--exclude '*' \
"${MEMIND_SRC}/" "${MEMIND_LIB}/"
if [[ -d "${MEMIND_SRC}/skills" ]]; then
say "打包 Memind 平台技能目录到 memind-lib"
mkdir -p "${MEMIND_LIB}/skills"
rsync -a \
--exclude '.DS_Store' \
--exclude '__pycache__/' \
"${MEMIND_SRC}/skills/" "${MEMIND_LIB}/skills/"
fi
[[ -f "${MEMIND_LIB}/user-auth.mjs" ]] || {
echo "memind-lib 缺少 user-auth.mjs" >&2
exit 1
}
[[ -f "${MEMIND_LIB}/image-make-admin-config.mjs" ]] || {
echo "memind-lib 缺少 image_make 管理配置模块" >&2
exit 1
}
[[ -f "${MEMIND_LIB}/image-make-client.mjs" ]] || {
echo "memind-lib 缺少 image_make 客户端模块" >&2
exit 1
}
[[ -f "${MEMIND_LIB}/skills/excel-analyst/SKILL.md" ]] || {
echo "memind-lib 缺少 Excel Analyst 技能" >&2
exit 1
}
grep -q 'EXCEL_ANALYST_SKILL_NAME' "${MEMIND_LIB}/skills-registry.mjs" || {
echo "memind-lib 缺少 Excel Analyst 技能授权映射" >&2
exit 1
}
grep -q 'tkmind-excel' "${MEMIND_LIB}/capabilities.mjs" || {
echo "memind-lib 缺少 Excel Analyst runtime 扩展映射" >&2
exit 1
}
if [[ "${AUTO_YES}" -ne 1 && "${PACKAGE_ONLY}" -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/tty
[[ "${confirm}" =~ ^[Yy]$ ]] || exit 0
fi
say "生成发布清单"
{
echo "release_id=${RELEASE_ID}"
echo "created_at=$(date '+%Y-%m-%d %H:%M:%S %z')"
echo "host=$(hostname)"
echo "root=${ROOT}"
echo "git_head=$(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)"
echo "memind_git_head=${MEMIND_GIT_HEAD}"
echo "memind_git_branch=${MEMIND_GIT_BRANCH}"
echo "memind_base_ref=${MEMIND_BASE_REF}"
echo "memind_base_head=${MEMIND_BASE_HEAD}"
echo "memind_git_status=clean"
echo "image_make_release_id=${IMAGE_MAKE_RELEASE_ID}"
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 [[ "${PACKAGE_ONLY}" -eq 1 ]]; then
if [[ -z "${OUTPUT_DIR}" ]]; then
OUTPUT_DIR="${ROOT}/release-candidates/${RELEASE_ID}"
elif [[ "${OUTPUT_DIR}" != /* ]]; then
OUTPUT_DIR="${ROOT}/${OUTPUT_DIR}"
fi
mkdir -p "${OUTPUT_DIR}"
cp "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${OUTPUT_DIR}/"
say "候选包生成完成;未连接或修改 103"
echo "candidate_dir=${OUTPUT_DIR}"
ls -lh "${OUTPUT_DIR}/$(basename "${BUNDLE_PATH}")" \
"${OUTPUT_DIR}/$(basename "${MANIFEST_PATH}")" \
"${OUTPUT_DIR}/$(basename "${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; }
[[ -f "${RELEASE_DIR}/memind-lib/user-auth.mjs" ]] || { echo "release bundle missing memind-lib/user-auth.mjs" >&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"