#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 发版与版本号:推荐顺序见 scripts/package/release.bat — 先 PyInstaller 打包,再更新版本号并同步到 dist,再 Inno Setup,最后 --post-build。 单独用法: 仅改版本号(通常在 build.bat 成功之后): python scripts/package/release.py 1.0.4 [--notes "说明"] 打包完成后: python scripts/package/release.py --post-build [--old-exe 上一版 ServerManager.exe] 本地测试包: scripts/package/pack_local_test.bat 或 python scripts/package/pack_local_test.py(见 RELEASE.md) """ import argparse import hashlib import json import re import shutil import subprocess import sys import zipfile from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[2] VERSION_JSON = ROOT / "resources" / "version.json" ROOT_VERSION_JSON = ROOT / "version.json" OUTPUT_DIR = ROOT / "build" / "output" PUBLIC_MANIFEST_URL = "https://www.sumi.work/api/software/server-manager/version.json" PUBLIC_INSTALLER_URL = "https://www.sumi.work/api/software/server-manager/update-download" PUBLIC_DELTA_URL = "https://www.sumi.work/api/software/server-manager/delta-download?from={from_version}" APP_DIST_NAME = "ServerManager" APP_EXE_NAME = "ServerManager.exe" FILE_DELTA_FORMAT = "server-manager-file-delta-v1" def read_text(p: Path) -> str: return p.read_text(encoding="utf-8") def write_text(p: Path, s: str) -> None: p.write_text(s, encoding="utf-8") def read_version_from_json(p: Path) -> str: if not p.exists(): return "" try: data = json.loads(read_text(p)) except Exception: return "" return (data.get("version") or "").strip() def get_current_version() -> str: """读取当前已发布版本,以 build/output/version.json 为准。""" return read_version_from_json(OUTPUT_DIR / "version.json") def format_size(bytes_count: int) -> str: return f"{bytes_count / 1024 / 1024:.1f} MB" def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest().lower() def rel_path(path: Path, root: Path) -> str: return path.relative_to(root).as_posix() def collect_dist_files(root: Path) -> dict: files = {} if not root.is_dir(): return files for p in sorted(root.rglob("*")): if not p.is_file(): continue rel = rel_path(p, root) if rel.startswith(".update_backup/") or rel.startswith("logs/") or rel.endswith(".old"): continue stat = p.stat() files[rel] = { "path": p, "sha256": sha256_file(p), "size": stat.st_size, } return files def generate_file_delta(old_dist_path: Path, old_version: str, new_version: str) -> tuple[Path, dict]: old_root = Path(old_dist_path).resolve() new_root = ROOT / "src" / "dist" / APP_DIST_NAME if not old_root.is_dir(): raise FileNotFoundError(f"old dist not found: {old_root}") if not new_root.is_dir(): raise FileNotFoundError(f"new dist not found: {new_root}") old_files = collect_dist_files(old_root) new_files = collect_dist_files(new_root) changed = [] deleted = [] for rel, meta in sorted(new_files.items()): old_meta = old_files.get(rel) if not old_meta or old_meta["sha256"] != meta["sha256"]: changed.append({ "path": rel, "zip_path": f"files/{rel}", "sha256": meta["sha256"], "size": meta["size"], "old_sha256": old_meta["sha256"] if old_meta else "", "old_size": old_meta["size"] if old_meta else 0, }) for rel, meta in sorted(old_files.items()): if rel not in new_files: deleted.append({ "path": rel, "old_sha256": meta["sha256"], "old_size": meta["size"], }) patch_dir = OUTPUT_DIR / "patches" patch_dir.mkdir(parents=True, exist_ok=True) delta_file = patch_dir / f"v{old_version}_to_v{new_version}.smdelta.zip" manifest = { "format": FILE_DELTA_FORMAT, "base_version": old_version, "target_version": new_version, "app": APP_DIST_NAME, "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), "files": changed, "delete": deleted, } with zipfile.ZipFile(delta_file, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf: zf.writestr("delta_manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") for entry in changed: zf.write(new_files[entry["path"]]["path"], entry["zip_path"]) size = delta_file.stat().st_size return delta_file, { "sha256": sha256_file(delta_file), "sizeBytes": size, "size": format_size(size), "fileCount": len(changed), "deleteCount": len(deleted), } def save_dist_snapshot(version: str) -> None: src = ROOT / "src" / "dist" / APP_DIST_NAME if not src.is_dir(): return dst = OUTPUT_DIR / "snapshots" / f"{APP_DIST_NAME}-{version}" dst.parent.mkdir(parents=True, exist_ok=True) if dst.exists(): shutil.rmtree(dst) shutil.copytree(src, dst) print(f" [OK] 已保存 dist 快照 -> {dst.relative_to(ROOT)}") def ensure_compat_manifest(data: dict) -> dict: """补齐客户端更新字段,并固定到 build/output 发布路径。""" data["update_url"] = PUBLIC_MANIFEST_URL data["full_installer_url"] = PUBLIC_INSTALLER_URL data["download_url"] = PUBLIC_INSTALLER_URL return data def write_version_json(data: dict) -> None: data = ensure_compat_manifest(data) text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" write_text(VERSION_JSON, text) write_text(ROOT_VERSION_JSON, text) def set_version_pre(new_version: str, release_notes: str = None, with_delta: bool = False, delta_kind: str = "file-zip") -> str: """更新版本号并同步 manifest。默认全量更新;显式 --with-delta 才生成 delta 项。""" vj = VERSION_JSON if not vj.exists(): print("错误: 未找到 version.json", file=sys.stderr) sys.exit(1) data = json.loads(read_text(vj)) old_version = read_version_from_json(OUTPUT_DIR / "version.json") if with_delta and not old_version: print("错误: 未找到 build/output/version.json 或其中无 version 字段", file=sys.stderr) sys.exit(1) if old_version: print(f" [i] 旧版本以 build/output/version.json 为准: {old_version}") # build_installer.iss iss = ROOT / "scripts" / "package" / "build_installer.iss" if iss.exists(): t = read_text(iss) t = re.sub(r'(#define MyAppVersion\s+")[^"]+(")', r'\g<1>' + new_version + r'\g<2>', t) write_text(iss, t) print(f" [OK] build_installer.iss -> {new_version}") # Tk demo/updater examples for rel in ("src/entrypoints/tk_update_demo.py", "scripts/update/tk_updater.py"): main_py = ROOT / rel if not main_py.exists(): continue t = read_text(main_py) t = re.sub(r'(LOCAL_VERSION\s*=\s*")[^"]+(")', r'\g<1>' + new_version + r'\g<2>', t) write_text(main_py, t) print(f" [OK] {rel} -> LOCAL_VERSION {new_version}") # version.json data["version"] = new_version if release_notes is not None: data["release_notes"] = release_notes else: data["release_notes"] = data.get("release_notes", "") or f"Server Manager {new_version} 更新。" data = ensure_compat_manifest(data) if with_delta: # delta_updates: 只保留 上一版本 -> 当前版本 一项。默认使用文件级增量 zip。 if delta_kind == "exe-patch": patch_url = PUBLIC_DELTA_URL.format(from_version=old_version) delta_entry = { "type": "bsdiff-exe", "patch_url": patch_url, "new_exe_sha256": "", } else: delta_url = PUBLIC_DELTA_URL.format(from_version=old_version) delta_entry = { "type": "file-zip", "format": FILE_DELTA_FORMAT, "fromVersion": old_version, "from_version": old_version, "targetVersion": new_version, "target_version": new_version, "package_url": delta_url, "packageUrl": delta_url, "patch_url": delta_url, "patchUrl": delta_url, "sha256": "", "sizeBytes": 0, "size": "", } data["delta_updates"] = {old_version: delta_entry} data["deltaUpdates"] = data["delta_updates"] print(f" [OK] version.json -> version {new_version}, delta_updates[{old_version}] 已添加") else: data["delta_updates"] = {} data["deltaUpdates"] = {} data.pop("delta", None) print(f" [OK] version.json -> version {new_version},本次使用全量更新") write_version_json(data) print(" [OK] 已同步 version.json -> resources/version.json 与根目录 version.json") # 若已打过 PyInstaller,把新版本同步进 dist,避免安装包内仍是旧 version.json for rel in ( f"src/dist/{APP_DIST_NAME}/_internal/version.json", f"src/dist/{APP_DIST_NAME}/version.json", ): dest = ROOT / rel if dest.parent.is_dir(): shutil.copy2(vj, dest) print(f" [OK] 已同步 version.json -> {dest.relative_to(ROOT)}") return old_version def set_version_post(old_exe_path: str = None, old_dist_path: str = None) -> None: """打包完成后:生成文件级增量 zip 或兼容旧 exe patch,并复制 version.json。""" vj = VERSION_JSON if not vj.exists(): return data = json.loads(read_text(vj)) data = ensure_compat_manifest(data) new_version = (data.get("version") or "").strip() delta_updates = data.get("delta_updates") or {} if not isinstance(delta_updates, dict): return # 找到需要补齐元数据的那一项(上一版本) old_version = None for k, v in delta_updates.items(): if not isinstance(v, dict): continue kind = str(v.get("type") or "").strip().lower().replace("_", "-") if kind == "bsdiff-exe": if not (v.get("new_exe_sha256") or "").strip(): old_version = k.strip() break elif not (v.get("sha256") or v.get("package_sha256") or v.get("packageSha256") or "").strip(): old_version = k.strip() break if not old_version: print(" [i] 无需生成增量补丁(new_exe_sha256 已填或无 delta)") write_version_json(data) save_dist_snapshot(new_version) _copy_version_to_output() return delta_entry = delta_updates.get(old_version) or {} delta_kind = str(delta_entry.get("type") or "").strip().lower().replace("_", "-") if delta_kind != "bsdiff-exe": if not old_dist_path: print(" [i] 未提供上一版 dist 目录(使用 --old-dist 指定可生成文件级增量包),本次仅全量") data["delta_updates"] = {} data.pop("deltaUpdates", None) write_version_json(data) save_dist_snapshot(new_version) _copy_version_to_output() return try: delta_file, delta_meta = generate_file_delta(Path(old_dist_path), old_version, new_version) except Exception as e: print(f" [!] 生成文件级增量包失败: {e},本次仅全量", file=sys.stderr) data["delta_updates"] = {} data.pop("deltaUpdates", None) write_version_json(data) save_dist_snapshot(new_version) _copy_version_to_output() return delta_entry.update({ "type": "file-zip", "format": FILE_DELTA_FORMAT, "fromVersion": old_version, "from_version": old_version, "targetVersion": new_version, "target_version": new_version, "sha256": delta_meta["sha256"], "package_sha256": delta_meta["sha256"], "packageSha256": delta_meta["sha256"], "sizeBytes": delta_meta["sizeBytes"], "size": delta_meta["size"], "fileCount": delta_meta["fileCount"], "deleteCount": delta_meta["deleteCount"], }) delta_updates[old_version] = delta_entry data["delta_updates"] = delta_updates data["deltaUpdates"] = delta_updates write_version_json(data) print( f" [OK] 已生成文件级增量包: {delta_file} " f"({delta_meta['size']}, {delta_meta['fileCount']} changed, {delta_meta['deleteCount']} deleted)" ) save_dist_snapshot(new_version) _copy_version_to_output() print(" 发版准备完成。") return # 支持目录模式 (dist/ServerManager/ServerManager.exe) 与单文件 (dist/ServerManager.exe) new_exe = ROOT / "src" / "dist" / APP_DIST_NAME / APP_EXE_NAME if not new_exe.exists(): new_exe = ROOT / "src" / "dist" / APP_EXE_NAME if not new_exe.exists(): print(f" [i] 未找到 src/dist/{APP_EXE_NAME} 或 src/dist/{APP_DIST_NAME}/{APP_EXE_NAME},跳过增量包") write_version_json(data) save_dist_snapshot(new_version) _copy_version_to_output() return # 仅当显式传入上一版 exe 路径时生成 patch(与 Cursor 一致,本地不保存 exe) old_exe = Path(old_exe_path).resolve() if old_exe_path else None if not old_exe_path or not old_exe or not old_exe.is_file(): print(f" [i] 未提供上一版 exe(使用 --old-exe 指定路径可生成增量包),仅全量") write_version_json(data) save_dist_snapshot(new_version) _copy_version_to_output() return try: import bsdiff4 except ImportError: print(" [!] 未安装 bsdiff4,跳过增量包。可执行: pip install bsdiff4", file=sys.stderr) save_dist_snapshot(new_version) _copy_version_to_output() return patch_dir = OUTPUT_DIR / "patches" patch_dir.mkdir(parents=True, exist_ok=True) patch_file = patch_dir / f"v{old_version}_to_v{new_version}.patch" old_data = old_exe.read_bytes() new_data = new_exe.read_bytes() patch_data = bsdiff4.diff(old_data, new_data) patch_file.write_bytes(patch_data) print(f" [OK] 已生成: {patch_file}") new_sha = hashlib.sha256(new_data).hexdigest().lower() delta_updates[old_version]["new_exe_sha256"] = new_sha data["delta_updates"] = delta_updates data["deltaUpdates"] = delta_updates write_version_json(data) print(f" [OK] version.json -> delta_updates[\"{old_version}\"].new_exe_sha256 = {new_sha[:16]}...") save_dist_snapshot(new_version) _copy_version_to_output() print(" 发版准备完成。") def _copy_version_to_output() -> None: src = VERSION_JSON dst = OUTPUT_DIR / "version.json" if src.exists() and dst.parent.exists(): shutil.copy2(src, dst) print(" [OK] 已复制 version.json -> build/output/") if src.exists(): shutil.copy2(src, ROOT_VERSION_JSON) print(" [OK] 已同步 version.json -> 根目录") def clean_generated_artifacts() -> None: cleanup_script = ROOT / "scripts" / "package" / "clean_artifacts.py" if cleanup_script.exists(): subprocess.run([sys.executable, str(cleanup_script)], cwd=str(ROOT), check=False) def main(): parser = argparse.ArgumentParser(description="一键发版:更新版本号 / 打包后生成增量并填 SHA256") parser.add_argument("version", nargs="?", help="新版本号,如 1.0.4") parser.add_argument("--post-build", action="store_true", help="打包完成后执行:复制 version.json;若提供 --old-dist 则生成文件级增量包") parser.add_argument("--old-dist", default=None, help="上一版 dist/ServerManager 目录,用于生成文件级增量 zip") parser.add_argument("--old-exe", default=None, help="上一版 ServerManager.exe 路径,用于兼容旧 bsdiff exe patch") parser.add_argument("--with-delta", action="store_true", help="生成增量更新项;默认文件级增量 zip") parser.add_argument("--delta-kind", choices=("file-zip", "exe-patch"), default="file-zip", help="增量类型,默认 file-zip") parser.add_argument("--notes", "-n", default=None, help="可选:release_notes 更新说明") args = parser.parse_args() if args.post_build: set_version_post(old_exe_path=args.old_exe, old_dist_path=args.old_dist) clean_generated_artifacts() return if not args.version or not re.match(r"^\d+\.\d+\.\d+$", args.version.strip()): print("用法: python scripts/package/release.py <版本号> 例如: python scripts/package/release.py 1.0.4", file=sys.stderr) sys.exit(1) new_ver = args.version.strip() old_ver = set_version_pre(new_ver, args.notes, with_delta=args.with_delta, delta_kind=args.delta_kind) if args.with_delta: print(f"\n 当前版本 {old_ver} -> 新版本 {new_ver}。若使用 scripts/package/release.bat,接下来将编译安装包并执行 --post-build。") else: print(f"\n 新版本 {new_ver} 将通过 build/output 下的完整安装包更新客户端。") if __name__ == "__main__": main()