#!/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" # ---- 0. 逐文件核对:发布包 == 打标签的树(T-AND-018)---- CHECKER="$PROJECT_DIR/build/check_tracked_release.py" if [ -f "$CHECKER" ] && command -v git >/dev/null 2>&1 && [ -f "$TARGET" ]; then TAG="v$(basename "$TARGET" .zip | sed 's/^opendance-android-v//')" if git -C "$PROJECT_DIR" rev-parse --verify "${TAG}^{commit}" >/dev/null 2>&1; then log "逐文件核对:发布包 == tag $TAG" "${PYTHON_BIN:-python3}" "$CHECKER" "$TARGET" --tag "$TAG" || fail "发布包与 tag $TAG 不一致" else log "跳过逐文件核对(仓库里没有 tag $TAG)" fi else log "跳过逐文件核对(缺 checker / git / 目标是已解压目录)" fi # ---- 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 # ---- 4b. native 库体检(T-AND-015:ABI / ELF 类型 / 依赖闭包 / 16 KB 页对齐)---- log "native 库体检(ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐):" NPY="${PYTHON_BIN:-}" if [ -z "$NPY" ]; then for candidate in python3 python; do command -v "$candidate" >/dev/null 2>&1 && { NPY="$candidate"; break; }; done fi if [ -n "$NPY" ]; then "$NPY" "$PROJECT_DIR/build/check_native_libs.py" "$APK" \ --expected-abis "$(echo "$ABIS" | tr '\n' ',')" || fail "native 库体检未通过" else fail "未找到 python3,无法做 native 库体检(apt-get install -y python3)" fi # 16 KB 页对齐的 zip 层检查(Android 15 起部分设备页 16 KB) SDK_FOR_ZIPALIGN="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" ZIPALIGN="$(ls -d "$SDK_FOR_ZIPALIGN"/build-tools/*/zipalign 2>/dev/null | sort -V | tail -1)" if [ -n "$ZIPALIGN" ]; then if "$ZIPALIGN" -c -P 16 -v 4 "$APK" >"$WORK_DIR/zipalign.txt" 2>&1; then echo " ✓ zipalign -c -P 16 -v 4 通过(4 字节对齐 + 16 KB 页对齐)" else fail "zipalign -P 16 校验失败(见 $WORK_DIR/zipalign.txt)" fi else log "未找到 zipalign,跳过 zip 对齐检查(不影响结论)" fi # ---- 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 "============================================================"