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>
This commit is contained in:
ysicingandClaude Opus 5.5 authored and 缘生 committed 2026-09-30 15:20:08 +08:00
1 parent 3dd5aa3e6c
commit 1623effd61
7 files changed
+233 -33

No files matched your search

+39 -9
View File
@@ -20,25 +20,51 @@ DEFAULT_ENDPOINTS = (
)
# 与服务端 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:
parts = urlsplit(endpoint)
if not parts.path.endswith("/api/v1/releases/current"):
raise ValueError(f"无法推导 Sparkle 更新源:{endpoint}")
url = urlunsplit((parts.scheme, parts.netloc, parts.path.removesuffix("releases/current") + "update/appcast.xml", "", ""))
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:
if error.code != 400 or error.headers.get("Cache-Control") != "no-store":
# 代理可能合并缓存指令,按 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 不能为空")
@@ -75,15 +101,19 @@ def configured_endpoints(environment: Mapping[str, str]) -> list[str]:
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit(f"用法:{sys.argv[0]} <appcast.json>")
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 sys.argv[1] == "--check-sparkle":
if arguments[0] == "--check-sparkle":
check_sparkle_endpoints(endpoints)
elif arguments[0] == "--verify-sparkle":
verify_sparkle_feeds(Path(arguments[1]), endpoints)
else:
publish(Path(sys.argv[1]), endpoints, token)
publish(Path(arguments[0]), endpoints, token)
except Exception as error:
raise SystemExit(f"发布版本到 API 失败:{error}") from error
+36
View File
@@ -39,6 +39,42 @@ class PublishAPITest(unittest.TestCase):
self.assertIsNone(call.args[0].data)
self.assertFalse(call.args[0].has_header("Authorization"))
def test_sparkle_gate_accepts_no_store_merged_by_proxy(self) -> None:
errors = [urllib.error.HTTPError(url, 400, "invalid request", {"Cache-Control": "private, No-Store"}, io.BytesIO(b""))
for url in publish_api.DEFAULT_ENDPOINTS]
with patch("publish_api.urllib.request.urlopen", side_effect=errors):
publish_api.check_sparkle_endpoints(list(publish_api.DEFAULT_ENDPOINTS))
def test_verify_fetches_each_region_with_release_probe_and_compares_bytes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
feed = Path(directory) / "XStats-1.0.0-AppleSilicon.xml"
feed.write_bytes(b"<rss>signed</rss>")
served = [b"<rss>signed</rss>", b"<rss>signed</rss>"]
responses = [contextlib.nullcontext(type("Response", (), {"status": 200, "read": lambda self, limit, body=body: body})())
for body in served]
with patch("publish_api.urllib.request.urlopen", side_effect=responses) as send, \
contextlib.redirect_stdout(io.StringIO()):
publish_api.verify_sparkle_feeds(feed, list(publish_api.DEFAULT_ENDPOINTS))
urls = [call.args[0].full_url for call in send.call_args_list]
self.assertEqual(urls, [
"https://xstats-apps.12306.work/api/v1/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64,
"https://x-stats.china.12306.work/api/v1/update/appcast.xml?current_version=release-probe&installation_id=" + "0" * 64,
])
for call in send.call_args_list:
self.assertFalse(call.args[0].has_header("Authorization"))
def test_verify_rejects_region_serving_different_feed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
feed = Path(directory) / "XStats-1.0.0-AppleSilicon.xml"
feed.write_bytes(b"<rss>signed</rss>")
stale = type("Response", (), {"status": 200, "read": lambda self, limit: b"<rss>previous</rss>"})()
ok = type("Response", (), {"status": 200, "read": lambda self, limit: b"<rss>signed</rss>"})()
with patch("publish_api.urllib.request.urlopen",
side_effect=[contextlib.nullcontext(ok), contextlib.nullcontext(stale)]), \
contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(RuntimeError, "x-stats.china.12306.work"):
publish_api.verify_sparkle_feeds(feed, list(publish_api.DEFAULT_ENDPOINTS))
def test_sparkle_gate_rejects_old_server_or_cached_validation(self) -> None:
for status, headers in ((404, {}), (400, {}), (503, {"Cache-Control": "no-store"})):
error = urllib.error.HTTPError("https://example.test", status, "error", headers, io.BytesIO(b""))
+2
View File
@@ -95,6 +95,8 @@ echo "✅ https://github.com/ysicing/xstats/releases/tag/v${VERSION}"
# 清单最后提交:安装包已在对象存储就位并校验通过后,已安装的应用才会看到新版本。
# Token 只从环境变量读取,不出现在命令参数里。
python3 scripts/publish_api.py "$APPCAST"
# JSON 生效后逐区域取回 Sparkle 清单,确认服务端验签通过且与本地 XML 一致;保留探测标识不计入统计。
python3 scripts/publish_api.py --verify-sparkle "dist/XStats-${VERSION}-AppleSilicon.xml"
# Homebrew tap
WORK="$(mktemp -d)"