test(accept): 发布包级端到端验收(读包内 pck 而非源码)+ T-WIN-018

`build/verify.sh` 跑的是仓库源码(`godot --path .`),交付给用户的是 zip 里的
OpenDance.pck + OpenDance.exe,差别全在「导出」这一步:导出预设漏资源、pck 没
重新生成、版本号没同步、契约快照与 pck 不一致 —— 都只在包里才暴露。
本仓 v0.2.1 的 PE 版本元数据就是这么翻的车。

新增:
  * build/probe-release.gd —— 包内运行时探针。用
    `godot --headless --main-pack <pck> --script <探针>` 只读包内资源,断言并
    quit(0/1):
      1) application/config/version == 期望版本(界面版本唯一来源,§11.1);
      2) AboutScreen.app_version() == 期望版本(「关于」清单真正显示的值);
      3) 主场景可加载、四个 autoload 都在场景树里;
      4) 15 个关键资源(UI/模型/场景/网络/评分)在包内都可加载;
      5) 包内 Credits.CONTRACT_VERSIONS 与包内 contracts/VERSION 快照逐项一致
         (抓「pck 用旧契约构建、快照却拷了新的」)。
  * build/accept-release.sh —— 一个 zip(或已解压目录)跑完:sha256 → 解压
    (deflate 用 unzip / LZMA 用 7z,并检查没有 .partN 残留)→ 文件清单 →
    check-pe-version.sh → 包内 pck 启动 180 帧无 SCRIPT ERROR → 探针。
  * package-release.sh 结尾打印下一步验收命令;README 补用法。

实测(对**已发布**的 v0.2.2 资产重新下载后跑):
  sha256 OK;PE 版本资源 0.2.2;包内 pck 启动 180 帧无错误;
  包内 version=0.2.2;AboutScreen.app_version()=0.2.2;主场景
  res://scenes/main.tscn;autoload 齐备;15 项关键资源可加载;
  pck 内契约表与包内 contracts/VERSION 一致 → 发布包级验收全部通过。

反向验证:
  * 篡改包内 contracts/VERSION 的 rest-api=0.4.0 →
    ✗ ["rest-api: pck=0.5.0 快照=0.4.0"],退出码 1;
  * 期望版本写 0.2.1 对 v0.2.2 的包 → 三处版本断言全失败,退出码 1。

顺带修掉脚本自身一个缺陷:SHA256SUMS 用相对路径 + cd 到包目录后被二次拼接成
dist-release/dist-release/SHA256SUMS,导致 sha256 校验假失败(其余检查仍全绿,
很容易被忽略)。首跑时正是它把「全部通过」变成了失败。

verify.sh 仍全绿(754 通过 / 0 失败 / 5 跳过)。
This commit is contained in:
2026-09-17 04:50:40 +08:00
parent ba988f6ca6
commit e4e534f2a2
8 changed files with 442 additions and 1 deletions
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# ============================================================
# OpenDance Studio Windows 客户端 —— **发布包级端到端验收**(T-WIN-018)
#
# 为什么需要它:`build/verify.sh` 跑的是**仓库源码**(`godot --path .`),
# 而交付给用户的是 zip 里的 `OpenDance.pck` + `OpenDance.exe`。
# 差别全在「导出」这一步 —— 导出预设漏资源、pck 没重新生成、版本号没同步、
# 契约快照没更新,都只在包里才暴露。本项目已经因此翻过一次车:
# v0.2.1 的包解开后 exe 属性页显示 0.1.0(配置文件却全一致)。
#
# 本脚本对一个 zip(或已解压目录)跑完:
# 1. sha256 校验(有 SHA256SUMS 时);
# 2. 解压 + 文件清单完整性(exe / pck / README / THIRD_PARTY / contracts);
# 3. exe 的 **PE 版本资源** = 本次发布版本(build/check-pe-version.sh);
# 4. 用 Godot 直接加载**包内 pck** 跑 180 帧,断言无 SCRIPT ERROR;
# 5. 包内运行时探针(build/probe-release.gd):版本号、AboutScreen 显示值、
# 主场景、autoload、关键资源可加载、**pck 内契约表与包内 contracts/VERSION 一致**。
#
# 需要与本项目同版本的 Godot 4.7.x(导出模板要求的版本)。
#
# 用法:
# GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2.zip
# GODOT_BIN=/path/to/godot bash build/accept-release.sh dist-release/opendance-windows-v0.2.2
# GODOT_BIN=... bash build/accept-release.sh <zip> 0.2.2 # 显式指定期望版本
#
# 可选环境变量:
# WORK_DIR 解压目录(默认 mktemp -d)
# BOOT_FRAMES 启动检查帧数(默认 180)
# ============================================================
set -uo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
TARGET="${1:-}"
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
EXPECTED="${2:-$YAML_VERSION}"
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
BOOT_FRAMES="${BOOT_FRAMES:-180}"
FAILED=0
log() { printf '\033[1;34m[accept]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[accept] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
echo "用法:GODOT_BIN=/path/to/godot bash build/accept-release.sh <发布包 zip 或已解压目录> [期望版本]" >&2
exit 2
fi
if [ -z "$EXPECTED" ]; then
echo "✗ 无法确定期望版本(project.yaml 里没有 version,也没有显式传入)" >&2
exit 2
fi
log "目标:$TARGET"
log "期望版本:$EXPECTED"
log "工作目录:$WORK_DIR"
mkdir -p "$WORK_DIR"
# ---- 1. sha256 + 解压 ----
if [ -f "$TARGET" ]; then
# SHA256SUMS 里是**裸文件名**,所以校验时要把 cwd 切到包所在目录;
# 但 sums 文件本身必须用绝对路径 —— 否则 cd 之后相对路径会被二次拼接成
# dist-release/dist-release/SHA256SUMS(这个 bug 在本脚本首跑时就命中了)。
TARGET_DIR_ABS="$(cd "$(dirname "$TARGET")" && pwd)"
SUMS="$TARGET_DIR_ABS/SHA256SUMS"
if [ -f "$SUMS" ]; then
log "sha256 校验($SUMS):"
if ( cd "$TARGET_DIR_ABS" && sha256sum -c "$SUMS" --ignore-missing ); then
:
else
fail "sha256 校验失败"
fi
else
log "未找到 $SUMS,跳过 sha256 校验"
fi
log "解压…"
case "$TARGET" in
*.zip)
if unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then
log " 用 unzip 解压成功(deflate 包)"
elif command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null; then
log " 用 7z 解压成功(LZMA 包 —— 注意 Windows 10 资源管理器不支持它)"
else
fail "解压失败(既不是可 unzip 的 deflate 包,也没有 7z 处理 LZMA)"
exit 1
fi
;;
*)
fail "只支持 .zip 或已解压目录"
exit 2
;;
esac
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
else
PACKAGE_DIR="$TARGET"
fi
[ -n "$PACKAGE_DIR" ] && [ -d "$PACKAGE_DIR" ] || { fail "找不到解压后的包目录"; exit 1; }
log "包目录:$PACKAGE_DIR"
# ---- 2. 文件清单 ----
log "文件清单:"
for required in OpenDance.exe OpenDance.pck README.txt THIRD_PARTY.md contracts/VERSION; do
if [ -f "$PACKAGE_DIR/$required" ]; then
printf ' ✓ %s\n' "$required"
else
fail "缺少 $required"
fi
done
# exe 与 pck 必须同名同目录,否则 Godot 找不到资源
if [ "$(basename "$PACKAGE_DIR/OpenDance.exe" .exe)" != "$(basename "$PACKAGE_DIR/OpenDance.pck" .pck)" ]; then
fail "exe 与 pck 主文件名不一致"
fi
# 不允许出现历史分卷残留
if find "$PACKAGE_DIR" -maxdepth 1 -name "*.part[0-9]*" | grep -q .; then
fail "包内出现 .partN 分卷残留(发布规则要求单个完整包)"
fi
# ---- 3. exe 的 PE 版本资源 ----
log "产物 exe 的 PE 版本资源:"
if [ -f "$PACKAGE_DIR/OpenDance.exe" ]; then
bash "$PROJECT_DIR/build/check-pe-version.sh" "$PACKAGE_DIR/OpenDance.exe" "$EXPECTED" \
|| fail "exe 的 PE 版本资源不是 v$EXPECTED(Windows 属性页会显示错版本)"
fi
# ---- 4/5. 用 Godot 加载包内 pck ----
GODOT_BIN="${GODOT_BIN:-}"
if [ -z "$GODOT_BIN" ]; then
for candidate in godot godot4 Godot; do
if command -v "$candidate" >/dev/null 2>&1; then GODOT_BIN="$candidate"; break; fi
done
fi
if [ -z "$GODOT_BIN" ]; then
fail "未找到 Godot(本端验收需要与本项目同版本的 Godot 4.7.x;用 GODOT_BIN=<路径> 指定)"
echo "(其余检查已完成,见上)" >&2
exit 1
fi
log "Godot:$GODOT_BIN($("$GODOT_BIN" --version 2>/dev/null | head -1))"
PCK="$PACKAGE_DIR/OpenDance.pck"
if [ -f "$PCK" ]; then
log "包内 pck 启动检查($BOOT_FRAMES 帧,无头)…"
BOOT_LOG="$WORK_DIR/pck-boot.log"
timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" --quit-after "$BOOT_FRAMES" >"$BOOT_LOG" 2>&1
BOOT_STATUS=$?
if [ "$BOOT_STATUS" -ne 0 ]; then
fail "包内 pck 启动退出码 $BOOT_STATUS"
tail -20 "$BOOT_LOG" | sed 's/^/ /'
elif grep -qE "SCRIPT ERROR|Parse Error|Compile Error" "$BOOT_LOG"; then
fail "包内 pck 启动出现脚本错误"
grep -E "SCRIPT ERROR|Parse Error|Compile Error" "$BOOT_LOG" | head -10 | sed 's/^/ /'
else
echo " ✓ 包内 pck 启动 $BOOT_FRAMES 帧无错误(退出码 0)"
fi
log "包内运行时探针(版本号 / 界面显示值 / 契约表 / 资源可加载)…"
PROBE="$PROJECT_DIR/build/probe-release.gd"
PROBE_LOG="$WORK_DIR/probe.log"
# 探针自己断言并 quit(0/1),因此以**退出码**为准;日志单独落盘再打印,
# 避免把「grep 的退出码」误当成「探针的退出码」。
if timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" --script "$PROBE" \
-- --expect-version "$EXPECTED" --snapshot "$PACKAGE_DIR/contracts/VERSION" \
>"$PROBE_LOG" 2>&1; then
PROBE_OK=1
else
PROBE_OK=0
fi
grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /'
[ "$PROBE_OK" = 1 ] || fail "包内运行时探针未通过"
fi
echo ""
if [ "$FAILED" -ne 0 ]; then
echo "============================================================"
echo " 发布包级验收失败(工作目录:$WORK_DIR)"
echo "============================================================"
exit 1
fi
echo "============================================================"
echo " 发布包级验收全部通过(工作目录:$WORK_DIR)"
echo "============================================================"
+3
View File
@@ -178,3 +178,6 @@ fi
echo ""
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
echo ""
echo "下一步(文档 §7.5:每次发布都要有一条读产物本身的验收):"
echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP"
+151
View File
@@ -0,0 +1,151 @@
extends SceneTree
## 发布产物验收探针(T-WIN-018,由 build/accept-release.sh 调用)
##
## 用法:
## godot --headless --main-pack <包内 OpenDance.pck> \
## --script /绝对路径/build/probe-release.gd \
## -- --expect-version 0.2.2 --snapshot /绝对路径/contracts/VERSION
##
## 为什么需要它:`build/verify.sh` 验证的是**仓库源码**(`godot --path .`),
## 而交付给用户的是 zip 里的 `OpenDance.pck`。差别全在「导出」这一步:
## 导出预设漏了资源、pck 没重新生成、版本号没同步、契约快照没更新……
## 都只在包里才暴露。本探针**只读包内资源**(`--main-pack` + `res://`),
## 所以它给出的版本号、契约表、autoload、主场景就是用户装到机器上的那一份。
##
## 断言(任一不满足即 quit(1)):
## 1. ProjectSettings 的 application/config/version == 期望版本
## (界面上「关于」清单与 .pck 都源自它 —— 文档 §11.1);
## 2. AboutScreen.app_version() == 期望版本(界面真正会显示的值);
## 3. 主场景非空且可加载,四个 autoload 都在场景树里;
## 4. 关键资源(UI / 模型 / 场景 / 网络 / 评分)在包内都可加载;
## 5. 包内 Credits.CONTRACT_VERSIONS 与**包内** contracts/VERSION 快照逐项一致
## —— 抓「pck 用旧契约构建、快照却拷了新的」这类漂移。
const KEY_RESOURCES := [
"res://src/ui/credits.gd",
"res://src/ui/about_screen.gd",
"res://src/ui/main.gd",
"res://src/ui/main_menu.gd",
"res://src/ui/scene_panel.gd",
"res://src/ui/source_panel.gd",
"res://src/model/humanoid_map.gd",
"res://src/model/vrm_loader.gd",
"res://src/scene/scene_runtime.gd",
"res://src/net/api_client.gd",
"res://src/net/catalog_types.gd",
"res://src/net/pose_server.gd",
"res://src/net/keypoint_frame.gd",
"res://src/score/dtw.gd",
"res://src/score/scoring.gd",
]
const EXPECTED_AUTOLOADS := ["AppConfig", "CatalogStore", "PoseReceiver", "ScoreService"]
var _failed := 0
func _check(ok: bool, text: String) -> void:
print(" %s %s" % ["✓" if ok else "✗", text])
if not ok:
_failed += 1
## 解析 contracts/VERSION(`key: value`,`#` 注释)
func _read_snapshot(path: String) -> Dictionary:
var out := {}
if path.is_empty() or not FileAccess.file_exists(path):
return out
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return out
while not file.eof_reached():
var line := file.get_line().strip_edges()
if line.is_empty() or line.begins_with("#"):
continue
var idx := line.find(":")
if idx > 0:
out[line.substr(0, idx).strip_edges()] = line.substr(idx + 1).strip_edges()
file.close()
return out
func _initialize() -> void:
var expect_version := ""
var snapshot_path := ""
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
if args[i] == "--expect-version" and i + 1 < args.size():
expect_version = args[i + 1]
i += 2
elif args[i] == "--snapshot" and i + 1 < args.size():
snapshot_path = args[i + 1]
i += 2
else:
i += 1
print("[accept] 包内运行时探针(--main-pack OpenDance.pck)")
var project_version := str(ProjectSettings.get_setting("application/config/version", ""))
print(" engine=%s project=%s main_scene=%s" % [
str(Engine.get_version_info().get("string", "?")),
project_version,
str(ProjectSettings.get_setting("application/run/main_scene", "")),
])
var about = load("res://src/ui/about_screen.gd")
var about_version := str(about.app_version()) if about != null else ""
var credits = load("res://src/ui/credits.gd")
var contract_versions: Dictionary = credits.CONTRACT_VERSIONS if credits != null else {}
if not expect_version.is_empty():
_check(project_version == expect_version,
"包内 application/config/version = %s(期望 %s)" % [project_version, expect_version])
_check(about_version == expect_version,
"AboutScreen.app_version() = %s(「关于」清单会显示这个值)" % about_version)
else:
print(" · 未指定 --expect-version,跳过版本比对(实际 %s)" % project_version)
var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", ""))
_check(not main_scene.is_empty() and load(main_scene) != null, "主场景可加载:%s" % main_scene)
var autoloads := []
for child in root.get_children():
autoloads.append(str(child.name))
var missing := []
for name in EXPECTED_AUTOLOADS:
if not autoloads.has(name):
missing.append(name)
_check(missing.is_empty(), "autoload 齐备 %s(实际 %s)" % [str(EXPECTED_AUTOLOADS), str(autoloads)])
var unloadable := []
for path in KEY_RESOURCES:
if load(path) == null:
unloadable.append(path)
_check(unloadable.is_empty(), "关键资源全部可加载(%d 项)%s" % [
KEY_RESOURCES.size(), "" if unloadable.is_empty() else " 缺失:" + str(unloadable)])
var snapshot := _read_snapshot(snapshot_path)
if snapshot.is_empty():
print(" · 未提供 contracts/VERSION 快照(%s),跳过契约比对" % snapshot_path)
else:
print(" [contracts] 包内 Credits = ", JSON.stringify(contract_versions))
print(" [contracts] 包内快照文件 = ", JSON.stringify(snapshot))
var mismatch := []
for key in snapshot.keys():
if not contract_versions.has(key):
mismatch.append("%s 缺失" % key)
elif str(contract_versions[key]) != str(snapshot[key]):
mismatch.append("%s: pck=%s 快照=%s" % [key, contract_versions[key], snapshot[key]])
for key in contract_versions.keys():
if not snapshot.has(key):
mismatch.append("%s 只存在于 pck" % key)
_check(mismatch.is_empty(), "pck 内契约表与包内 contracts/VERSION 快照一致%s" % [
"" if mismatch.is_empty() else ":" + str(mismatch)])
if _failed > 0:
print("[accept] ✗ 探针发现 %d 项不符" % _failed)
quit(1)
return
print("[accept] ✓ 包内运行时探针全部通过")
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://bb1l1hl8fpavq