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。
This commit is contained in:
@@ -25,7 +25,8 @@
|
|||||||
"migration:show": "node scripts/migrate.mjs show",
|
"migration:show": "node scripts/migrate.mjs show",
|
||||||
"version:check": "node scripts/check-version.mjs",
|
"version:check": "node scripts/check-version.mjs",
|
||||||
"bootstrap:check": "node scripts/check-bootstrap.mjs",
|
"bootstrap:check": "node scripts/check-bootstrap.mjs",
|
||||||
"vectors:frame": "vite-node scripts/gen-frame-vectors.mts --"
|
"vectors:frame": "vite-node scripts/gen-frame-vectors.mts --",
|
||||||
|
"release:check": "python3 scripts/check-tracked-release.py"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.12",
|
"@nestjs/common": "^11.0.12",
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ log "发布包:$TARBALL"
|
|||||||
log "工作目录:$WORK_DIR"
|
log "工作目录:$WORK_DIR"
|
||||||
mkdir -p "$WORK_DIR"
|
mkdir -p "$WORK_DIR"
|
||||||
|
|
||||||
|
# ---- 0. 逐文件核对:发布包 == 打标签的树(T-WEB-018)----
|
||||||
|
# 只在仓库内、且能解析到对应 tag 时执行;对用户拿到的 tarball 也能跑(--tag 需显式给)。
|
||||||
|
TRACKED_CHECKER="$PROJECT_DIR/scripts/check-tracked-release.py"
|
||||||
|
if [ -f "$TRACKED_CHECKER" ] && command -v git >/dev/null 2>&1; then
|
||||||
|
TAG="$(basename "$TARBALL" .tar.gz | sed 's/^opendance-web-//')"
|
||||||
|
if git -C "$PROJECT_DIR" rev-parse --verify "${TAG}^{commit}" >/dev/null 2>&1; then
|
||||||
|
log "逐文件核对:发布包 == tag $TAG(源码/文档比 tag,构建产物比工作树构建)"
|
||||||
|
python3 "$TRACKED_CHECKER" "$TARBALL" --tag "$TAG" || fail "发布包与 tag $TAG 不一致"
|
||||||
|
else
|
||||||
|
log "跳过逐文件核对(仓库里没有 tag $TAG;可在仓库内用 --tag 手工核对)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "跳过逐文件核对(缺 $TRACKED_CHECKER 或 git)"
|
||||||
|
fi
|
||||||
|
|
||||||
# ---- 1. 解包 ----
|
# ---- 1. 解包 ----
|
||||||
log "解包…"
|
log "解包…"
|
||||||
tar -xzf "$TARBALL" -C "$WORK_DIR" || { echo "✗ 解包失败" >&2; exit 1; }
|
tar -xzf "$TARBALL" -C "$WORK_DIR" || { echo "✗ 解包失败" >&2; exit 1; }
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/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))
|
||||||
Reference in New Issue
Block a user