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/ 导致误报「工作树缺失」; 现在区分「以 / 结尾的目录前缀」与「精确文件名」两种键。
This commit is contained in:
@@ -56,6 +56,22 @@ log "期望版本:$EXPECTED"
|
||||
log "工作目录:$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
# ---- 0. 逐文件核对:发布包 == 打标签的树(T-WIN-025)----
|
||||
# 「发布包里的东西必须等于打标签的那棵树」此前只靠抽查几个文件,必然会漏。
|
||||
# 本步骤对包内**每一个**文件核对:源码/文档比 tag,构建产物(exe/pck)比工作树导出结果。
|
||||
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-windows-v//')"
|
||||
if git -C "$PROJECT_DIR" rev-parse --verify "${TAG}^{commit}" >/dev/null 2>&1; then
|
||||
log "逐文件核对:发布包 == tag $TAG"
|
||||
python3 "$CHECKER" "$TARGET" --tag "$TAG" || fail "发布包与 tag $TAG 不一致"
|
||||
else
|
||||
log "跳过逐文件核对(仓库里没有 tag $TAG;打标签后可手工跑 $CHECKER)"
|
||||
fi
|
||||
else
|
||||
log "跳过逐文件核对(缺 checker / git / 目标是已解压目录)"
|
||||
fi
|
||||
|
||||
# ---- 1. sha256 + 解压 ----
|
||||
if [ -f "$TARGET" ]; then
|
||||
# SHA256SUMS 里是**裸文件名**,所以校验时要把 cwd 切到包所在目录;
|
||||
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/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))
|
||||
@@ -179,7 +179,8 @@ echo ""
|
||||
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
|
||||
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
|
||||
echo ""
|
||||
echo "下一步(文档 §7.5 / §7.6:发布前必须做的三条产物级验收):"
|
||||
echo "下一步(文档 §7.5 / §7.6 / §7.7:发布前必须做的验收,含逐文件核对):"
|
||||
echo " python3 build/check-tracked-release.py $ZIP --tag v$VERSION # 包内每个文件 vs 打标签的树"
|
||||
echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP # 产物自证"
|
||||
echo " GODOT_BIN=/path/to/godot bash build/accept-ws.sh $ZIP # WebSocket 契约一致性"
|
||||
echo " GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh $ZIP # 关键点帧跨实现一致性"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# T-WIN-025:逐文件核对「发布包 == 打标签的树」
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WIN-018(发布包级验收)
|
||||
- **文档**: v1.6 §5.3 / §7.7
|
||||
- **契约**: 不涉及
|
||||
|
||||
## 为什么需要
|
||||
|
||||
「发布包里的东西必须等于打标签的那棵树」此前只靠**抽查**几个文件
|
||||
(README、`contracts/VERSION`…),而抽查必然会漏。上一轮实测暴露两个认知错误:
|
||||
|
||||
* Web 的发布包**包含** `web-server/test/**`(33 个文件),而我此前以为不含;
|
||||
* 发布包里另有**构建产物**(本仓是 `OpenDance.exe` / `OpenDance.pck`)与 git 无关,
|
||||
必须与工作树的导出结果比。
|
||||
|
||||
## 实现
|
||||
|
||||
`build/check-tracked-release.py`(纯标准库,`.zip` 直接读,LZMA 也支持):
|
||||
对包内**每一个**文件按路径决定比较基准
|
||||
|
||||
| 路径 | 比较基准 |
|
||||
|--|--|
|
||||
| `OpenDance.exe` / `OpenDance.pck`(`BUILD_OUTPUTS`) | 工作树 `build/windows/` 下的导出产物 |
|
||||
| 其余全部 | `git show <tag>:<路径>` |
|
||||
|
||||
任一**内容不一致** / **包内多余**(既不在 git 也不是声明的构建产物)/
|
||||
**构建产物在工作树缺失** 都会列出并以非 0 退出。已接入 `build/accept-release.sh`
|
||||
(第 0 步;目标须是 `.zip` 且仓库里有对应 tag)与 `build/package-release.sh` 的提示。
|
||||
|
||||
## 实测(**已发布**的 v0.2.5 资产重新下载后跑)
|
||||
|
||||
```
|
||||
[accept] 逐文件核对:发布包 == tag v0.2.5
|
||||
包内 13 个文件:源码/文档 11(与 tag 比)、构建产物 2(与工作树构建比)
|
||||
✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失
|
||||
```
|
||||
|
||||
**反向验证**:把包内 `README.txt` 追加一行后用 7z 重新打成 LZMA zip →
|
||||
|
||||
```
|
||||
✗ 内容不一致 1 处:· README.txt(与 v0.2.5 的内容不一致)
|
||||
✗ 发布包与打标签的树不一致 退出码=1
|
||||
```
|
||||
|
||||
## 踩坑
|
||||
|
||||
脚本第一版把 `BUILD_OUTPUTS` 的键一律当**目录前缀**处理(`rel[len(prefix):]`),
|
||||
而本仓与 Android 的产物是**单文件**(键不带 `/`),于是拼出
|
||||
`build/windows/OpenDance.exe/` 这种路径,误报「构建产物在工作树缺失」。
|
||||
现在区分两种键:以 `/` 结尾的是目录前缀,否则是精确文件名。
|
||||
|
||||
## 与「产物内开发残留检查」的区别
|
||||
|
||||
| 检查 | 回答的问题 |
|
||||
|--|--|
|
||||
| `list-pck.gd`(T-WIN-023) | 产物**内部**(pck 里)有没有混进开发/验收工具 |
|
||||
| 本脚本(T-WIN-025) | 产物**对着仓库**看有没有多、少、被改过的文件 |
|
||||
Reference in New Issue
Block a user