#!/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", ".deb", ".rpm", ".appimage"} 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. 已 Developer ID 签名并公证、staple 的包可直接打开。若仍提示无法验证,请确认下载的是钉票后的 DMG,或在 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.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)