Files
opendance-windows/build/check-tracked-release.py
zijunchiang 20c6682202 test(release): 逐文件核对「发布包 == 打标签的树」(T-WIN-025)
「发布包 == 打标签的树」此前只抽查几个文件,必然会漏。新增
build/check-tracked-release.py(纯标准库,直接读 LZMA zip):对包内**每一个**文件
按路径决定基准 —— OpenDance.exe / OpenDance.pck 比工作树 build/windows/ 的导出结果,
其余文件比 git show <tag>:<路径>;内容不一致 / 包内多余 / 构建产物缺失都会列出并退出 1。
已接入 build/accept-release.sh(第 0 步)与 build/package-release.sh 的提示。

实测(已发布的 v0.2.5 资产重新下载后):
  包内 13 个文件:源码/文档 11(与 tag v0.2.5 比)+ 构建产物 2(与工作树导出比)
  ✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失
反向验证:把包内 README.txt 追加一行后用 7z 重打成 LZMA zip →
  ✗ 内容不一致 1 处(README.txt),退出码 1。

踩坑:脚本第一版把 BUILD_OUTPUTS 的键一律当目录前缀(rel[len(prefix):]),
而本仓产物是单文件,拼出 build/windows/OpenDance.exe/ 导致误报「工作树缺失」;
现在区分「以 / 结尾的目录前缀」与「精确文件名」两种键。
2026-09-18 03:10:15 +08:00

183 lines
6.7 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""发布产物 × 打标签的树:**逐文件**核对(T-WEB-018)
## 为什么需要
「发布包里的东西必须等于打标签的那棵树」这条规则此前是靠人手工抽查几个文件
(README、contracts/VERSION…)来确认的 —— 而抽查必然会漏。本轮实测就发现
两个认知错误:
* Web 的发布包**包含** `web-server/test/**`(33 个文件)而我此前以为不含,
于是 `test/**` 的改动是否影响产物从来没被检查过;
* 发布包里另有构建产物(`OpenDance.exe` / `OpenDance.pck`)与 git 无关,
必须与工作树的导出结果比 —— 只比源码文件是不够的。
本脚本把这条规则变成**可重复的全量核对**:
对发布包里的**每一个**文件,按路径决定比较基准:
| 路径 | 比较基准 |
|--|--|
| 构建产物(见 `BUILD_OUTPUTS`) | 工作树里对应的构建输出 |
| 其余文件 | `git show <tag>:<路径>`(打标签的树) |
任何一处不一致 / 包内多余(既不在 git 也不在构建输出)/ 缺失都会列出并以非 0 退出。
## 用法(在仓库根目录执行)
python3 <script> <发布包> --tag v0.7.5 [--json]
Windows 的包是 `.zip`(LZMA 压缩,Python 的 zipfile 能直接读)。
包内通常有**一层顶层目录**(`opendance-windows-v0.2.5/`),脚本会自动剥掉。
"""
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] = {
'OpenDance.exe': 'build/windows/OpenDance.exe', # Godot 导出产物
'OpenDance.pck': 'build/windows/OpenDance.pck',
}
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))