401 lines
13 KiB
Bash
Executable File
401 lines
13 KiB
Bash
Executable File
#!/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 "本地构建 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
|
||
}
|
||
|
||
[[ -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"
|