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:
1 parent
3dd5aa3e6c
commit
1623effd61
7 files changed
+233
-33
No files matched your search
+39
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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""))
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in new issue
Block a user