diff --git a/.adm-api.pid b/.adm-api.pid deleted file mode 100644 index e7579bb..0000000 --- a/.adm-api.pid +++ /dev/null @@ -1 +0,0 @@ -26747 diff --git a/.adm-preview.pid b/.adm-preview.pid deleted file mode 100644 index 1b2a2f9..0000000 --- a/.adm-preview.pid +++ /dev/null @@ -1 +0,0 @@ -25645 diff --git a/.gitignore b/.gitignore index 5608d60..dfb8c3a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ memind-lib/ .vite/ *.log logs/ +.adm-api.pid +.adm-dev.pid +.adm-preview.pid +release-candidates/ diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 61b6b58..779a807 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -83,10 +83,35 @@ nginx 示例见 `scripts/gadm-nginx.conf.example`(`/ops/` → preview,`/auth ## 唯一入口 ```bash -bash scripts/release-prod.sh --dry-run +# 只生成持久候选包;不连接、不修改 103 +TEST_MEMIND_ROOT= \ +IMAGE_MAKE_RELEASE_ID=20260720-standalone-rc1 \ +bash scripts/release-prod.sh --package-only + +# 候选包生成后,只读检查 103 灰度前置条件 +bash scripts/check-gray-ready.sh --candidate-dir release-candidates/ + +# 正式生产发布;必须在灰度通过并得到明确许可后执行 bash scripts/release-prod.sh ``` +`--dry-run` 是 `--package-only` 的兼容别名。候选包默认保存在 +`release-candidates//`,包含 tar 包、manifest 和 SHA-256 校验文件。 + +发包时 `Memind` 共享源码必须满足: + +1. 是 Git 工作区且完全干净。 +2. `HEAD` 精确匹配 `MEMIND_BASE_REF`(默认 `origin/main`)。 +3. 包含 `asset-gateway.mjs`、`image-make-admin-config.mjs`、`image-make-client.mjs`。 +4. manifest 明确记录 `IMAGE_MAKE_RELEASE_ID`,避免后台候选与错误的 image_make 版本配套。 + +灰度分两层: + +1. `check-gray-ready.sh` 默认只做后台候选包、103 当前健康状态、灰度端口和 image_make 版本的只读预检。 +2. `--require-image-integration` 额外要求 Portal runtime-config 路由、image_make 配置 URL 和 worker 均已就绪。该条件涉及生产运行态,未经单独许可不得开启。 + +`release-prod.sh` 没有“灰度即生产”的隐式行为:不带 `--package-only` 时就是正式 live 原子切换,必须等灰度验收通过后再执行。 + ## 产物发布流程 1. 本地生成 `memind-adm-.tar.gz` 和发布清单。 @@ -94,7 +119,7 @@ bash scripts/release-prod.sh 3. 103 备份当前 `/Users/john/Project/memind_adm` 为时间戳压缩包。 4. 在 `/Users/john/Project/releases/` 下解包新版本。 5. 从当前 live 目录继承运行态配置与日志文件。 -6. 在 release 目录安装依赖、构建。 +6. 在 release 目录安装锁定依赖;前端 `dist/` 已在本地构建完成,103 不执行前端构建。 7. 原子切换 `memind_adm` live 目录。 8. 调用 `scripts/remote_restart.sh` 重启 `8085` 和 `5174`。 9. 检查 `127.0.0.1:8085/health` 与 `127.0.0.1:5174/`。 diff --git a/scripts/.releaseignore-prod b/scripts/.releaseignore-prod index dcc5060..902fe9d 100644 --- a/scripts/.releaseignore-prod +++ b/scripts/.releaseignore-prod @@ -11,6 +11,7 @@ .mindops node_modules coverage +release-candidates *.log adm-preview.log adm-api.log @@ -33,6 +34,7 @@ scripts/local_restart.sh scripts/pro_restart.sh scripts/rsync_to_server.sh scripts/audit-103-state.sh +scripts/check-gray-ready.sh scripts/gadm-nginx-105.conf scripts/gadm-nginx.conf.example scripts/gadm-lb.Caddyfile diff --git a/scripts/check-gray-ready.sh b/scripts/check-gray-ready.sh new file mode 100755 index 0000000..b58ec3e --- /dev/null +++ b/scripts/check-gray-ready.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HOST="${STUDIO_HOST:-10.10.0.2}" +GRAY_WEB_PORT="${GRAY_WEB_PORT:-15174}" +GRAY_API_PORT="${GRAY_API_PORT:-18085}" +REQUIRE_IMAGE_INTEGRATION=0 +CANDIDATE_DIR="" + +usage() { + cat <<'EOF' +Usage: + bash scripts/check-gray-ready.sh --candidate-dir DIR [--require-image-integration] + +Read-only checks only. This script never uploads, restarts, or changes 103. + +Checks: + - candidate checksum and traceability manifest + - current memind_adm production health + - reserved gray ports are free on 103 + - expected image_make release is healthy on 103 + - optional end-to-end Portal/image_make runtime wiring readiness +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --candidate-dir) + shift + [[ $# -gt 0 ]] || { echo "--candidate-dir requires a directory" >&2; exit 2; } + CANDIDATE_DIR="$1" + ;; + --require-image-integration) REQUIRE_IMAGE_INTEGRATION=1 ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +[[ -n "${CANDIDATE_DIR}" ]] || { usage >&2; exit 2; } +if [[ "${CANDIDATE_DIR}" != /* ]]; then + CANDIDATE_DIR="${ROOT}/${CANDIDATE_DIR}" +fi +[[ -d "${CANDIDATE_DIR}" ]] || { echo "Candidate directory not found: ${CANDIDATE_DIR}" >&2; exit 1; } + +manifest="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.manifest.txt' -print -quit)" +sha_file="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.sha256' -print -quit)" +[[ -n "${manifest}" && -n "${sha_file}" ]] || { + echo "Candidate manifest or checksum file missing in ${CANDIDATE_DIR}" >&2 + exit 1 +} + +( + cd "${CANDIDATE_DIR}" + shasum -a 256 -c "$(basename "${sha_file}")" +) + +manifest_value() { + local key="$1" + sed -n "s/^${key}=//p" "${manifest}" | head -n 1 +} + +release_id="$(manifest_value release_id)" +git_head="$(manifest_value git_head)" +memind_head="$(manifest_value memind_git_head)" +memind_status="$(manifest_value memind_git_status)" +image_make_release_id="$(manifest_value image_make_release_id)" + +[[ -n "${release_id}" && -n "${git_head}" && -n "${memind_head}" ]] || { + echo "Candidate manifest is missing release traceability fields" >&2 + exit 1 +} +[[ "${memind_status}" == "clean" ]] || { + echo "Candidate manifest does not prove a clean Memind shared source" >&2 + exit 1 +} +[[ "${image_make_release_id}" != "not-declared" && -n "${image_make_release_id}" ]] || { + echo "Candidate manifest must declare IMAGE_MAKE_RELEASE_ID" >&2 + exit 1 +} + +echo "candidate_release=${release_id}" +echo "candidate_git_head=${git_head}" +echo "candidate_memind_head=${memind_head}" +echo "candidate_image_make_release=${image_make_release_id}" + +remote_result="$(ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \ + "GRAY_WEB_PORT='${GRAY_WEB_PORT}' GRAY_API_PORT='${GRAY_API_PORT}' IMAGE_MAKE_RELEASE_ID='${image_make_release_id}' REQUIRE_IMAGE_INTEGRATION='${REQUIRE_IMAGE_INTEGRATION}' /bin/bash" <<'REMOTE' +set -euo pipefail + +api_code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)" +web_code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)" +image_code="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:18083/health || true)" +[[ "${api_code}" == "200" && "${web_code}" == "200" && "${image_code}" == "200" ]] || { + echo "health_failed admin_api=${api_code} admin_web=${web_code} image_make=${image_code}" >&2 + exit 1 +} + +for port in "${GRAY_WEB_PORT}" "${GRAY_API_PORT}"; do + if lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t | grep -q .; then + echo "gray_port_in_use=${port}" >&2 + exit 1 + fi +done + +export DOCKER_HOST=unix:///Users/john/.colima/default/docker.sock +docker_bin=/usr/local/bin/docker +container="$(${docker_bin} ps --format '{{.Names}} {{.Ports}}' | awk '/18083/ {print $1; exit}')" +[[ -n "${container}" ]] || { echo "image_make_container_missing" >&2; exit 1; } +image="$(${docker_bin} inspect --format '{{.Config.Image}}' "${container}")" +health="$(${docker_bin} inspect --format '{{.State.Health.Status}}' "${container}")" +[[ "${image}" == *"${IMAGE_MAKE_RELEASE_ID}"* && "${health}" == "healthy" ]] || { + echo "image_make_release_mismatch image=${image} health=${health}" >&2 + exit 1 +} + +worker_enabled="$(${docker_bin} inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "${container}" | sed -n 's/^WORKER_ENABLED=//p' | head -n 1)" +config_url_present=0 +if ${docker_bin} inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "${container}" | grep -q '^IMAGE_MAKE_MEMIND_CONFIG_URL=.'; then + config_url_present=1 +fi +portal_route_present=0 +if grep -q '/internal/image-make/runtime-config' /Users/john/Project/Memind/server.mjs 2>/dev/null; then + portal_route_present=1 +fi + +echo "admin_api=200 admin_web=200 image_make=200" +echo "gray_ports_free=${GRAY_WEB_PORT},${GRAY_API_PORT}" +echo "image_make_image=${image}" +echo "image_make_worker_enabled=${worker_enabled:-unset}" +echo "image_make_config_url_present=${config_url_present}" +echo "portal_runtime_route_present=${portal_route_present}" + +if [[ "${REQUIRE_IMAGE_INTEGRATION}" == "1" ]]; then + [[ "${worker_enabled}" == "true" && "${config_url_present}" == "1" && "${portal_route_present}" == "1" ]] || { + echo "image_make_integration_not_ready" >&2 + exit 1 + } +fi +REMOTE +)" + +printf '%s\n' "${remote_result}" +echo "gray_preflight=passed" diff --git a/scripts/release-prod.sh b/scripts/release-prod.sh index f6cb7e4..ba39fa7 100755 --- a/scripts/release-prod.sh +++ b/scripts/release-prod.sh @@ -22,7 +22,10 @@ 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" -DRY_RUN=0 +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 @@ -34,15 +37,19 @@ trap cleanup EXIT usage() { cat <<'EOF' 用法: - bash scripts/release-prod.sh [--dry-run] [--skip-build] [--yes] + bash scripts/release-prod.sh [--package-only|--dry-run] [--output-dir DIR] [--skip-build] [--yes] 说明: - 1. 本地构建 dist 并打包运行产物(不含 src/ 等开发源码) - 2. 上传到 103 - 3. 103 全量备份当前 memind_adm - 4. 解包、安装依赖(不在 103 构建前端) - 5. 原子切换 live 目录并重启验证 - 6. 旧 live 目录移入 archives,103 不再保留可编辑源码树 + 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 @@ -53,7 +60,12 @@ EOF while [[ $# -gt 0 ]]; do case "$1" in - --dry-run) DRY_RUN=1 ;; + --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) @@ -93,14 +105,52 @@ else 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 } -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 +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 不再执行前端构建)" @@ -120,7 +170,6 @@ fi exit 1 } -MEMIND_GIT_HEAD="$(git -C "${MEMIND_SRC}" rev-parse HEAD 2>/dev/null || echo unknown)" MEMIND_GIT_BRANCH="$(git -C "${MEMIND_SRC}" branch --show-current 2>/dev/null || echo detached)" say "打包 Memind 共享模块到 memind-lib" @@ -150,6 +199,14 @@ fi 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 @@ -163,7 +220,7 @@ grep -q 'tkmind-excel' "${MEMIND_LIB}/capabilities.mjs" || { exit 1 } -if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then +if [[ "${AUTO_YES}" -ne 1 && "${PACKAGE_ONLY}" -ne 1 ]]; then say "发布确认" echo "目标主机: ${HOST}" echo "目标目录: ${APP_DIR}" @@ -185,6 +242,10 @@ say "生成发布清单" 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" @@ -199,9 +260,19 @@ tar \ 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}" +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