test(release): 逐文件核对「发布包 == 打标签的树」(T-AND-018)

新增 build/check_tracked_release.py(纯标准库):对包内每个文件按路径决定比较基准 ——
app-release.apk 比工作树构建结果,其余比 git show <tag>:<路径>;内容不一致 / 多余 /
构建产物缺失都会列出并退出 1。已接入 accept-release.sh(第 0 步)与 package-release.sh 提示。

实测(已发布的 v0.2.2 资产重新下载后):
  包内 12 个文件:源码/文档 11(与 tag v0.2.2 比)+ 构建产物 1(APK 比工作树构建)
  ✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失
同行产品级验收(版本元数据 0.2.2/202、native 体检、契约快照、验签)同时全绿。

踩坑:第一版把 BUILD_OUTPUTS 的键一律当目录前缀,而 APK 是单文件,拼出
app-release.apk/ 导致「工作树缺失」误报;现区分目录前缀与精确文件名。
This commit is contained in:
2026-09-18 03:10:35 +08:00
parent c0d0937023
commit 0ee95c46e0
5 changed files with 243 additions and 1 deletions
+14
View File
@@ -56,6 +56,20 @@ 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 文件用绝对路径
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""发布产物 × 打标签的树:**逐文件**核对(T-WEB-018)
## 为什么需要
「发布包里的东西必须等于打标签的那棵树」这条规则此前是靠人手工抽查几个文件
(README、contracts/VERSION…)来确认的 —— 而抽查必然会漏。本轮实测就发现
两个认知错误:
* Web 的发布包**包含** `web-server/test/**`(33 个文件)而我此前以为不含,
于是 `test/**` 的改动是否影响产物从来没被检查过;
* 发布包里另有构建产物(`app-release.apk`)与 git 无关,
必须与工作树的构建结果比 —— 只比源码文件是不够的。
本脚本把这条规则变成**可重复的全量核对**:
对发布包里的**每一个**文件,按路径决定比较基准:
| 路径 | 比较基准 |
|--|--|
| 构建产物(见 `BUILD_OUTPUTS`) | 工作树里对应的构建输出 |
| 其余文件 | `git show <tag>:<路径>`(打标签的树) |
任何一处不一致 / 包内多余(既不在 git 也不在构建输出)/ 缺失都会列出并以非 0 退出。
## 用法(在仓库根目录执行)
python3 <script> <发布包> --tag v0.7.5 [--json]
Android 的包是 `.zip`。包内通常有**一层顶层目录**
(`opendance-android-v0.2.2/`),脚本会自动剥掉。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tarfile
import zipfile
# ---------------------------------------------------------------- 仓库相关配置
# 各仓库按自己的产物结构改这一段即可;脚本其余部分与仓库无关。
# 包内路径前缀 → 工作树路径(构建产物:不在 git 里,与工作树的构建结果比)
BUILD_OUTPUTS: dict[str, str] = {
'app-release.apk': 'app/build/outputs/apk/release/app-release.apk', # Gradle 构建产物
}
def read_archive(path: str) -> dict[str, bytes]:
"""读出包内全部文件(剥掉顶层目录)。"""
files: dict[str, bytes] = {}
if path.endswith('.tar.gz') or path.endswith('.tgz'):
with tarfile.open(path, 'r:gz') as tar:
for member in tar.getmembers():
if not member.isfile():
continue
handle = tar.extractfile(member)
if handle is not None:
files[_strip_top(member.name)] = handle.read()
elif path.endswith('.zip'):
with zipfile.ZipFile(path) as zf:
for info in zf.infolist():
if info.is_dir():
continue
files[_strip_top(info.filename)] = zf.read(info)
else:
raise SystemExit(f'✗ 不认识的包格式:{path}(支持 .tar.gz / .zip)')
return {k: v for k, v in files.items() if not k.startswith('/')}
def _strip_top(name: str) -> str:
parts = name.split('/', 1)
return parts[1] if len(parts) == 2 else name
def md5(data: bytes) -> str:
return hashlib.md5(data).hexdigest()
def git_blob(rel_path: str, tag: str) -> bytes | None:
result = subprocess.run(
['git', 'show', f'{tag}:{rel_path}'],
capture_output=True,
)
return result.stdout if result.returncode == 0 else None
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser()
parser.add_argument('archive')
parser.add_argument('--tag', required=True, help='要对比的 Git tag,例如 v0.7.5')
parser.add_argument('--json', action='store_true', help='输出机器可读结果')
args = parser.parse_args(argv[1:])
if not os.path.exists(args.archive):
print(f'✗ 找不到发布包:{args.archive}', file=sys.stderr)
return 2
if subprocess.run(['git', 'rev-parse', '--verify', f'{args.tag}^{{commit}}'],
capture_output=True).returncode != 0:
print(f'✗ 仓库里没有 tag {args.tag}', file=sys.stderr)
return 2
files = read_archive(args.archive)
mismatch: list[str] = []
extra: list[str] = []
missing: list[str] = []
tracked = built = 0
for rel, data in sorted(files.items()):
local_base: str | None = None
for prefix, target in BUILD_OUTPUTS.items():
# 目录前缀(以 / 结尾):把剩余路径拼到目标目录下;
# 精确文件名:目标就是该文件的路径(Windows/Android 的产物是单文件)
if prefix.endswith('/'):
if rel.startswith(prefix):
local_base = os.path.join(target, rel[len(prefix):])
break
elif rel == prefix:
local_base = target
break
if local_base is not None:
built += 1
if not os.path.exists(local_base):
missing.append(f'{rel}(工作树缺 {local_base})')
elif md5(data) != md5(open(local_base, 'rb').read()):
mismatch.append(f'{rel}(与工作树构建 {local_base} 不一致)')
continue
tracked += 1
blob = git_blob(rel, args.tag)
if blob is None:
extra.append(rel)
elif md5(data) != md5(blob):
mismatch.append(f'{rel}(与 {args.tag} 的内容不一致)')
report = {
'archive': args.archive,
'tag': args.tag,
'files': len(files),
'trackedFiles': tracked,
'buildOutputs': built,
'mismatch': mismatch,
'extra': extra,
'missing': missing,
}
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(f' 发布包 {args.archive}')
print(f' 对比 tag:{args.tag}')
print(f' 包内 {len(files)} 个文件:源码/文档 {tracked}(与 tag 比)、构建产物 {built}(与工作树构建比)')
if mismatch:
print(f' ✗ 内容不一致 {len(mismatch)} 处:')
for item in mismatch[:20]:
print(f' · {item}')
if extra:
print(f' ✗ 包内有、{args.tag} 里没有 {len(extra)} 处(既不是声明过的构建产物):')
for item in extra[:20]:
print(f' · {item}')
if missing:
print(f' ✗ 构建产物在工作树里找不到 {len(missing)} 处:')
for item in missing[:20]:
print(f' · {item}')
if mismatch or extra or missing:
if not args.json:
print(' ✗ 发布包与打标签的树不一致')
return 1
if not args.json:
print(' ✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))
+2 -1
View File
@@ -99,5 +99,6 @@ 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 "下一步(文档 §7.5 / §7.7:发布前必须做的验收,含逐文件核对):"
echo " python3 build/check_tracked_release.py $ZIP --tag v$VERSION"
echo " bash build/accept-release.sh $ZIP"
+4
View File
@@ -94,6 +94,10 @@ tasks:
file: tasks/017-backoff-and-pong.md
scope: 可注入计时 + 退避封顶端到端验证 + 修「合规不回 pong 的接收端被周期性误判链路已死」
status: done
- id: T-AND-018
file: tasks/018-tracked-release-check.md
scope: 逐文件核对「发布包 == 打标签的树」(源码比 tag、APK 比工作树构建)
status: done
deliverable:
artifact: opendance-android-v0.2.2.apk
+42
View File
@@ -0,0 +1,42 @@
# T-AND-018:逐文件核对「发布包 == 打标签的树」
- **状态**: done
- **依赖**: T-AND-014(发布包级验收)
- **文档**: v1.6 §6.3 / §7.7
- **契约**: 不涉及
## 为什么需要
「发布包里的东西必须等于打标签的那棵树」此前只靠抽查几个文件,
而抽查必然会漏(Web 端实测:发布包其实**包含** `web-server/test/**` 33 个文件,
且另有 177 个构建产物必须与工作树构建比对)。
## 实现
`build/check_tracked_release.py`(纯标准库,`.zip` 直接读)对包内**每一个**文件
按路径决定基准:
| 路径 | 比较基准 |
|--|--|
| `app-release.apk`(`BUILD_OUTPUTS`) | 工作树 `app/build/outputs/apk/release/app-release.apk` |
| 其余全部 | `git show <tag>:<路径>` |
任一内容不一致 / 包内多余 / 构建产物缺失都会列出并以非 0 退出。
已接入 `build/accept-release.sh`(第 0 步)与 `build/package-release.sh` 的提示。
## 实测(**已发布**的 v0.2.2 资产重新下载后跑)
```
[accept] 逐文件核对:发布包 == tag v0.2.2
包内 12 个文件:源码/文档 11(与 tag 比)、构建产物 1(与工作树构建比)
✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失
✓ APK 版本元数据一致:0.2.2(versionCode 202)
✓ native 库体检通过 | ✓ 包内契约快照与仓库一致
发布包级验收全部通过
```
## 踩坑
脚本第一版把 `BUILD_OUTPUTS` 的键一律当**目录前缀**处理,而 APK 是**单文件**
(键不带 `/`),于是拼出 `app/build/outputs/.../app-release.apk/` 这种路径,
误报「构建产物在工作树缺失」。现在区分「以 `/` 结尾的目录前缀」与「精确文件名」。