Files
opendance-web/web-server/scripts/check-tracked-release.py
T
zijunchiang 1acc75f00e test(release): 逐文件核对「发布包 == 打标签的树」(T-WEB-018)
「发布包里的东西必须等于打标签的那棵树」此前靠人抽查几个文件(README、contracts/VERSION…),
必然会漏。上一轮实测就暴露了两个认知错误:Web 的发布包其实**包含** web-server/test/**
(33 个文件,我此前以为不含),以及包内另有 177 个**构建产物**(admin/、web-server/dist/)
必须与工作树构建比 —— 只比源码文件是不够的。

新增 web-server/scripts/check-tracked-release.py(纯标准库,支持 .tar.gz / .zip):
对包内**每一个**文件按路径决定比较基准 —— 构建产物(admin/、web-server/dist/)比工作树
构建结果,其余文件比 `git show <tag>:<路径>`;任何内容不一致 / 包内多余(既不在 git 也不是
声明的构建产物)/ 构建产物在工作树缺失都会列出并以非 0 退出。`npm run release:check` 已注册。

实测(**已发布**资产重新下载后跑):
  包内 356 个文件:源码/文档 179(与 tag v0.7.5 比)+ 构建产物 177(与工作树构建比)
  ✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失
反向验证:篡改 README.md 一个字节 + 塞入 web-server/EXTRA.txt →
  ✗ 内容不一致 1 处(README.md)、✗ 包内多余 1 处(EXTRA.txt),退出码 1。

顺带修掉脚本自身一个路径映射缺陷:BUILD_OUTPUTS 的键若是不带 / 的**精确文件名**
(Windows/Android 的单文件产物),原实现会把剩余路径拼成 ".../OpenDance.exe/"
导致「工作树缺失」误报;现在区分「目录前缀」与「精确文件名」两种键。

注:accept-release.sh 的接入只在下一个版本的产物里生效(本仓 accept-release.sh 本身
也在发布包内,v0.7.5 已发布);对已发布的 v0.7.5 包仍可用 `npm run release:check -- <包> --tag v0.7.5`
逐文件核对(已跑通)。同时已把同一脚本复制到 opendance-windows / opendance-android 并接入
两端的 accept-release.sh。
2026-09-18 03:10:05 +08:00

184 lines
6.7 KiB
Python
Raw 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/**` 的改动是否影响产物从来没被检查过;
* 发布包里另有 177 个**构建产物**(`admin/`、`web-server/dist/`)与 git 无关,
必须与工作树的构建结果比 —— 只比源码文件是不够的。
本脚本把这条规则变成**可重复的全量核对**:
对发布包里的**每一个**文件,按路径决定比较基准:
| 路径 | 比较基准 |
|--|--|
| 构建产物(见 `BUILD_OUTPUTS`) | 工作树里对应的构建输出 |
| 其余文件 | `git show <tag>:<路径>`(打标签的树) |
任何一处不一致 / 包内多余(既不在 git 也不在构建输出)/ 缺失都会列出并以非 0 退出。
## 用法(在仓库根目录执行)
python3 <script> <发布包> --tag v0.7.5 [--json]
Web 的包是 `.tar.gz`,Windows / Android 是 `.zip`(Windows 用 LZMA 压缩,
Python 的 zipfile 能直接读)。包内通常有**一层顶层目录**
(`opendance-web-v0.7.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] = {
'admin/': 'web-server/admin/dist/', # Vite 构建产物
'web-server/dist/': 'web-server/dist/', # nest build 产物
}
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))