chore: gate memind_adm gray release candidates

This commit is contained in:
john
2026-07-21 17:01:59 +08:00
parent e08eb50775
commit 7f20c4ce87
7 changed files with 273 additions and 21 deletions
-1
View File
@@ -1 +0,0 @@
26747
-1
View File
@@ -1 +0,0 @@
25645
+4
View File
@@ -9,3 +9,7 @@ memind-lib/
.vite/
*.log
logs/
.adm-api.pid
.adm-dev.pid
.adm-preview.pid
release-candidates/
+27 -2
View File
@@ -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=<clean-Memind-worktree> \
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/<release-id>
# 正式生产发布;必须在灰度通过并得到明确许可后执行
bash scripts/release-prod.sh
```
`--dry-run` 是 `--package-only` 的兼容别名。候选包默认保存在
`release-candidates/<release-id>/`,包含 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-<release-id>.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/`。
+2
View File
@@ -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
+152
View File
@@ -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"
+88 -17
View File
@@ -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 "本地构建 dist103 不再执行前端构建)"
@@ -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