test(accept): 发布包级端到端验收(读 zip 内 APK)+ T-AND-014

`build/verify.sh` 验的是 app/build/outputs/ 里**刚构建**的 APK,而用户装的是 zip
里的 app-release.apk。两端都因此翻过车(Web v0.7.4 实体注册、Windows v0.2.1 的
PE 版本元数据),故三端统一:每次发布都要有一条读产物本身的验收(文档 §7.5)。

新增 build/accept-release.sh,对一个 zip(或已解压目录)跑完:
  1) sha256(有 SHA256SUMS 时);
  2) 解压 + 文件清单(apk / README.txt / THIRD_PARTY.md / contracts/VERSION);
  3) APK 的 AndroidManifest versionName/versionCode = 发布版本(复用
     check-apk-version.sh —— 安装界面与应用信息页读的就是它);
  4) ABI 恰为 arm64-v8a + armeabi-v7a、无模拟器 ABI,且两个 ABI 下都有
     MediaPipe native 库(T-AND-012 崩溃根因之一);
  5) assets/pose_landmarker_lite.task 在包内;dex 里仍有
     poselandmarker/PoseLandmarker(R8 没整体裁掉);
  6) 有 apksigner 时顺带验签;
  7) 包内 contracts/VERSION 与仓库逐项一致(抓「APK 用旧契约构建、快照拷了新的」)。
package-release.sh 结尾打印下一步验收命令;README 补用法。

实测(对**已发布**的 v0.2.0 资产重新下载后跑):sha256 OK、版本元数据 0.2.0/200、
ABI 恰为两个真机 ABI、两个 ABI 下都有 MediaPipe native、模型资源在、dex 命中
PoseLandmarker、apksigner 通过、契约快照与仓库一致 → 发布包级验收全部通过。

反向验证(真实负例):
  * 篡改包内 contracts/VERSION 的 data-models=0.6.0 →
    ✗ data-models:包内 0.6.0,仓库 0.7.0,退出码 1;
  * 期望版本写 0.2.1 对 v0.2.0 的包 → ✗ versionName 是 '0.2.0',期望 '0.2.1'
    且 ✗ versionCode 是 200,期望 201。

诚实声明:**装到真机跑 30 分钟**这条脚本替代不了,仍属设备实测项;本脚本能证明的是
「装得上、不会因打包配置而崩」。verify.sh 仍全绿。
This commit is contained in:
2026-09-17 04:51:16 +08:00
parent 31b6ddd0fa
commit c207e7dadd
6 changed files with 278 additions and 0 deletions
+13
View File
@@ -256,6 +256,19 @@ app/src/main/java/com/opendance/studio/
(= `project.yaml` 的 `version` = APK 文件名 = Git tag),契约版本在构建期从
`contracts/VERSION` 解析后注入 `BuildConfig`;Kotlin 界面代码里不出现任何版本号字面量。
**发布包级验收(T-AND-014)**:`build/verify.sh` 验的是本地刚构建的 APK,
而用户装的是 zip 里的 `app-release.apk`。发布前对包再跑一次:
```bash
bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip
# -> sha256 / 文件清单 / AndroidManifest versionName+versionCode / ABI+MediaPipe native
# / 模型资源 / dex 中的 PoseLandmarker / 验签 / 包内契约快照与仓库一致
# -> 发布包级验收全部通过
```
已对**已发布的 v0.2.0 资产**(重新下载)跑通,并用「篡改包内 `contracts/VERSION`」
与「期望版本写错」两个负例反向验证过。
**产物侧复核(T-AND-013)**:`versionName` 是**构建配置**,而安装界面 / 应用信息页 /
`dumpsys package` 读的是 **APK 的 `AndroidManifest`**。构建缓存、复用旧 APK、
打包时拷错文件都会造成「配置全对、产物是旧版」,配置层校验抓不到(Windows 端
+6
View File
@@ -138,6 +138,12 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
预期:退出码 0(无输出即通过)
0) 发布包级验收(对**发布包**而不是本地构建;文档 §7.5)
bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
读包内 APK 的 AndroidManifest 版本元数据、核对 ABI 与 MediaPipe native 库、
模型资源、dex 里的 PoseLandmarker、验签,以及包内契约快照与仓库是否一致。
bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk
预期:AndroidManifest versionName = <project.yaml 的 version>
AndroidManifest versionCode = 10000*主 + 100*次 + 修订
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# ============================================================
# OpenDance Studio Android 辅助 App —— **发布包级端到端验收**(T-AND-014)
#
# 为什么需要它:`build/verify.sh` 验的是 `app/build/outputs/` 里**刚构建**的 APK,
# 而交付给用户的是 zip 里的 `app-release.apk`。两者之间隔着打包与分发;
# 装到手机上的是后者。本项目在另两端都因此翻过车(Web v0.7.4 的实体注册、
# Windows v0.2.1 的 PE 版本元数据),所以三端统一要求:
# **每次发布都要有一条读产物本身的验收**(文档 §7.5)。
#
# 本脚本对一个 zip(或已解压目录)跑完:
# 1. sha256 校验(有 SHA256SUMS 时);
# 2. 解压 + 文件清单(apk / README / THIRD_PARTY / contracts);
# 3. APK 的 **AndroidManifest versionName/versionCode** = 本次发布版本
# (build/check-apk-version.sh;安装界面与应用信息页读的就是它);
# 4. ABI 恰为 arm64-v8a + armeabi-v7a(无模拟器 ABI),且两个 ABI 下都有
# MediaPipe native 库(T-AND-012 的崩溃根因之一);
# 5. MediaPipe 模型资源随包分发、dex 里仍有 PoseLandmarker(R8 没裁掉);
# 6. 有 apksigner 时顺带验签(可选,不影响结论);
# 7. 包内 contracts/VERSION 与仓库 contracts/VERSION 逐项一致
# (抓「APK 用旧契约构建、快照却拷了新的」)。
#
# 用法:
# bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip
# bash build/accept-release.sh <zip 或已解压目录> 0.2.0 # 显式期望版本
#
# 可选环境变量:
# ANDROID_HOME / ANDROID_SDK_ROOT 找 apksigner 用(找不到就跳过验签)
# WORK_DIR 解压目录(默认 mktemp -d)
# ============================================================
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)}"
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 "用法: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 文件用绝对路径
TARGET_DIR_ABS="$(cd "$(dirname "$TARGET")" && pwd)"
SUMS="$TARGET_DIR_ABS/SHA256SUMS"
if [ -f "$SUMS" ]; then
log "sha256 校验($SUMS):"
( cd "$TARGET_DIR_ABS" && sha256sum -c "$SUMS" --ignore-missing ) || fail "sha256 校验失败"
else
log "未找到 $SUMS,跳过 sha256 校验"
fi
log "解压…"
unzip -qq -o "$TARGET" -d "$WORK_DIR" || { fail "解压失败"; exit 1; }
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 app-release.apk README.txt THIRD_PARTY.md contracts/VERSION; do
if [ -f "$PACKAGE_DIR/$required" ]; then
printf ' ✓ %s\n' "$required"
else
fail "缺少 $required"
fi
done
APK="$PACKAGE_DIR/app-release.apk"
if [ ! -f "$APK" ]; then
echo "(没有 APK,后续检查无法进行)" >&2
exit 1
fi
# ---- 3. APK 版本元数据 ----
log "APK 版本元数据(AndroidManifest):"
bash "$PROJECT_DIR/build/check-apk-version.sh" "$APK" "$EXPECTED" \
|| fail "APK 的 versionName/versionCode 不是 v$EXPECTED"
# ---- 4. ABI 与 MediaPipe native 库 ----
log "ABI 与 native 库:"
ABIS="$(unzip -l "$APK" | grep -oE 'lib/[^/]+/' | sed 's#lib/##;s#/##' | sort -u)"
ABIS_ONE_LINE="$(echo "$ABIS" | tr '\n' ' ')"
echo " 包含 ABI:$ABIS_ONE_LINE"
if [ "$ABIS" = "$(printf 'arm64-v8a\narmeabi-v7a')" ]; then
echo " ✓ ABI 恰为 arm64-v8a + armeabi-v7a(真机 ABI)"
else
fail "ABI 与预期不符(预期 arm64-v8a / armeabi-v7a)"
fi
if echo "$ABIS" | grep -qE "x86"; then
fail "release APK 含模拟器 ABI(包体翻倍且与 abiFilters 不符)"
fi
for abi in arm64-v8a armeabi-v7a; do
if unzip -l "$APK" | grep -q "lib/$abi/.*mediapipe"; then
echo " ✓ lib/$abi 下有 MediaPipe native 库"
else
fail "lib/$abi 下缺少 MediaPipe native 库(真机安装后必然初始化失败)"
fi
done
# ---- 5. 模型资源与 R8 保留 ----
log "MediaPipe 模型与 R8 保留:"
if unzip -l "$APK" | grep -q "assets/pose_landmarker_lite.task"; then
echo " ✓ MediaPipe 模型资源随包分发(assets/pose_landmarker_lite.task)"
else
fail "APK 缺少 MediaPipe 模型资源"
fi
DEX_HIT=0
for entry in $(unzip -Z1 "$APK" | grep -E '^classes[0-9]*\.dex$'); do
if unzip -p "$APK" "$entry" | grep -qa "poselandmarker/PoseLandmarker"; then
DEX_HIT=$((DEX_HIT + 1))
fi
done
if [ "$DEX_HIT" -gt 0 ]; then
echo " ✓ dex 中存在 poselandmarker/PoseLandmarker(R8 未整体裁掉 MediaPipe;命中 $DEX_HIT 个 dex)"
else
fail "dex 里找不到 PoseLandmarker(R8 把 MediaPipe 裁掉了,release 必崩)"
fi
# ---- 6. 验签(可选)----
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
APKSIGNER="$(ls -d "$SDK"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1)"
if [ -n "$APKSIGNER" ]; then
log "签名校验($APKSIGNER):"
if "$APKSIGNER" verify "$APK" >"$WORK_DIR/apksigner.txt" 2>&1; then
echo " ✓ apksigner 验签通过(退出码 0)"
else
fail "apksigner 验签失败"
sed 's/^/ /' "$WORK_DIR/apksigner.txt" | tail -5
fi
else
log "未找到 apksigner,跳过验签(不影响结论)"
fi
# ---- 7. 契约快照与仓库一致 ----
log "包内契约快照:"
snapshot_ok=1
if [ -f "$PACKAGE_DIR/contracts/VERSION" ]; then
sed 's/^/ /' "$PACKAGE_DIR/contracts/VERSION"
# 与仓库当前的 contracts/VERSION 逐项比对:抓「APK 用旧契约构建、快照拷了新的」
while IFS= read -r line; do
case "$line" in ''|'#'*) continue ;; esac
key="${line%%:*}"; value="${line#*:}"
key="$(printf '%s' "$key" | tr -d '[:space:]')"
value="$(printf '%s' "$value" | tr -d '[:space:]')"
repo_value="$(sed -n "s/^${key}:[[:space:]]*//p" contracts/VERSION | head -1)"
if [ -z "$repo_value" ]; then
fail "仓库 contracts/VERSION 缺少 $key"
snapshot_ok=0
elif [ "$repo_value" != "$value" ]; then
fail "$key:包内 $value,仓库 $repo_value"
snapshot_ok=0
fi
done <"$PACKAGE_DIR/contracts/VERSION"
[ "$snapshot_ok" = 1 ] && echo " ✓ 包内契约快照与仓库 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 "============================================================"
+3
View File
@@ -98,3 +98,6 @@ echo ""
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip"
echo " 再 adb install -r $NAME/app-release.apk(Android 10+)"
echo ""
echo "下一步(文档 §7.5:每次发布都要有一条读产物本身的验收):"
echo " bash build/accept-release.sh $ZIP"
+4
View File
@@ -78,6 +78,10 @@ tasks:
file: tasks/013-apk-version.md
scope: APK 产物版本元数据(AndroidManifest 的 versionName/versionCode)校验 + 打包脚本 die 未定义修复
status: done
- id: T-AND-014
file: tasks/014-release-acceptance.md
scope: 发布包级端到端验收(读 zip 内 APK:版本元数据 / ABI+MediaPipe native / 模型资源 / R8 / 契约快照)
status: done
deliverable:
artifact: opendance-android-v0.2.0.apk
+63
View File
@@ -0,0 +1,63 @@
# T-AND-014:发布包级端到端验收(读 zip 内的 APK)
- **状态**: done
- **依赖**: T-AND-013(APK 版本元数据)/ T-AND-009(打包)
- **文档**: v1.6 §6.3 / §7.5 / §11
- **契约**: 不涉及(验收流程)
## 为什么需要
`build/verify.sh` 验的是 `app/build/outputs/` 里**刚构建**的 APK,而用户装的是
zip 里的 `app-release.apk`。两者之间隔着打包与分发;本项目另两端都因此翻过车
(Web v0.7.4 的实体注册、Windows v0.2.1 的 PE 版本元数据),所以三端统一要求:
**每次发布都要有一条读产物本身的验收**(文档 §7.5)。
## 实现:`build/accept-release.sh`
对一个 zip(或已解压目录)跑完:
| 步骤 | 检查 |
|--|--|
| 1 | sha256 校验(有 `SHA256SUMS` 时) |
| 2 | 解压 + 文件清单(`app-release.apk` / `README.txt` / `THIRD_PARTY.md` / `contracts/VERSION`) |
| 3 | **APK 的 `AndroidManifest` `versionName`/`versionCode`** = 发布版本(复用 `build/check-apk-version.sh`;安装界面与应用信息页读的就是它) |
| 4 | ABI 恰为 `arm64-v8a` + `armeabi-v7a`(无模拟器 ABI),且**两个 ABI 下都有 MediaPipe native 库**(T-AND-012 崩溃根因之一) |
| 5 | `assets/pose_landmarker_lite.task` 随包分发;**dex 里仍有 `poselandmarker/PoseLandmarker`**(R8 没整体裁掉) |
| 6 | 有 `apksigner` 时顺带验签(可选,不影响结论) |
| 7 | 包内 `contracts/VERSION` 与**仓库** `contracts/VERSION` 逐项一致(抓「APK 用旧契约构建、快照拷了新的」) |
## 实测(对**已发布**的 v0.2.0 资产,重新下载后跑)
```
[accept] sha256 校验:opendance-android-v0.2.0.zip: OK
✓ app-release.apk / README.txt / THIRD_PARTY.md / contracts/VERSION
✓ APK 版本元数据一致:0.2.0(versionCode 200)
✓ ABI 恰为 arm64-v8a + armeabi-v7a(真机 ABI)
✓ lib/arm64-v8a 下有 MediaPipe native 库
✓ lib/armeabi-v7a 下有 MediaPipe native 库
✓ MediaPipe 模型资源随包分发(assets/pose_landmarker_lite.task)
✓ dex 中存在 poselandmarker/PoseLandmarker(R8 未整体裁掉 MediaPipe;命中 1 个 dex)
✓ apksigner 验签通过(退出码 0)
✓ 包内契约快照与仓库 contracts/VERSION 一致
发布包级验收全部通过
```
## 反向验证
| 负例 | 结果 |
|--|--|
| 把包内 `contracts/VERSION` 的 `data-models` 改成 `0.6.0` | ✗ `data-models:包内 0.6.0,仓库 0.7.0`,退出码 1 |
| 期望版本写 `0.2.1` 对 v0.2.0 的包 | ✗ `versionName 是 '0.2.0',期望 '0.2.1'` + `versionCode 是 200,期望 201` |
## 仍未覆盖(诚实声明)
**装到真机跑 30 分钟**这条只能真机做,脚本无法替代。本脚本能证明的是:
包能验签安装(ABI 与 native 库齐备、模型资源在、R8 没裁掉类、版本元数据正确),
即「安装后不会因打包配置而崩」;运行时稳定性仍需设备实测。
## 用法
```bash
bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip
# 或对已解压目录:bash build/accept-release.sh dist-release/opendance-android-v0.2.0
```