Files

303 lines
11 KiB
Python
Executable File
Raw Permalink 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: artifacts stay in Tauri's
``src-tauri/target/<triple>/release/bundle/`` 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")
TAURI_BUNDLE_ROOTS = (
"src-tauri/target/release/bundle",
"src-tauri/target/aarch64-apple-darwin/release/bundle",
"src-tauri/target/x86_64-apple-darwin/release/bundle",
"src-tauri/target/x86_64-pc-windows-msvc/release/bundle",
"src-tauri/target/x86_64-unknown-linux-gnu/release/bundle",
"src-tauri/target/aarch64-unknown-linux-gnu/release/bundle",
)
def collect_assets(root: Path, version: str) -> list[Path]:
version_num = version.removeprefix("v")
found: list[Path] = []
for relative in TAURI_BUNDLE_ROOTS:
bundle = root / relative
if not bundle.is_dir():
continue
for path in bundle.rglob("*"):
if not path.is_file():
continue
if path.name.startswith("rw."):
continue
if path.name in CHECKSUM_NAMES:
found.append(path)
continue
if path.suffix.lower() in ALLOWED_SUFFIXES and version_num in path.name:
found.append(path)
assets = sorted(set(found))
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(
"未在 src-tauri/target/<triple>/release/bundle/ 找到安装包;"
f"请先构建 v{version_num}(不要使用仓库根 release/)"
)
if not checksums:
fail("bundle 目录中没有 SHA256SUMS.txt(由 ./scripts/build.sh 生成)")
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)