test(packaging): APK 产物版本元数据校验(T-AND-013)+ 修 die 在定义前被调用

文档 §11.2 要求「版本号单一来源 + 可核对」。本仓库原有校验只覆盖**构建配置**
(gradle verifyReleaseVersion 比 versionName ↔ project.yaml)。但版本号真正被
系统与用户看到的地方是 **APK 的 AndroidManifest**:安装界面、应用信息页、
dumpsys package 读的都是它。构建缓存、复用旧 APK、打包时拷错文件都会造成
「配置全对、产物是旧版」——配置层校验抓不到。

这不是假设:同批工作里 Windows 端 v0.2.1 的发布包就是配置文件全一致、
exe 属性页显示 0.1.0(opendance-windows/tasks/017-pe-version.md)。

新增:
  * build/check_apk_version.py:直接解析 APK 内 AndroidManifest.xml(二进制 AXML)
    的 versionName / versionCode。只依赖 Python 标准库,不需要 Android SDK/aapt2。
    AXML 解析:顶层 ResChunk_header → 字符串池(UTF-8 与 UTF-16 都要处理)→
    START_ELEMENT;属性名本身就在字符串池里,按文本匹配即可,无需资源映射表;
  * build/check-apk-version.sh:薄包装,期望版本缺省取 project.yaml,
    并校验 versionCode == 10000*主 + 100*次 + 修订(§11.1);
  * build/verify.sh 步骤 3/3 新增「版本元数据」计分项(原先只 aapt2 打印一行);
  * build/package-release.sh 打包前校验源 APK,不一致即 die。

交叉验证(与 aapt2 dump badging 逐项一致):
  v0.1.0 APK -> versionCode 1 / versionName 0.1.0
  v0.2.0 APK -> versionCode 200 / versionName 0.2.0
  app/build/outputs/apk/release/app-release.apk -> 同 v0.2.0
反向验证(用真实旧产物当负例):
  bash build/check-apk-version.sh dist-release/opendance-android-v0.1.0/app-release.apk
    ✗ versionName 是 '0.1.0',期望 '0.2.0'
    ✗ versionCode 是 1,期望 200
    退出码 1

顺带修复:build/package-release.sh 第 31/33 行用 die 报版本不一致,但 die()
到第 40 行才定义。命中该分支时输出的是
    build/package-release.sh: 行 31: die: 未找到命令   (退出码 127)
脚本确实中止了,但用户完全看不出真实原因。现把 log()/die() 移到首次使用之前:
    [error] 版本号不一致:参数/versionName=9.9.9,project.yaml=0.2.0  (退出码 1)

未新发版本:APK 内容与 v0.2.0 完全一致(产物侧校验证明其 versionName/versionCode
本就正确),本次改动只涉及构建/校验脚本与文档,故不为它提版本号;
README.txt 的新增自检项会随下一次 Android 发布进入发布包。
This commit is contained in:
2026-09-17 04:29:52 +08:00
parent ad18427928
commit 31b6ddd0fa
8 changed files with 407 additions and 5 deletions
+17 -1
View File
@@ -9,7 +9,7 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe
## 状态
**阶段三已完成**: T-AND-001 -> T-AND-012 全部落地(含重连状态机与 MediaPipe 崩溃修复)。
**阶段三已完成**: T-AND-001 -> T-AND-013 全部落地(含重连状态机、MediaPipe 崩溃修复、APK 产物版本元数据校验)。
| 验收项 | 结果 |
|--|--|
@@ -20,6 +20,7 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe
| 包信息 | OK `com.opendance.studio` v0.2.0, minSdk 29(Android 10+)/ targetSdk 34 |
| 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) |
| ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) |
| APK 版本元数据 | OK 直接读产物 `AndroidManifest` 的 `versionName=0.2.0`/`versionCode=200`,与 `project.yaml` 一致(T-AND-013) |
## 快速开始
@@ -255,6 +256,21 @@ app/src/main/java/com/opendance/studio/
(= `project.yaml` 的 `version` = APK 文件名 = Git tag),契约版本在构建期从
`contracts/VERSION` 解析后注入 `BuildConfig`;Kotlin 界面代码里不出现任何版本号字面量。
**产物侧复核(T-AND-013)**:`versionName` 是**构建配置**,而安装界面 / 应用信息页 /
`dumpsys package` 读的是 **APK 的 `AndroidManifest`**。构建缓存、复用旧 APK、
打包时拷错文件都会造成「配置全对、产物是旧版」,配置层校验抓不到(Windows 端
v0.2.1 就踩过同类坑)。因此新增:
```bash
bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk
# -> AndroidManifest versionName = 0.2.0 / versionCode = 200
# -> ✓ APK 版本元数据一致:0.2.0(versionCode 200)
```
`build/check_apk_version.py` 直接解析二进制 AXML,**不需要 Android SDK / aapt2**
(已与 `aapt2 dump badging` 在 v0.1.0 / v0.2.0 两个真实 APK 上交叉验证逐项一致)。
该检查已接入 `build/verify.sh`(步骤 3/3)与 `build/package-release.sh`(打包前 `die`)。
- [`contracts/websocket-keypoints.md`](contracts/websocket-keypoints.md) - 239 字节关键点帧(本端为**发送方**)
- [`contracts/skeleton-topology.md`](contracts/skeleton-topology.md) - MediaPipe 33 点索引与坐标语义
- [`contracts/open-source-credits.md`](contracts/open-source-credits.md) - 关于页必须展示的开源声明
+8
View File
@@ -138,6 +138,14 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
预期:退出码 0(无输出即通过)
bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk
预期:AndroidManifest versionName = <project.yaml 的 version>
AndroidManifest versionCode = 10000*主 + 100*次 + 修订
"APK 版本元数据一致"
说明:安装界面 / 应用信息页读的是 APK 的 AndroidManifest,而不是
build.gradle.kts 的配置。构建缓存或拷错文件会让两者不一致,
所以这里直接读产物(不需要 aapt2,只用 python3 解析二进制 AXML)。
adb install -r app/build/outputs/apk/debug/app-debug.apk
预期:Success
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# ============================================================
# OpenDance Studio —— APK 版本元数据校验(读产物本身)
#
# 文档 §11.2 要求「版本号单一来源 + 可核对」。`app/build.gradle.kts` 里的
# `verifyReleaseVersion` 任务只比**构建配置**(versionName ↔ project.yaml);
# 而版本号真正被系统与用户看到的地方是 **APK 的 AndroidManifest** ——
# 安装界面、应用信息页、`dumpsys package` 读的都是它。
# 构建缓存、复用旧 APK、打包时拷错文件都会让「配置全对、产物是旧版」;
# Windows 端 v0.2.1 就踩过同类的坑(配置文件全一致,exe 属性页显示 0.1.0,
# 见 opendance-windows/tasks/017-pe-version.md)。
#
# 本脚本直接读产物,因此与构建配置无关也能发现漂移。
#
# 用法:
# bash build/check-apk-version.sh <apk> [期望版本 X.Y.Z]
# 期望版本缺省时取 project.yaml 的 version;此时同时校验
# versionCode == 10000*主 + 100*次 + 修订(文档 §11.1)。
# ============================================================
set -uo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
APK="${1:-}"
EXPECTED="${2:-$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)}"
if [ -z "$APK" ]; then
echo "用法:bash build/check-apk-version.sh <apk> [期望版本 X.Y.Z]" >&2
exit 2
fi
if [ ! -f "$APK" ]; then
echo "✗ 找不到文件:$APK" >&2
exit 2
fi
# 解析器只用 Python 标准库读二进制 AXML(不依赖 Android SDK / aapt2)。
# 已用 aapt2 dump badging 在 v0.1.0 与 v0.2.0 两个真实 APK 上交叉验证一致。
PYTHON="${PYTHON_BIN:-}"
if [ -z "$PYTHON" ]; then
for candidate in python3 python; do
if command -v "$candidate" >/dev/null 2>&1; then PYTHON="$candidate"; break; fi
done
fi
if [ -z "$PYTHON" ]; then
echo "✗ 未找到 python3(用于解析 APK 内的二进制 AndroidManifest.xml)" >&2
echo " Debian/Ubuntu: apt-get install -y python3" >&2
exit 2
fi
if [ -n "$EXPECTED" ]; then
"$PYTHON" "$PROJECT_DIR/build/check_apk_version.py" "$APK" --expect-version "$EXPECTED"
else
"$PYTHON" "$PROJECT_DIR/build/check_apk_version.py" "$APK"
fi
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""读取 APK 内 AndroidManifest.xml(二进制 AXML)的 versionName / versionCode。
为什么需要它(T-AND-013):
文档 §11.2 要求「版本号单一来源 + 可核对」。本仓库原有的
`app/build.gradle.kts` 里那个 `verifyReleaseVersion` 任务只比**构建配置**
(`versionName` ↔ `project.yaml`)。但版本号真正呈现给系统与用户的地方是
**APK 的 AndroidManifest** —— 安装界面、应用信息页、`dumpsys package` 读的都是它。
配置全对、产物却是旧版(构建缓存、复用旧 APK、打包时拷错文件)属于同一类
「校验位置少一个,等于没校验」的失效;Windows 端 v0.2.1 就踩过(见该仓库
`tasks/017-pe-version.md`:配置文件全一致,exe 属性页显示 0.1.0)。
因此这里直接读**产物本身**。不依赖 Android SDK:AXML 的属性名就在字符串池里,
按属性名文本匹配即可,无需解析资源映射表,也不需要 aapt2。
用法:
python3 build/check_apk_version.py <apk> [--expect-version X.Y.Z]
[--expect-code N]
[--print-code-for X.Y.Z]
退出码:0 通过 / 1 不一致 / 2 用法或文件问题。
"""
from __future__ import annotations
import struct
import sys
import zipfile
# AXML chunk 类型
RES_STRING_POOL = 0x0001
RES_XML_START_ELEMENT = 0x0102
# Res_value 的 dataType
TYPE_STRING = 0x03
TYPE_INT_DEC = 0x10
TYPE_INT_HEX = 0x11
class AxmlError(Exception):
pass
def _parse_string_pool(data: bytes, offset: int) -> list[str]:
"""解析 ResStringPool:返回字符串列表。支持 UTF-8 与 UTF-16 两种编码。"""
chunk_type, header_size, _chunk_size = struct.unpack_from("<HHI", data, offset)
if chunk_type != RES_STRING_POOL:
raise AxmlError("offset %d 处不是字符串池(type=0x%04x)" % (offset, chunk_type))
string_count, _style_count, flags, strings_start, _styles_start = struct.unpack_from(
"<IIIII", data, offset + 8
)
utf8 = bool(flags & (1 << 8))
base = offset + strings_start
offsets = struct.unpack_from("<%dI" % string_count, data, offset + header_size)
out: list[str] = []
for rel in offsets:
pos = base + rel
if utf8:
# u16 字符数(高位为 1 表示后面还有一字节的字节数)、再 u8 字节数
first = struct.unpack_from("<H", data, pos)[0]
pos += 2
char_len = first
if first & 0x80:
char_len = ((first & 0x7F) << 8) | data[pos]
pos += 1
byte_len = data[pos]
pos += 1
del char_len # 只用字节数读取,避免两处长度不一致时误截断
out.append(data[pos : pos + byte_len].decode("utf-8", "replace"))
else:
first = struct.unpack_from("<H", data, pos)[0]
pos += 2
char_len = first
if first & 0x8000:
char_len = ((first & 0x7FFF) << 16) | struct.unpack_from("<H", data, pos)[0]
pos += 2
out.append(data[pos : pos + char_len * 2].decode("utf-16-le", "replace"))
return out
def read_manifest_versions(apk_path: str) -> tuple[str | None, int | None]:
"""读 <manifest> 标签的 versionName / versionCode。"""
try:
with zipfile.ZipFile(apk_path) as zf:
raw = zf.read("AndroidManifest.xml")
except (OSError, KeyError, zipfile.BadZipFile) as exc:
raise AxmlError("无法读取 %s 里的 AndroidManifest.xml:%s" % (apk_path, exc)) from exc
if len(raw) < 8:
raise AxmlError("AndroidManifest.xml 太短(%d 字节),不是二进制 AXML" % len(raw))
# 顶层 ResChunk_header(type=0x0003 XML),其后依次是字符串池、资源映射、XML 节点
offset = struct.unpack_from("<HHI", raw, 0)[2] # 用 header size 定位第一个子 chunk
node_offset, header_size, _file_size = struct.unpack_from("<HHI", raw, 0)
del node_offset
if offset <= 0 or offset >= len(raw):
offset = header_size if header_size else 8
strings = _parse_string_pool(raw, offset)
pos = offset + struct.unpack_from("<HHI", raw, offset)[2]
while pos + 8 <= len(raw):
chunk_type, _chunk_header_size, chunk_size = struct.unpack_from("<HHI", raw, pos)
if chunk_size == 0:
break
if chunk_type == RES_XML_START_ELEMENT:
# ResXMLTree_node(16) + ResXMLTree_attrExt(20) + attributeCount * 20
(ns_idx, name_idx) = struct.unpack_from("<II", raw, pos + 16)
attr_start, attr_size, attr_count = struct.unpack_from("<HHH", raw, pos + 16 + 8)
tag = strings[name_idx] if name_idx < len(strings) else ""
del ns_idx
if tag == "manifest":
version_name: str | None = None
version_code: int | None = None
attr_base = pos + 16 + attr_start
for i in range(attr_count):
a = attr_base + i * attr_size
a_name_idx, a_raw_idx = struct.unpack_from("<II", raw, a + 4)
v_size, _res0, data_type, data = struct.unpack_from("<HBBI", raw, a + 12)
del v_size, _res0
a_name = strings[a_name_idx] if a_name_idx < len(strings) else ""
if a_name == "versionName":
if data_type == TYPE_STRING and data < len(strings):
version_name = strings[data]
elif a_raw_idx < len(strings):
version_name = strings[a_raw_idx]
elif a_name == "versionCode":
if data_type in (TYPE_INT_DEC, TYPE_INT_HEX):
version_code = data
return version_name, version_code
pos += chunk_size
raise AxmlError("AndroidManifest.xml 里没有找到 <manifest> 标签")
def expected_version_code(version: str) -> int:
"""文档 §11.1:versionCode = 10000 * 主 + 100 * 次 + 修订。"""
major, minor, patch = (int(x) for x in version.split("."))
return 10000 * major + 100 * minor + patch
def main(argv: list[str]) -> int:
if len(argv) < 2:
print(__doc__.strip(), file=sys.stderr)
return 2
apk = argv[1]
expect_version = None
expect_code = None
print_code_for = None
i = 2
while i < len(argv):
arg = argv[i]
if arg == "--expect-version" and i + 1 < len(argv):
expect_version = argv[i + 1]
i += 2
elif arg == "--expect-code" and i + 1 < len(argv):
expect_code = int(argv[i + 1])
i += 2
elif arg == "--print-code-for" and i + 1 < len(argv):
print_code_for = argv[i + 1]
i += 2
else:
print("未知参数:%s" % arg, file=sys.stderr)
return 2
if print_code_for is not None:
print(expected_version_code(print_code_for))
return 0
try:
version_name, version_code = read_manifest_versions(apk)
except AxmlError as exc:
print("✗ %s" % exc, file=sys.stderr)
return 2
print(" APK 产物版本元数据(%s):" % apk)
print(" AndroidManifest versionName = %s" % (version_name if version_name else "(未找到)"))
print(" AndroidManifest versionCode = %s" % (version_code if version_code is not None else "(未找到)"))
if expect_version is None and expect_code is None:
return 0
failed = False
if expect_version is not None:
if version_name != expect_version:
print(
" ✗ APK 的 versionName 是 '%s',期望 '%s'(安装界面与应用信息页会显示错版本)"
% (version_name, expect_version),
file=sys.stderr,
)
failed = True
derived = expected_version_code(expect_version)
if version_code is not None and version_code != derived:
print(
" ✗ APK 的 versionCode 是 %s,期望 %s(§11.1:10000*主 + 100*次 + 修订)"
% (version_code, derived),
file=sys.stderr,
)
failed = True
if expect_code is not None and version_code != expect_code:
print(" ✗ APK 的 versionCode 是 %s,期望 %s" % (version_code, expect_code), file=sys.stderr)
failed = True
if failed:
return 1
print(" ✓ APK 版本元数据一致:%s(versionCode %s)" % (version_name, version_code))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+11 -3
View File
@@ -23,6 +23,11 @@ set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
# 先定义 log/die:下面的版本号校验就会用到 die,函数必须先于调用定义,
# 否则命中的是 "die: 未找到命令"(退出码 127,看不出真实原因)。
log() { printf '\033[1;34m[package]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
GRADLE_VERSION="$(sed -n 's/^[[:space:]]*versionName = "\(.*\)"/\1/p' app/build.gradle.kts | head -1)"
VERSION="${1:-$GRADLE_VERSION}"
@@ -36,11 +41,14 @@ OUT_DIR="$PROJECT_DIR/dist-release"
STAGE="$OUT_DIR/$NAME"
APK="app/build/outputs/apk/release/app-release.apk"
log() { printf '\033[1;34m[package]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
[ -f "$APK" ] || die "未找到 $APK,请先执行 bash build/build-apk.sh release"
# 源 APK 的 AndroidManifest 版本号必须是本次发布版本(文档 §11.2):
# 安装界面 / 应用信息页读的就是它,与 build.gradle.kts 的配置无关。
bash "$PROJECT_DIR/build/check-apk-version.sh" "$APK" "$VERSION" \
|| die "APK 的 versionName/versionCode 不是 v$VERSION;请用 bash build/build-apk.sh release 重新构建"
log "源 APK 版本元数据校验通过(=$VERSION)"
# APK 是 zip,校验魔数
MAGIC=$(head -c 2 "$APK" | od -An -tx1 | tr -d ' \n')
[ "$MAGIC" = "504b" ] || die "$APK 不是合法 APK(魔数 $MAGIC)"
+11 -1
View File
@@ -38,7 +38,7 @@ fi
echo ""
echo "============================================================"
echo " 3/3 产物校验(签名 / 包信息 / 模型资源)"
echo " 3/3 产物校验(签名 / 版本元数据 / 包信息 / 模型资源 / ABI / R8)"
echo "============================================================"
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
# 优先选用与 compileSdk 匹配的 build-tools,缺失时退回最新版
@@ -77,6 +77,16 @@ else
"$BT/aapt2" dump badging "$RELEASE_APK" 2>/dev/null | head -1 | sed 's/^/ /'
# ---- 版本元数据必须落在**产物本身**上(文档 §11.2)----
# gradle 的 verifyReleaseVersion 只比构建配置;APK 的 AndroidManifest 才是
# 安装界面 / 应用信息页读到的版本。构建缓存或拷错文件都只会在这一层暴露。
if bash "$PROJECT_DIR/build/check-apk-version.sh" "$RELEASE_APK"; then
:
else
echo "✗ APK 的 versionName/versionCode 与 project.yaml 不一致"
FAILED=1
fi
if unzip -l "$RELEASE_APK" | grep -q "assets/pose_landmarker_lite.task"; then
echo "✓ MediaPipe 模型已随包分发"
else
+4
View File
@@ -74,6 +74,10 @@ tasks:
file: tasks/012-mediapipe-crash.md
scope: MediaPipe 初始化崩溃修复 + 诊断
status: done
- id: T-AND-013
file: tasks/013-apk-version.md
scope: APK 产物版本元数据(AndroidManifest 的 versionName/versionCode)校验 + 打包脚本 die 未定义修复
status: done
deliverable:
artifact: opendance-android-v0.2.0.apk
+84
View File
@@ -0,0 +1,84 @@
# T-AND-013:APK 产物版本元数据校验(+ 打包脚本 `die` 未定义修复)
- **状态**: done
- **依赖**: T-AND-009(构建)/ T-AND-010(版本号展示)
- **文档**: v1.6 §11.1 / §11.2
- **契约**: 不涉及(打包元数据)
## 一、为什么需要(同类失效的跨端复核)
文档 §11.2 要求「版本号单一来源 + 可核对」。本仓库原有两条校验:
1. `app/build.gradle.kts` 的 `verifyReleaseVersion` 任务:比**构建配置**
(`versionName` ↔ `project.yaml` ↔ 契约快照);
2. `README.md` / `project.yaml` 里的版本文本。
但版本号真正被**系统与用户看到**的地方是 **APK 的 `AndroidManifest`**:
安装界面、「应用信息」、`dumpsys package`、应用商店读的都是它。
构建缓存、复用旧 APK、打包时拷错文件,都会造成「配置全对、产物是旧版」——
配置文件层面的校验抓不到。
这不是假设:同一批工作里,Windows 端 v0.2.1 的发布包就出现过**配置文件全一致、
`OpenDance.exe` 属性页显示 0.1.0**(`export_presets.cfg` 里的版本键没人管),
详见 `opendance-windows/tasks/017-pe-version.md`。因此本端也补一条**读产物本身**的校验。
## 二、实现
| 文件 | 作用 |
|--|--|
| `build/check_apk_version.py`(新增) | 直接解析 APK 内 `AndroidManifest.xml`(二进制 AXML)的 `versionName` / `versionCode`;只依赖 Python 标准库,**不需要 Android SDK / aapt2** |
| `build/check-apk-version.sh`(新增) | 薄包装:找 python3 → 带期望版本时同时校验 `versionCode == 10000*主 + 100*次 + 修订`(§11.1);期望版本缺省取 `project.yaml` |
| `build/package-release.sh` | 打包前校验源 APK 的版本元数据,不一致即 `die` |
| `build/verify.sh` | 步骤 3/3 新增「版本元数据」校验项(原来只 `aapt2 dump badging` 打印一行,不判失败) |
AXML 解析要点:顶层 `ResChunk_header` → 字符串池(UTF-8 / UTF-16 两种编码都要处理)
→ `START_ELEMENT` 节点;`<manifest>` 的属性名(`versionName` / `versionCode`)
本身就在字符串池里,按**属性名文本**匹配即可,无需解析资源映射表。
## 三、验证
与 `aapt2 dump badging` 交叉验证,三个真实 APK 完全一致:
| APK | aapt2 | 本脚本 |
|--|--|--|
| `dist-release/opendance-android-v0.1.0/app-release.apk` | `versionCode='1' versionName='0.1.0'` | versionCode 1 / versionName 0.1.0 ✓ |
| `dist-release/opendance-android-v0.2.0/app-release.apk` | `versionCode='200' versionName='0.2.0'` | versionCode 200 / versionName 0.2.0 ✓ |
| `app/build/outputs/apk/release/app-release.apk` | `versionCode='200' versionName='0.2.0'` | 同上 ✓ |
**反向验证(用真实旧产物当负例)**:
```
$ bash build/check-apk-version.sh dist-release/opendance-android-v0.1.0/app-release.apk
✗ APK 的 versionName 是 '0.1.0',期望 '0.2.0'(安装界面与应用信息页会显示错版本)
✗ APK 的 versionCode 是 1,期望 200(§11.1:10000*主 + 100*次 + 修订)
退出码=1
```
## 四、顺带修复:`package-release.sh` 里 `die` 在定义之前被调用
`build/package-release.sh` 的第 31/33 行用 `die` 报版本不一致,但 `die()` 直到第 40 行
才定义。命中该分支时实际输出的是:
```
$ bash build/package-release.sh 9.9.9
build/package-release.sh: 行 31: die: 未找到命令
退出码=127
```
脚本确实中止了(`set -e` + 127),但用户看到的是 shell 报错,**完全看不出真实原因**。
现已把 `log()` / `die()` 的定义移到首次使用之前,修复后:
```
$ bash build/package-release.sh 9.9.9
[error] 版本号不一致:参数/versionName=9.9.9,project.yaml=0.2.0
退出码=1
```
## 五、验收
| 验收项 | 结果 |
|--|--|
| 能读出 APK 的 `versionName` / `versionCode` | ✅ 与 aapt2 在 3 个真实 APK 上逐项一致 |
| 产物漂移会被拦住 | ✅ 反向验证:v0.1.0 的 APK 对 0.2.0 期望 → 退出码 1,并指出具体不符项 |
| 打包与验证两条链路都接入 | ✅ `package-release.sh`(打包前 `die`)/ `verify.sh`(步骤 3/3 计分) |
| 不新增 SDK 依赖 | ✅ 只用 Python 标准库;无 python3 时给出安装提示 |