Files
john 98721371a4 fix(mindspace): edit_file 落盘、Finish 聊天 merge 与回归守卫
- finish-sync 支持 edit_file 覆盖 public HTML
- Finish 同步 merge 本地流式消息,剥离 agent 内部前缀
- 新增 verify:mindspace-publish-guards 与 AGENTS.md 跨工具说明
- 发版脚本接入回归门禁;103 runtime 发布含备份回退

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 00:27:15 +08:00

301 lines
9.0 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
cat >&2 <<'EOF'
`release-prod.sh` 已停用。
103 Portal 生产必须走无源码 runtime 发布,禁止在 103 解源码包、npm install 或 npm run build。
请改用:
bash scripts/release-portal-runtime-prod.sh --dry-run
bash scripts/release-portal-runtime-prod.sh
说明见:
- docs/no-source-portal-migration.md
- PRODUCTION_RELEASE_RULES.md
- docs/release-deploy.md
EOF
exit 1
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-58.38.22.103}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
APP_NAME="${STUDIO_APP_NAME:-Memind}"
APP_DIR="${REMOTE_ROOT}/${APP_NAME}"
INCOMING_DIR="${REMOTE_ROOT}/incoming/memind"
RELEASES_DIR="${REMOTE_ROOT}/releases"
BACKUP_DIR="${REMOTE_ROOT}/backups/memind"
IGNORE_FILE="${ROOT}/scripts/.releaseignore-prod"
HEALTH_URL="${STUDIO_HEALTH_URL:-http://127.0.0.1:8081/api/status}"
PLAZA_HEALTH_URL="${STUDIO_PLAZA_HEALTH_URL:-http://127.0.0.1:3001/plaza}"
LAUNCHD_GUI="gui/$(id -u)"
PORTAL_LABEL="cn.tkmind.memind-portal"
PLAZA_LABEL="cn.tkmind.plaza"
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}/memind-release.XXXXXX")"
BUNDLE_PATH="${TMP_DIR}/memind-${RELEASE_ID}.tar.gz"
MANIFEST_PATH="${TMP_DIR}/memind-${RELEASE_ID}.manifest.txt"
SHA_PATH="${TMP_DIR}/memind-${RELEASE_ID}.sha256"
DRY_RUN=0
SKIP_TESTS=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-tests] [--skip-build] [--yes]
说明:
1. 本地打包当前工作区为发布产物
2. 上传到 103
3. 103 全量备份当前 Memind
4. 解包、安装依赖、构建
5. 原子切换 live 目录并重启验证
禁止:
- 直接 rsync 到 103
- 在 103 生产目录手改源码后继续运行
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--skip-tests) SKIP_TESTS=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
[[ -f "${IGNORE_FILE}" ]] || {
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
exit 1
}
say "本地预检查"
git -C "${ROOT}" rev-parse --show-toplevel >/dev/null 2>&1 || true
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
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
say "运行最小验证"
(
cd "${ROOT}"
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
npm run verify:mindspace-publish-guards >/dev/null
)
fi
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/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 "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}'"
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}' HEALTH_URL='${HEALTH_URL}' PLAZA_HEALTH_URL='${PLAZA_HEALTH_URL}' LAUNCHD_GUI='${LAUNCHD_GUI}' PORTAL_LABEL='${PORTAL_LABEL}' PLAZA_LABEL='${PLAZA_LABEL}' SKIP_BUILD='${SKIP_BUILD}' /bin/bash" <<'REMOTE_SCRIPT'
set -euo pipefail
RELEASE_DIR="${RELEASES_DIR}/memind-${RELEASE_ID}"
OLD_LIVE_DIR="${RELEASES_DIR}/memind-live-before-${RELEASE_ID}"
BUNDLE="${INCOMING_DIR}/memind-${RELEASE_ID}.tar.gz"
MANIFEST="${INCOMING_DIR}/memind-${RELEASE_ID}.manifest.txt"
SHA_FILE="${INCOMING_DIR}/memind-${RELEASE_ID}.sha256"
BACKUP_TAR="${BACKUP_DIR}/memind-${RELEASE_ID}-before.tar.gz"
PERSISTED_ITEMS=(
".env"
"MindSpace"
"data"
"users"
".tailscale"
"public/plaza-covers"
"logs"
)
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
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
launchctl kickstart -k "${LAUNCHD_GUI}/${PLAZA_LABEL}" >/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 --exclude="$(basename "${APP_DIR}")/.tailscale/*.sock" \
-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}")"
if [[ "${item}" == ".tailscale" && -d "${APP_DIR}/${item}" ]]; then
mkdir -p "${RELEASE_DIR}/${item}"
(
cd "${APP_DIR}/${item}"
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - .
) | (
cd "${RELEASE_DIR}/${item}"
tar -xf -
)
else
cp -a "${APP_DIR}/${item}" "$(dirname "${RELEASE_DIR}/${item}")/"
fi
fi
done
say "安装依赖"
cd "${RELEASE_DIR}"
export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/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
if [[ "${SKIP_BUILD}" != "1" ]]; then
say "构建前端"
"${NPM_BIN}" run build
fi
say "切换 live 目录"
rm -rf "${OLD_LIVE_DIR}"
mv "${APP_DIR}" "${OLD_LIVE_DIR}"
mv "${RELEASE_DIR}" "${APP_DIR}"
say "重启服务"
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}"
sleep 3
launchctl kickstart -k "${LAUNCHD_GUI}/${PLAZA_LABEL}"
say "健康检查"
for _ in $(seq 1 60); do
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
plaza_code="$(curl -s -o /dev/null -w '%{http_code}' "${PLAZA_HEALTH_URL}" || true)"
if [[ "${portal_code}" == "200" && "${plaza_code}" == "200" ]]; then
break
fi
sleep 2
done
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
plaza_code="$(curl -s -o /dev/null -w '%{http_code}' "${PLAZA_HEALTH_URL}" || true)"
if [[ "${portal_code}" != "200" || "${plaza_code}" != "200" ]]; then
echo "health check failed: portal=${portal_code} plaza=${plaza_code}" >&2
rm -rf "${APP_DIR}"
mv "${OLD_LIVE_DIR}" "${APP_DIR}"
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
launchctl kickstart -k "${LAUNCHD_GUI}/${PLAZA_LABEL}" >/dev/null 2>&1 || true
exit 1
fi
say "发布成功"
rm -rf "${OLD_LIVE_DIR}"
rm -f "${BUNDLE}" "${MANIFEST}" "${SHA_FILE}"
echo "backup_tar=${BACKUP_TAR}"
echo "live_dir=${APP_DIR}"
REMOTE_SCRIPT
say "发布完成,读取线上版本摘要"
ssh -o BatchMode=yes "${HOST}" "sed -n '1,20p' '${APP_DIR}/.release-manifest.txt' && echo '---' && curl -s '${HEALTH_URL}' && echo && curl -s -o /dev/null -w 'plaza=%{http_code}\n' '${PLAZA_HEALTH_URL}'"