303 lines
11 KiB
Python
Executable File
303 lines
11 KiB
Python
Executable File
#!/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)
|