Files
2026-06-09 14:01:20 +08:00

446 lines
17 KiB
Python
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
# -*- 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()