278 lines
11 KiB
Python
278 lines
11 KiB
Python
#!/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()
|