Files
meijiaka-zy/python-api/scripts/publish_release.py
T

244 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""
手动发布版本脚本
================
用法:
python scripts/publish_release.py \
--version 1.6.0 \
--notes "修复视频导出崩溃\n优化启动速度" \
--bundle-dir ../tauri-app/src-tauri/target/release/bundle
流程:
1. 扫描 bundle 目录,找到 .app.tar.gz / .exe / .AppImage 和对应的 .sig 文件
2. 上传安装包到七牛云
3. 读取 .sig 文件内容
4. 调用后端 API 创建版本发布记录
依赖:
pip install qiniu httpx
"""
import argparse
import sys
from pathlib import Path
from urllib.parse import quote
import httpx
from dotenv import load_dotenv
load_dotenv()
def find_packages(bundle_dir: Path) -> list[dict]:
"""扫描构建产物目录,找到安装包和对应的签名文件"""
packages = []
# macOS: .app.tar.gz + .app.tar.gz.sig
for sig_file in bundle_dir.rglob("*.app.tar.gz.sig"):
pkg_file = sig_file.with_suffix("") # 去掉 .sig
if not pkg_file.exists():
print(f"警告: 签名文件存在但安装包缺失: {pkg_file}")
continue
signature = sig_file.read_text().strip()
file_size = pkg_file.stat().st_size
filename = pkg_file.name
# 判断文件名是否包含架构标识
has_arch_marker = any(marker in filename for marker in ["aarch64", "arm64", "x86_64"])
if has_arch_marker:
# 文件名含架构标识,按标识解析
arch = "aarch64" if "aarch64" in filename or "arm64" in filename else "x86_64"
archs = [arch]
else:
# Universal Binary:同时支持 x86_64 和 aarch64
archs = ["x86_64", "aarch64"]
for arch in archs:
packages.append({
"platform": "darwin",
"architecture": arch,
"filename": filename,
"local_path": str(pkg_file),
"signature": signature,
"file_size": file_size,
})
# Windows: .exe + .exe.sig
for sig_file in bundle_dir.rglob("*.exe.sig"):
pkg_file = sig_file.with_suffix("")
if not pkg_file.exists():
print(f"警告: 签名文件存在但安装包缺失: {pkg_file}")
continue
arch = "x86_64" # Windows 目前只打 x86_64
signature = sig_file.read_text().strip()
packages.append({
"platform": "windows",
"architecture": arch,
"filename": pkg_file.name,
"local_path": str(pkg_file),
"signature": signature,
"file_size": pkg_file.stat().st_size,
})
# Linux: .AppImage + .AppImage.sig
for sig_file in bundle_dir.rglob("*.AppImage.sig"):
pkg_file = sig_file.with_suffix("")
if not pkg_file.exists():
print(f"警告: 签名文件存在但安装包缺失: {pkg_file}")
continue
arch = "x86_64" # Linux 目前只打 x86_64
signature = sig_file.read_text().strip()
packages.append({
"platform": "linux",
"architecture": arch,
"filename": pkg_file.name,
"local_path": str(pkg_file),
"signature": signature,
"file_size": pkg_file.stat().st_size,
})
# macOS DMG: 给新用户首次安装用(无签名文件)
for dmg_file in bundle_dir.rglob("*.dmg"):
filename = dmg_file.name
# 判断文件名是否包含架构标识
has_arch_marker = any(marker in filename for marker in ["aarch64", "arm64", "x86_64"])
if has_arch_marker:
arch = "aarch64" if "aarch64" in filename or "arm64" in filename else "x86_64"
archs = [arch]
else:
# Universal DMG:同时支持 x86_64 和 aarch64
archs = ["x86_64", "aarch64"]
for arch in archs:
packages.append({
"platform": "darwin",
"architecture": arch,
"filename": filename,
"local_path": str(dmg_file),
"signature": "", # DMG 无签名(非 updater 包)
"file_size": dmg_file.stat().st_size,
})
return packages
def upload_to_qiniu(local_path: str, key: str) -> str:
"""上传文件到七牛云,返回访问 URL"""
# 这里使用七牛云 SDK,需要配置环境变量
# 复用现有七牛云密钥和 media bucket
import os
from qiniu import Auth, put_file
access_key = os.environ["QINIU_ACCESS_KEY"]
secret_key = os.environ["QINIU_SECRET_KEY"]
bucket_name = os.environ["QINIU_VIDEO_BUCKET"]
domain = os.environ["QINIU_VIDEO_DOMAIN"]
auth = Auth(access_key, secret_key)
token = auth.upload_token(bucket_name, key, 3600)
ret, info = put_file(token, key, local_path, version="v2")
if ret is None:
raise RuntimeError(f"上传失败: {info}")
return f"https://{domain}/{key}"
def create_release(api_base_url: str, version: str, notes: str, mandatory: bool, packages: list[dict]):
"""调用后端 API 创建版本发布记录"""
url = f"{api_base_url}/api/v1/update/releases"
payload = {
"version": version,
"notes": notes,
"mandatory": mandatory,
"packages": packages,
}
response = httpx.post(url, json=payload, timeout=30)
response.raise_for_status()
return response.json()
def main():
parser = argparse.ArgumentParser(description="手动发布应用版本")
parser.add_argument("--version", required=True, help="版本号,如 1.6.0")
parser.add_argument("--notes", default="", help="更新说明(支持 \\n 换行)")
parser.add_argument("--mandatory", action="store_true", help="强制更新")
parser.add_argument("--bundle-dir", required=True, help="Tauri 构建产物目录")
parser.add_argument("--api-url", default="https://dev.tapi.meijiaka.cn", help="后端 API 地址")
parser.add_argument("--skip-upload", action="store_true", help="跳过七牛云上传(包已上传)")
parser.add_argument("--base-url", default="", help="安装包基础 URL(跳过上传时使用)")
args = parser.parse_args()
bundle_dir = Path(args.bundle_dir)
if not bundle_dir.exists():
print(f"错误: 构建目录不存在: {bundle_dir}")
sys.exit(1)
# 1. 扫描构建产物
print(f"扫描构建目录: {bundle_dir}")
packages = find_packages(bundle_dir)
if not packages:
print("错误: 未找到任何安装包或签名文件")
print("请确认已运行: export TAURI_SIGNING_PRIVATE_KEY=... && npm run tauri build")
sys.exit(1)
print(f"发现 {len(packages)} 个平台包:")
for p in packages:
print(f" - {p['platform']}-{p['architecture']}: {p['filename']} ({p['file_size'] / 1024 / 1024:.1f} MB)")
# 2. 上传到七牛云(或构造 URL)
# 按 local_path 去重,同一个文件只上传一次
uploaded = {} # local_path -> file_url
for p in packages:
if args.skip_upload:
if not args.base_url:
print("错误: --skip-upload 时必须提供 --base-url")
sys.exit(1)
encoded_filename = quote(p['filename'], safe='')
p["file_url"] = f"{args.base_url.rstrip('/')}/{p['platform']}/{encoded_filename}"
else:
local_path = p["local_path"]
if local_path not in uploaded:
key = f"meijiaka-zy/releases/{args.version}/{p['platform']}/{p['filename']}"
print(f"上传 {p['filename']} ({p['platform']}) 到七牛云...")
uploaded[local_path] = upload_to_qiniu(local_path, key)
encoded_filename = quote(p['filename'], safe='')
p["file_url"] = f"{uploaded[local_path].rsplit('/', 1)[0]}/{encoded_filename}"
# 删除临时字段
del p["local_path"]
# 3. 创建版本发布记录
print(f"创建版本发布记录: {args.version}")
try:
result = create_release(
api_base_url=args.api_url,
version=args.version,
notes=args.notes.replace("\\n", "\n"),
mandatory=args.mandatory,
packages=packages,
)
print(f"发布成功!ID: {result['id']}")
print(f"更新接口: {args.api_url}/api/v1/update/check?version={{current_version}}&target={{target}}&arch={{arch}}")
except httpx.HTTPStatusError as e:
print(f"发布失败: {e.response.status_code} - {e.response.text}")
sys.exit(1)
if __name__ == "__main__":
main()