Files
server_manager/scripts/package/release.py
T
2026-05-22 00:16:08 +08:00

278 lines
11 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
# -*- 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 上一版main.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
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_PATH = "server_manager/build/output/version.json"
PUBLIC_INSTALLER_PATH = "server_manager/build/output/ServerManager_Setup.exe"
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 _set_url_path(url: str, api_kind: str, public_path: str) -> str:
fallback = f"http://172.18.180.94:3000/api/{api_kind}?path={public_path}"
if not url:
return fallback
url = url.strip()
url = url.replace("/api/file?", f"/api/{api_kind}?")
url = url.replace("/api/download?", f"/api/{api_kind}?")
if "path=" in url:
return re.sub(r"path=[^&]+", lambda _m: f"path={public_path}", url, count=1)
return fallback
def ensure_compat_manifest(data: dict) -> dict:
"""补齐客户端更新字段,并固定到 build/output 发布路径。"""
seed = (
data.get("update_url")
or data.get("full_installer_url")
or data.get("download_url")
or ""
)
data["update_url"] = _set_url_path(seed, "file", PUBLIC_MANIFEST_PATH)
full_url = _set_url_path(
data.get("full_installer_url") or data.get("download_url") or seed,
"download",
PUBLIC_INSTALLER_PATH,
)
data["full_installer_url"] = full_url
data["download_url"] = full_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) -> 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)
full_url = data.get("full_installer_url", "")
if with_delta:
# delta_updates: 只保留 上一版本 -> 当前版本 一项。
# 目录模式下 delta 只替换 main.exe;只有确认依赖目录无需变化时才启用。
patch_path = f"patches/v{old_version}_to_v{new_version}.patch"
if full_url and "build/output/" in full_url:
patch_url = re.sub(r"build/output/[^?]+", "build/output/" + patch_path, full_url)
elif full_url and "output/" in full_url:
patch_url = re.sub(r"output/[^?]+", "build/output/" + patch_path, full_url)
else:
patch_url = full_url.replace("ServerManager_Setup.exe", patch_path) if full_url else ""
data["delta_updates"] = {
old_version: {
"patch_url": patch_url,
"new_exe_sha256": "",
}
}
print(f" [OK] version.json -> version {new_version}, delta_updates[{old_version}] 已添加")
else:
data["delta_updates"] = {}
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 ("src/dist/main/_internal/version.json", "src/dist/main/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) -> None:
"""打包完成后:若提供 --old-exe(上一版 main.exe 路径)则生成 patch 并填 new_exe_sha256;否则仅复制 version.json。本地不保存 exe。"""
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
# 找到需要填 new_exe_sha256 的那一项(上一版本)
old_version = None
for k, v in delta_updates.items():
if isinstance(v, dict) and not (v.get("new_exe_sha256") or "").strip():
old_version = k.strip()
break
if not old_version:
print(" [i] 无需生成增量补丁(new_exe_sha256 已填或无 delta")
write_version_json(data)
_copy_version_to_output()
return
# 支持目录模式 (dist/main/main.exe) 与单文件 (dist/main.exe)
new_exe = ROOT / "src" / "dist" / "main" / "main.exe"
if not new_exe.exists():
new_exe = ROOT / "src" / "dist" / "main.exe"
if not new_exe.exists():
print(" [i] 未找到 src/dist/main.exe 或 src/dist/main/main.exe,跳过增量包")
write_version_json(data)
_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)
_copy_version_to_output()
return
try:
import bsdiff4
except ImportError:
print(" [!] 未安装 bsdiff4,跳过增量包。可执行: pip install bsdiff4", file=sys.stderr)
_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
write_version_json(data)
print(f" [OK] version.json -> delta_updates[\"{old_version}\"].new_exe_sha256 = {new_sha[:16]}...")
_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-exe 则生成增量并填 SHA256")
parser.add_argument("--old-exe", default=None, help="上一版 main.exe 路径,用于生成增量 patch(与 Cursor 一致,本地不保存 exe)")
parser.add_argument("--with-delta", action="store_true", help="生成 main.exe 增量项;仅在确认依赖目录无需变化时使用")
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)
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)
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()