Files
XStats/scripts/publish_api.py
T
ysicingandClaude Opus 5.5 1623effd61 fix(release): 发布后逐区域核验 Sparkle 更新源并覆盖跳转策略
- 服务端保留探测标识(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>
2026-09-30 15:20:08 +08:00

123 lines
5.0 KiB
Python
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
# 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()