From c207e7daddba49e0122325e9bcba719307c3713c Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Thu, 17 Sep 2026 04:51:16 +0800 Subject: [PATCH] =?UTF-8?q?test(accept):=20=E5=8F=91=E5=B8=83=E5=8C=85?= =?UTF-8?q?=E7=BA=A7=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=AA=8C=E6=94=B6=EF=BC=88?= =?UTF-8?q?=E8=AF=BB=20zip=20=E5=86=85=20APK=EF=BC=89+=20T-AND-014?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 仍全绿。 --- README.md | 13 +++ README.txt | 6 + build/accept-release.sh | 189 ++++++++++++++++++++++++++++++++ build/package-release.sh | 3 + project.yaml | 4 + tasks/014-release-acceptance.md | 63 +++++++++++ 6 files changed, 278 insertions(+) create mode 100755 build/accept-release.sh create mode 100644 tasks/014-release-acceptance.md diff --git a/README.md b/README.md index 5bb8178..f9c44e7 100644 --- a/README.md +++ b/README.md @@ -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 端 diff --git a/README.txt b/README.txt index f88dfaf..b75850f 100644 --- a/README.txt +++ b/README.txt @@ -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 = AndroidManifest versionCode = 10000*主 + 100*次 + 修订 diff --git a/build/accept-release.sh b/build/accept-release.sh new file mode 100755 index 0000000..3e4b7f6 --- /dev/null +++ b/build/accept-release.sh @@ -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 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 "============================================================" diff --git a/build/package-release.sh b/build/package-release.sh index 1aebf33..050bee2 100755 --- a/build/package-release.sh +++ b/build/package-release.sh @@ -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" diff --git a/project.yaml b/project.yaml index ace3267..1ddbb96 100644 --- a/project.yaml +++ b/project.yaml @@ -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 diff --git a/tasks/014-release-acceptance.md b/tasks/014-release-acceptance.md new file mode 100644 index 0000000..8850b71 --- /dev/null +++ b/tasks/014-release-acceptance.md @@ -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 +```