Files
opsxshell/release.py
T

282 lines
10 KiB
Python
Executable File
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
"""Create an OpsXShell Gitee Release and upload native installers.
The script is intentionally non-destructive: release artifacts stay in the
local ``release/`` directory after a successful upload.
"""
from __future__ import annotations
import argparse
import netrc
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import requests
API_BASE = "https://gitee.com/api/v5"
DEFAULT_REPO = "opsx-tools/opsxshell"
ALLOWED_SUFFIXES = {".dmg", ".exe", ".msi"}
CHECKSUM_NAMES = {"SHA256SUMS.txt"}
MAX_ASSET_BYTES = 100 * 1024 * 1024
def info(message: str) -> None:
print(f"[release] {message}")
def fail(message: str) -> None:
raise RuntimeError(message)
def normalize_version(value: str) -> str:
version = value if value.startswith("v") else f"v{value}"
parts = version.removeprefix("v").split(".")
if len(parts) != 3 or not all(part.isdigit() for part in parts):
fail(f"版本号必须是 vX.Y.Z,当前输入:{value}")
return version
def detect_repo() -> str:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return DEFAULT_REPO
remote = result.stdout.strip().removesuffix(".git")
if remote.startswith("git@gitee.com:"):
return remote.split(":", 1)[1]
parsed = urlparse(remote)
if parsed.hostname == "gitee.com" and parsed.path.strip("/"):
return parsed.path.strip("/")
return DEFAULT_REPO
def load_token() -> str:
token = os.environ.get("GITEE_TOKEN", "").strip()
if token:
return token
try:
auth = netrc.netrc().authenticators("gitee.com")
except (FileNotFoundError, netrc.NetrcParseError, OSError):
auth = None
if auth and auth[2]:
return auth[2]
fail("缺少 Gitee Token:请设置 GITEE_TOKEN 或 ~/.netrc 的 gitee.com password")
def collect_assets(root: Path, version: str) -> list[Path]:
version_dir = root / "release" / version
if not version_dir.is_dir():
fail(f"发布目录不存在:{version_dir}")
assets = sorted(
path
for path in version_dir.rglob("*")
if path.is_file()
and (path.suffix.lower() in ALLOWED_SUFFIXES or path.name in CHECKSUM_NAMES)
)
installers = [path for path in assets if path.suffix.lower() in ALLOWED_SUFFIXES]
checksums = [path for path in assets if path.name in CHECKSUM_NAMES]
if not installers:
fail(f"{version_dir} 中没有安装包")
if not checksums:
fail(f"{version_dir} 中没有 SHA256SUMS.txt")
duplicate_names = {path.name for path in assets if sum(item.name == path.name for item in assets) > 1}
if duplicate_names:
fail(f"附件文件名重复:{', '.join(sorted(duplicate_names))}")
for path in assets:
if path.stat().st_size > MAX_ASSET_BYTES:
fail(f"附件超过 Gitee 100 MB 限制:{path.name}")
return assets
def release_body(version: str, repo: str, assets: list[Path]) -> str:
installers = [path for path in assets if path.suffix.lower() in ALLOWED_SUFFIXES]
lines = [
f"## OpsXShell {version}",
"",
"轻量原生的 SSH/SFTP 远程运维工作台,基于 Rust 与 Tauri v2。",
"",
"### 本版更新",
"",
"- SSH 终端内置单入口 AI Agent,可执行、观察并继续处理远程任务",
"- 完善命令风险分级、危险命令拒绝、执行前确认与循环保护",
"- 优化远程 Shell 超时退出后的单窗格恢复和错误提示",
"- 完善 SSH/SFTP、终端交互与原生发布验收门禁",
"",
"### 下载",
"",
]
for path in installers:
lower = path.name.lower()
if path.suffix.lower() == ".dmg" and ("aarch64" in lower or "arm64" in lower):
platform = "macOS Apple Silicon"
elif path.suffix.lower() == ".dmg":
platform = "macOS Intel"
elif path.suffix.lower() in {".exe", ".msi"}:
platform = "Windows x86_64"
else:
platform = "安装包"
url = f"https://gitee.com/{repo}/releases/download/{version}/{path.name}"
lines.append(f"- **{platform}**:[{path.name}]({url})")
lines.extend(
[
"",
"### macOS 安装",
"",
"1. 打开 DMG,将 OpsXShell 拖入 Applications。",
"2. 当前公开包为 ad-hoc 开发签名且尚未 Apple 公证;首次启动如被 Gatekeeper 拦截,请在 Finder 中右键应用并选择“打开”。",
"",
"### 完整性校验",
"",
"下载 `SHA256SUMS.txt` 后,在安装包所在目录执行:",
"",
"```bash",
"shasum -a 256 -c SHA256SUMS.txt",
"```",
"",
"> 本 Release 仅声明附件中实际提供的平台与架构。",
]
)
return "\n".join(lines)
class GiteeRelease:
def __init__(self, token: str, repo: str) -> None:
self.repo = repo
self.headers = {
"Authorization": f"token {token}",
"User-Agent": "opsxshell-release-uploader",
}
def request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
response = requests.request(
method,
f"{API_BASE}/repos/{self.repo}{path}",
headers=self.headers,
timeout=kwargs.pop("timeout", 30),
**kwargs,
)
return response
def get_by_tag(self, version: str) -> dict[str, Any] | None:
response = self.request("GET", f"/releases/tags/{version}")
if response.status_code == 404:
return None
if response.status_code != 200:
fail(f"读取 Gitee Release 失败:HTTP {response.status_code} {response.text[:300]}")
return response.json()
def upsert(self, version: str, body: str) -> dict[str, Any]:
release = self.get_by_tag(version)
payload = {
"tag_name": version,
"name": f"OpsXShell {version}",
"body": body,
"prerelease": False,
}
if release:
response = self.request("PATCH", f"/releases/{release['id']}", json=payload)
action = "更新"
else:
payload["target_commitish"] = subprocess.check_output(
["git", "branch", "--show-current"], text=True
).strip()
response = self.request("POST", "/releases", json=payload)
action = "创建"
if response.status_code not in {200, 201}:
fail(f"{action} Gitee Release 失败:HTTP {response.status_code} {response.text[:300]}")
info(f"{action} Release 成功:{version}")
return response.json()
def details(self, release_id: int) -> dict[str, Any]:
response = self.request("GET", f"/releases/{release_id}")
if response.status_code != 200:
fail(f"读取 Release 附件失败:HTTP {response.status_code} {response.text[:300]}")
return response.json()
def upload(self, release_id: int, asset: Path, existing: dict[str, int]) -> None:
expected_size = asset.stat().st_size
if existing.get(asset.name) == expected_size:
info(f"跳过同名同大小附件:{asset.name}")
return
if asset.name in existing:
fail(f"Release 已存在同名但大小不同的附件,请先人工删除:{asset.name}")
info(f"上传 {asset.name}({expected_size / 1024 / 1024:.1f} MB)")
with asset.open("rb") as stream:
response = self.request(
"POST",
f"/releases/{release_id}/attach_files",
files={"file": (asset.name, stream, "application/octet-stream")},
timeout=600,
)
if response.status_code != 201:
fail(f"上传 {asset.name} 失败:HTTP {response.status_code} {response.text[:300]}")
def verify(self, release_id: int, assets: list[Path]) -> dict[str, Any]:
release = self.details(release_id)
remote = {item["name"]: int(item.get("size", -1)) for item in release.get("assets", [])}
mismatches = [
path.name
for path in assets
if path.name not in remote or remote[path.name] not in {-1, path.stat().st_size}
]
if mismatches:
fail(f"Release 附件核验失败:{', '.join(mismatches)}")
return release
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="发布 OpsXShell 原生安装包到 Gitee")
parser.add_argument("version", help="版本号,例如 v2.1.1")
parser.add_argument("--repo", default=detect_repo(), help="Gitee 仓库,默认从 origin 检测")
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent)
parser.add_argument("--dry-run", action="store_true", help="只检查并列出待上传附件")
return parser.parse_args()
def main() -> int:
args = parse_args()
version = normalize_version(args.version)
assets = collect_assets(args.root, version)
info(f"仓库:{args.repo}")
info(f"版本:{version}")
for asset in assets:
info(f"附件:{asset.relative_to(args.root)}")
if args.dry_run:
info("dry-run 完成,未访问 Gitee API")
return 0
uploader = GiteeRelease(load_token(), args.repo)
release = uploader.upsert(version, release_body(version, args.repo, assets))
details = uploader.details(int(release["id"]))
existing = {item["name"]: int(item.get("size", -1)) for item in details.get("assets", [])}
for asset in assets:
uploader.upload(int(release["id"]), asset, existing)
verified = uploader.verify(int(release["id"]), assets)
info(f"发布完成:https://gitee.com/{args.repo}/releases/tag/{version}")
info(f"附件核验通过:{len(assets)} 个")
if verified.get("tag_name") != version:
fail("Release tag 核验失败")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (RuntimeError, requests.RequestException, subprocess.SubprocessError) as exc:
print(f"[release] ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)