- 服务端保留探测标识(64 个 0):照常验签返回清单但不计入统计 - publish_api.py 新增 --verify-sparkle,JSON 发布后逐区域取回 XML 与本地签名文件逐字节比对,publish_release.sh 自动执行 - Cache-Control 探测按指令 token 判断,兼容代理合并的缓存头 - 抽出 feedRedirectPolicy 供测试复用,覆盖 HTTPS 跳转、HTTP 降级、跳转过多、404、传输错误 - 新增测试校验服务端公钥与 Info.plist、project.yml 的 SUPublicEDKey 一致 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
5.0 KiB
Python
123 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
||
# Copyright (C) 2026 ysicing
|
||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
||
"""把发布清单提交给 XStats API。"""
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import urllib.request
|
||
import urllib.error
|
||
from urllib.parse import urlsplit, urlunsplit
|
||
from collections.abc import Mapping
|
||
from pathlib import Path
|
||
|
||
|
||
DEFAULT_ENDPOINTS = (
|
||
"https://xstats-apps.12306.work/api/v1/releases/current",
|
||
"https://x-stats.china.12306.work/api/v1/releases/current",
|
||
)
|
||
|
||
|
||
# 与服务端 releaseProbeInstallationID 一致:核验更新源但不计入安装统计。
|
||
RELEASE_PROBE_INSTALLATION_ID = "0" * 64
|
||
MAXIMUM_FEED_BYTES = 256 * 1024
|
||
|
||
|
||
def sparkle_feed_url(endpoint: str, query: str = "") -> str:
|
||
parts = urlsplit(endpoint)
|
||
if not parts.path.endswith("/api/v1/releases/current"):
|
||
raise ValueError(f"无法推导 Sparkle 更新源:{endpoint}")
|
||
return urlunsplit((parts.scheme, parts.netloc, parts.path.removesuffix("releases/current") + "update/appcast.xml", query, ""))
|
||
|
||
|
||
def check_sparkle_endpoints(endpoints: list[str]) -> None:
|
||
"""不携带安装标识的探测必须返回 400,防止先发布客户端而服务端仍是旧版。"""
|
||
if not endpoints:
|
||
raise ValueError("至少需要一个版本发布接口")
|
||
for endpoint in endpoints:
|
||
url = sparkle_feed_url(endpoint)
|
||
request = urllib.request.Request(url, headers={"User-Agent": "XStats-Release"})
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=20) as response:
|
||
raise RuntimeError(f"{url} 未拒绝缺少安装标识的请求:{response.status}")
|
||
except urllib.error.HTTPError as error:
|
||
with error:
|
||
# 代理可能合并缓存指令,按 token 判断而不是整串比较。
|
||
directives = {item.strip().lower() for item in (error.headers.get("Cache-Control") or "").split(",")}
|
||
if error.code != 400 or "no-store" not in directives:
|
||
raise RuntimeError(f"请先部署支持 Sparkle 的 API:{url} 返回 {error.code}") from error
|
||
|
||
|
||
def verify_sparkle_feeds(feed: Path, endpoints: list[str]) -> None:
|
||
"""发布 JSON 后逐区域取回 Sparkle 清单,必须与本地已签名 XML 逐字节一致。"""
|
||
if not endpoints:
|
||
raise ValueError("至少需要一个版本发布接口")
|
||
expected = feed.read_bytes()
|
||
query = f"current_version=release-probe&installation_id={RELEASE_PROBE_INSTALLATION_ID}"
|
||
for endpoint in endpoints:
|
||
url = sparkle_feed_url(endpoint, query)
|
||
request = urllib.request.Request(url, headers={"User-Agent": "XStats-Release"})
|
||
with urllib.request.urlopen(request, timeout=30) as response:
|
||
if response.status != 200 or response.read(MAXIMUM_FEED_BYTES + 1) != expected:
|
||
raise RuntimeError(f"{url} 返回的 Sparkle 清单与 {feed.name} 不一致")
|
||
print(f"✅ Sparkle 更新源已生效:{urlsplit(url).netloc}")
|
||
|
||
|
||
def publish(appcast: Path, endpoints: list[str], token: str) -> None:
|
||
if not token:
|
||
raise ValueError("XSTATS_RELEASE_TOKEN 不能为空")
|
||
if not endpoints:
|
||
raise ValueError("至少需要一个版本发布接口")
|
||
body = appcast.read_bytes()
|
||
manifest = json.loads(body)
|
||
if not isinstance(manifest, dict) or not manifest.get("version"):
|
||
raise ValueError(f"{appcast} 不是有效的版本清单")
|
||
for endpoint in endpoints:
|
||
request = urllib.request.Request(
|
||
endpoint,
|
||
data=body,
|
||
method="PUT",
|
||
headers={
|
||
"Authorization": f"Bearer {token}",
|
||
"Content-Type": "application/json",
|
||
"User-Agent": "XStats-Release",
|
||
},
|
||
)
|
||
with urllib.request.urlopen(request, timeout=30) as response:
|
||
if response.status != 204:
|
||
raise RuntimeError(f"{endpoint} 返回 {response.status}")
|
||
print(f"✅ 版本清单已发布到 {endpoint}")
|
||
|
||
|
||
def configured_endpoints(environment: Mapping[str, str]) -> list[str]:
|
||
configured = environment.get("XSTATS_API_URLS", "")
|
||
if configured:
|
||
return [item.strip() for item in configured.split(",") if item.strip()]
|
||
if endpoint := environment.get("XSTATS_API_URL"):
|
||
return [endpoint]
|
||
return list(DEFAULT_ENDPOINTS)
|
||
|
||
|
||
def main() -> None:
|
||
usage = f"用法:{sys.argv[0]} <appcast.json> | --check-sparkle | --verify-sparkle <feed.xml>"
|
||
arguments = sys.argv[1:]
|
||
if len(arguments) not in (1, 2) or (len(arguments) == 2) != (arguments[0] == "--verify-sparkle"):
|
||
raise SystemExit(usage)
|
||
endpoints = configured_endpoints(os.environ)
|
||
token = os.environ.get("XSTATS_RELEASE_TOKEN", "")
|
||
try:
|
||
if arguments[0] == "--check-sparkle":
|
||
check_sparkle_endpoints(endpoints)
|
||
elif arguments[0] == "--verify-sparkle":
|
||
verify_sparkle_feeds(Path(arguments[1]), endpoints)
|
||
else:
|
||
publish(Path(arguments[0]), endpoints, token)
|
||
except Exception as error:
|
||
raise SystemExit(f"发布版本到 API 失败:{error}") from error
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|