Files
zijunchiang 1acc75f00e test(release): 逐文件核对「发布包 == 打标签的树」(T-WEB-018)
「发布包里的东西必须等于打标签的那棵树」此前靠人抽查几个文件(README、contracts/VERSION…),
必然会漏。上一轮实测就暴露了两个认知错误:Web 的发布包其实**包含** web-server/test/**
(33 个文件,我此前以为不含),以及包内另有 177 个**构建产物**(admin/、web-server/dist/)
必须与工作树构建比 —— 只比源码文件是不够的。

新增 web-server/scripts/check-tracked-release.py(纯标准库,支持 .tar.gz / .zip):
对包内**每一个**文件按路径决定比较基准 —— 构建产物(admin/、web-server/dist/)比工作树
构建结果,其余文件比 `git show <tag>:<路径>`;任何内容不一致 / 包内多余(既不在 git 也不是
声明的构建产物)/ 构建产物在工作树缺失都会列出并以非 0 退出。`npm run release:check` 已注册。

实测(**已发布**资产重新下载后跑):
  包内 356 个文件:源码/文档 179(与 tag v0.7.5 比)+ 构建产物 177(与工作树构建比)
  ✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失
反向验证:篡改 README.md 一个字节 + 塞入 web-server/EXTRA.txt →
  ✗ 内容不一致 1 处(README.md)、✗ 包内多余 1 处(EXTRA.txt),退出码 1。

顺带修掉脚本自身一个路径映射缺陷:BUILD_OUTPUTS 的键若是不带 / 的**精确文件名**
(Windows/Android 的单文件产物),原实现会把剩余路径拼成 ".../OpenDance.exe/"
导致「工作树缺失」误报;现在区分「目录前缀」与「精确文件名」两种键。

注:accept-release.sh 的接入只在下一个版本的产物里生效(本仓 accept-release.sh 本身
也在发布包内,v0.7.5 已发布);对已发布的 v0.7.5 包仍可用 `npm run release:check -- <包> --tag v0.7.5`
逐文件核对(已跑通)。同时已把同一脚本复制到 opendance-windows / opendance-android 并接入
两端的 accept-release.sh。
2026-09-18 03:10:05 +08:00

214 lines
8.8 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
# ============================================================
# OpenDance Studio Web 服务端 —— **发布产物级端到端验收**
#
# 为什么需要它:仓库里的 `npm run verify`(单元测试 + `check-bootstrap`)跑的是
# **源码与刚构建的 dist**;而真正交付给部署方的是 `dist-release/` 里的 tar.gz。
# 两者之间还隔着「解包 → npm install → 迁移 → 起进程」四步,历史上三次生产
# 事故(v0.7.0/v0.7.1 起不来、v0.7.3 场景上传 400、v0.7.4 `No metadata for "Scene"`)
# 全都只在**产物真正跑起来**时才暴露。
#
# 本脚本把那次验收完整自动化:解包发布包 → 装生产依赖 → 跑迁移 → 起服务 →
# 跑发布包自带的 `test/smoke.mjs` → 停服务 → 汇报。跑完你就得到一份
# 「这个 tarball 能不能用」的结论,而不是「源码测试是绿的」。
#
# 前置:需要一个可连的 **MySQL / MariaDB**(脚本不会安装数据库,与项目
# 「不部署 MySQL」的边界一致)。本机验证时可用纯用户态方案,例如:
# npm i mysql-memory-server # 或任何现成实例
#
# 用法:
# DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=opendance DB_PASS=xxx DB_NAME=opendance_test \
# bash scripts/accept-release.sh dist-release/opendance-web-v0.7.4.tar.gz
#
# 可选环境变量:
# PORT / ADMIN_PORT 服务端口(默认 34100 / 34101,避免撞上真实服务)
# WORK_DIR 解包与存储目录(默认 mktemp -d)
# ASSIMP_BIN Assimp 可执行文件;设为空则跳过需要它的用例
# SKIP_MIGRATE=1 跳过迁移(复用已有库)
# KEEP_RUNNING=1 跑完不杀进程(便于手工继续查)
#
# 退出码:0 全通过 / 1 有失败 / 2 用法或前置问题。
# ============================================================
set -uo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
TARBALL="${1:-}"
if [ -z "$TARBALL" ]; then
echo "用法:DB_HOST=... DB_USER=... DB_NAME=... bash scripts/accept-release.sh <发布包 tar.gz>" >&2
exit 2
fi
if [ ! -f "$TARBALL" ]; then
echo "✗ 找不到发布包:$TARBALL" >&2
exit 2
fi
for name in DB_HOST DB_USER DB_NAME; do
if [ -z "${!name:-}" ]; then
echo "✗ 缺少环境变量 $name(需要现有 MySQL/MariaDB 连接信息)" >&2
exit 2
fi
done
PORT="${PORT:-34100}"
ADMIN_PORT="${ADMIN_PORT:-34101}"
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
SKIP_MIGRATE="${SKIP_MIGRATE:-0}"
KEEP_RUNNING="${KEEP_RUNNING:-0}"
log() { printf '\033[1;34m[accept]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[accept] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
FAILED=0
log "发布包:$TARBALL"
log "工作目录:$WORK_DIR"
mkdir -p "$WORK_DIR"
# ---- 0. 逐文件核对:发布包 == 打标签的树(T-WEB-018)----
# 只在仓库内、且能解析到对应 tag 时执行;对用户拿到的 tarball 也能跑(--tag 需显式给)。
TRACKED_CHECKER="$PROJECT_DIR/scripts/check-tracked-release.py"
if [ -f "$TRACKED_CHECKER" ] && command -v git >/dev/null 2>&1; then
TAG="$(basename "$TARBALL" .tar.gz | sed 's/^opendance-web-//')"
if git -C "$PROJECT_DIR" rev-parse --verify "${TAG}^{commit}" >/dev/null 2>&1; then
log "逐文件核对:发布包 == tag $TAG(源码/文档比 tag,构建产物比工作树构建)"
python3 "$TRACKED_CHECKER" "$TARBALL" --tag "$TAG" || fail "发布包与 tag $TAG 不一致"
else
log "跳过逐文件核对(仓库里没有 tag $TAG;可在仓库内用 --tag 手工核对)"
fi
else
log "跳过逐文件核对(缺 $TRACKED_CHECKER 或 git)"
fi
# ---- 1. 解包 ----
log "解包…"
tar -xzf "$TARBALL" -C "$WORK_DIR" || { echo "✗ 解包失败" >&2; exit 1; }
PACKAGE_ROOT="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
SERVER_DIR="$PACKAGE_ROOT/web-server"
[ -d "$SERVER_DIR" ] || { echo "✗ 包里没有 web-server/ 目录" >&2; exit 1; }
# ---- 2. 装生产依赖 ----
# 与部署脚本一致:只装 dependencies,不装 devDependencies。
log "安装生产依赖(npm install --omit=dev)…"
( cd "$SERVER_DIR" && npm install --omit=dev --no-audit --no-fund >"$WORK_DIR/npm-install.log" 2>&1 ) \
|| { echo "✗ 依赖安装失败,日志:$WORK_DIR/npm-install.log" >&2; tail -20 "$WORK_DIR/npm-install.log"; exit 1; }
# ---- 3. 环境变量 ----
cat >"$WORK_DIR/accept.env" <<EOF
NODE_ENV=production
HOST=127.0.0.1
PORT=$PORT
ADMIN_PORT=$ADMIN_PORT
ADMIN_HOST=127.0.0.1
DB_HOST=$DB_HOST
DB_PORT=${DB_PORT:-3306}
DB_USER=$DB_USER
DB_PASS=${DB_PASS:-}
DB_NAME=$DB_NAME
DB_SYNCHRONIZE=false
STORAGE_DIR=$WORK_DIR/storage
ADMIN_DIR=$PACKAGE_ROOT/admin
CONTRACTS_DIR=$PACKAGE_ROOT/contracts
EOF
mkdir -p "$WORK_DIR/storage"
# shellcheck disable=SC1091
set -a; . "$WORK_DIR/accept.env"; set +a
# ---- 4. 迁移 ----
if [ "$SKIP_MIGRATE" = "1" ]; then
log "跳过迁移(SKIP_MIGRATE=1)"
else
log "执行迁移(npm run migration:run)…"
( cd "$SERVER_DIR" && npm run migration:run >"$WORK_DIR/migrate.log" 2>&1 ) \
|| { echo "✗ 迁移失败,日志:$WORK_DIR/migrate.log" >&2; tail -30 "$WORK_DIR/migrate.log"; exit 1; }
log "迁移完成"
fi
# ---- 5. 起服务 ----
log "启动产物(node dist/main.js)…"
( cd "$SERVER_DIR" && nohup node dist/main.js >"$WORK_DIR/server.log" 2>&1 & echo $! >"$WORK_DIR/server.pid" )
SERVER_PID="$(cat "$WORK_DIR/server.pid")"
cleanup() {
if [ "$KEEP_RUNNING" != "1" ] && [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
sleep 2
kill -9 "$SERVER_PID" 2>/dev/null || true
log "已停止服务(pid $SERVER_PID)"
fi
}
trap cleanup EXIT
API="http://127.0.0.1:$PORT"
READY=0
for _ in $(seq 1 60); do
if curl -sf -m 5 "$API/health" >"$WORK_DIR/health.json" 2>/dev/null; then READY=1; break; fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi
sleep 1
done
if [ "$READY" != "1" ]; then
fail "服务 60 秒内没有就绪;日志:$WORK_DIR/server.log"
tail -30 "$WORK_DIR/server.log"
exit 1
fi
# ---- 6. 健康检查关键字段(④ 的生产根因就在这几项上)----
log "健康检查:"
node -e '
const fs = require("node:fs");
const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const pkg = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
let bad = 0;
const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; };
say(j.version === pkg.version, `/health 的 version=${j.version} 与 package.json 的 ${pkg.version} 一致`);
say(j.database.connected === true, `数据库已连接(${j.database.database},pending=${j.database.migrationsPending})`);
say(j.database.migrationsPending === 0, "没有未执行的迁移");
say(j.scenes.available === true, `scenes.available=${j.scenes.available}(false 时 error=${JSON.stringify(j.scenes.error)})`);
process.exit(bad);
' "$WORK_DIR/health.json" "$SERVER_DIR/package.json" || fail "健康检查关键字段不符合预期"
# ---- 7. 产物级实体注册核对 ----
# 用**当前仓库**的检查脚本去核对包内产物:验收标准应当是最新的,而不是
# 「这个包自带的那份检查」(包里那份可能比现在旧,旧规则查不出新增的检查项)。
log "产物级实体注册核对:"
CHECKER="$PROJECT_DIR/scripts/check-bootstrap.mjs"
if [ -f "$CHECKER" ] && ! cmp -s "$CHECKER" "$SERVER_DIR/scripts/check-bootstrap.mjs"; then
cp "$CHECKER" "$SERVER_DIR/scripts/check-bootstrap.mjs"
log " 已用仓库当前的 check-bootstrap.mjs 覆盖包内版本(验收标准以最新为准)"
fi
if ( cd "$SERVER_DIR" && node scripts/check-bootstrap.mjs >"$WORK_DIR/bootstrap.log" 2>&1 ); then
grep -E "^✓" "$WORK_DIR/bootstrap.log" | sed 's/^/ /'
else
fail "实体注册核对失败"
sed 's/^/ /' "$WORK_DIR/bootstrap.log" | tail -20
fi
# ---- 8. 发布包自带的端到端冒烟 ----
log "端到端冒烟(发布包自带的 test/smoke.mjs)…"
if ( cd "$SERVER_DIR" && API_BASE="$API" node test/smoke.mjs >"$WORK_DIR/smoke.log" 2>&1 ); then
grep -E "^== 结果" "$WORK_DIR/smoke.log" | sed 's/^/ /'
grep -c "^ ✓" "$WORK_DIR/smoke.log" | sed 's/^/ 通过项:/'
else
fail "冒烟未全通过"
grep -E "^ ✗|^== 结果" "$WORK_DIR/smoke.log" | sed 's/^/ /'
fi
# ---- 9. 契约快照与三端一致 ----
log "包内契约快照:"
if [ -f "$PACKAGE_ROOT/contracts/VERSION" ]; then
sed 's/^/ /' "$PACKAGE_ROOT/contracts/VERSION"
else
fail "包内缺少 contracts/VERSION"
fi
echo ""
if [ "$FAILED" -ne 0 ]; then
echo "============================================================"
echo " 产物级验收失败(日志目录:$WORK_DIR)"
echo "============================================================"
exit 1
fi
echo "============================================================"
echo " 产物级验收全部通过(日志目录:$WORK_DIR)"
echo "============================================================"