换位置

This commit is contained in:
lwt
2026-06-09 14:01:20 +08:00
parent 6d86678e11
commit 0b34f2e3d0
53 changed files with 1762 additions and 603 deletions
+227 -59
View File
@@ -4,7 +4,7 @@
发版与版本号:推荐顺序见 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]
打包完成后: 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
"""
@@ -15,14 +15,20 @@ 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_PATH = "server_manager/build/output/version.json"
PUBLIC_INSTALLER_PATH = "server_manager/build/output/ServerManager_Setup.exe"
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:
@@ -48,34 +54,119 @@ def get_current_version() -> str:
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 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 发布路径。"""
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
data["update_url"] = PUBLIC_MANIFEST_URL
data["full_installer_url"] = PUBLIC_INSTALLER_URL
data["download_url"] = PUBLIC_INSTALLER_URL
return data
@@ -86,7 +177,7 @@ def write_version_json(data: dict) -> None:
write_text(ROOT_VERSION_JSON, text)
def set_version_pre(new_version: str, release_notes: str = None, with_delta: bool = False) -> str:
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():
@@ -126,28 +217,38 @@ def set_version_pre(new_version: str, release_notes: str = None, with_delta: boo
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: {
# 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},本次使用全量更新")
@@ -155,7 +256,10 @@ def set_version_pre(new_version: str, release_notes: str = None, with_delta: boo
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"):
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)
@@ -163,8 +267,8 @@ def set_version_pre(new_version: str, release_notes: str = None, with_delta: boo
return old_version
def set_version_post(old_exe_path: str = None) -> None:
"""打包完成后:若提供 --old-exe(上一版 main.exe 路径)则生成 patch 并填 new_exe_sha256;否则仅复制 version.json。本地不保存 exe。"""
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
@@ -174,25 +278,83 @@ def set_version_post(old_exe_path: str = None) -> None:
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():
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("_", "-")
# 支持目录模式 (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,跳过增量包")
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
@@ -201,6 +363,7 @@ def set_version_post(old_exe_path: str = None) -> 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
@@ -208,6 +371,7 @@ def set_version_post(old_exe_path: str = None) -> None:
import bsdiff4
except ImportError:
print(" [!] 未安装 bsdiff4,跳过增量包。可执行: pip install bsdiff4", file=sys.stderr)
save_dist_snapshot(new_version)
_copy_version_to_output()
return
@@ -224,9 +388,11 @@ def set_version_post(old_exe_path: str = None) -> None:
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(" 发版准备完成。")
@@ -251,14 +417,16 @@ def clean_generated_artifacts() -> None:
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("--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)
set_version_post(old_exe_path=args.old_exe, old_dist_path=args.old_dist)
clean_generated_artifacts()
return
@@ -266,7 +434,7 @@ def main():
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)
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: