Skip to content

Commit b630664

Browse files
committed
fix(cdn): rely on purge finished status
1 parent 0548ddb commit b630664

6 files changed

Lines changed: 35 additions & 155 deletions

File tree

.github/jsdelivr-publish.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@
55
"main",
66
"refs/heads/main"
77
],
8-
"verify_hosts": [
9-
"cdn.jsdelivr.net"
10-
],
118
"public_roots": [
129
"cfg",
1310
"game_rule",

.github/scripts/jsdelivr_purge.py

Lines changed: 3 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
"""Purge and verify mutable jsDelivr aliases for changed public files."""
2+
"""Purge mutable jsDelivr aliases for changed public files."""
33

44
from __future__ import annotations
55

@@ -23,8 +23,6 @@
2323
PURGE_HOST = "purge.jsdelivr.net"
2424
DEFAULT_PURGE_ATTEMPTS = 5
2525
DEFAULT_PURGE_WORKERS = 2
26-
DEFAULT_VERIFY_ATTEMPTS = 8
27-
DEFAULT_VERIFY_WORKERS = 4
2826
RETRY_DELAYS = (2, 5, 10, 20, 30, 45, 60)
2927
USER_AGENT = "Custom_OpenClash_Rules-jsDelivr-publisher/1.0"
3028

@@ -38,7 +36,6 @@ class PublishContract:
3836
repository: str
3937
branch: str
4038
ref_aliases: tuple[str, ...]
41-
verify_hosts: tuple[str, ...]
4239
public_roots: frozenset[str]
4340
deferred_sources: frozenset[str]
4441
generated_suffixes: tuple[str, ...]
@@ -66,12 +63,6 @@ class HttpResult:
6663
body: bytes
6764

6865

69-
@dataclasses.dataclass(frozen=True)
70-
class VerificationTarget:
71-
url: str
72-
expectation: AssetExpectation
73-
74-
7566
def _require_string_list(data: Mapping[str, object], key: str) -> tuple[str, ...]:
7667
value = data.get(key)
7768
if not isinstance(value, list) or not value or not all(
@@ -124,7 +115,6 @@ def load_contract(path: Path) -> PublishContract:
124115
repository=repository,
125116
branch=branch,
126117
ref_aliases=aliases,
127-
verify_hosts=_require_string_list(data, "verify_hosts"),
128118
public_roots=frozenset(roots),
129119
deferred_sources=frozenset(deferred),
130120
generated_suffixes=generated_suffixes,
@@ -339,27 +329,6 @@ def validate_purge_response(result: HttpResult, expected_path: str) -> None:
339329
raise PublishError(f"Purge returned invalid JSON for {expected_path}: {exc}") from exc
340330
if not isinstance(payload, dict) or payload.get("status") != "finished":
341331
raise PublishError(f"Purge did not finish for {expected_path}: {payload!r}")
342-
paths = payload.get("paths")
343-
if not isinstance(paths, dict):
344-
raise PublishError(f"Purge response has no paths map for {expected_path}")
345-
346-
normalized_expected = urllib.parse.unquote(expected_path)
347-
matching_entries = [
348-
value
349-
for key, value in paths.items()
350-
if isinstance(key, str) and urllib.parse.unquote(key) == normalized_expected
351-
]
352-
if len(matching_entries) != 1 or not isinstance(matching_entries[0], dict):
353-
raise PublishError(f"Purge response omitted exact path {expected_path}: {paths!r}")
354-
entry = matching_entries[0]
355-
if entry.get("throttled") is not False:
356-
raise PublishError(f"Purge was throttled or ambiguous for {expected_path}: {entry!r}")
357-
providers = entry.get("providers")
358-
if not isinstance(providers, dict) or not providers:
359-
raise PublishError(f"Purge response has no provider results for {expected_path}")
360-
failed = sorted(name for name, succeeded in providers.items() if succeeded is not True)
361-
if failed:
362-
raise PublishError(f"Purge providers failed for {expected_path}: {', '.join(failed)}")
363332

364333

365334
def purge_target(
@@ -413,77 +382,6 @@ def purge_all(
413382
raise PublishError("One or more purge requests failed:\n" + "\n".join(errors))
414383

415384

416-
def verification_targets(
417-
expectations: Sequence[AssetExpectation], contract: PublishContract
418-
) -> list[VerificationTarget]:
419-
# A successful purge response confirms that jsDelivr accepted the cache
420-
# invalidation for deleted assets. Their eventual HTTP 404 propagation is
421-
# outside this repository's control, so only published files are subject
422-
# to byte-for-byte CDN verification.
423-
return [
424-
VerificationTarget(
425-
url=f"https://{host}{alias_path(contract.repository, alias, expectation.path)}",
426-
expectation=expectation,
427-
)
428-
for expectation in expectations
429-
if expectation.content is not None
430-
for alias in contract.ref_aliases
431-
for host in contract.verify_hosts
432-
]
433-
434-
435-
def result_matches(result: HttpResult, expectation: AssetExpectation) -> tuple[bool, str]:
436-
if expectation.content is None:
437-
return result.status == 404, f"HTTP {result.status}"
438-
if result.status != 200:
439-
return False, f"HTTP {result.status}"
440-
actual_digest = hashlib.sha256(result.body).hexdigest()
441-
expected_digest = hashlib.sha256(expectation.content).hexdigest()
442-
return (
443-
result.body == expectation.content,
444-
f"sha256={actual_digest}, bytes={len(result.body)}; expected sha256={expected_digest}, bytes={len(expectation.content)}",
445-
)
446-
447-
448-
def verify_all(
449-
targets: Sequence[VerificationTarget],
450-
*,
451-
requester: Callable[[str], HttpResult] = request_url,
452-
attempts: int = DEFAULT_VERIFY_ATTEMPTS,
453-
workers: int = DEFAULT_VERIFY_WORKERS,
454-
sleeper: Callable[[float], None] = time.sleep,
455-
) -> None:
456-
pending = {target.url: target for target in targets}
457-
last_observed: dict[str, str] = {}
458-
for attempt in range(attempts):
459-
if not pending:
460-
return
461-
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
462-
futures = {executor.submit(requester, url): url for url in pending}
463-
for future in concurrent.futures.as_completed(futures):
464-
url = futures[future]
465-
target = pending[url]
466-
try:
467-
matched, observed = result_matches(future.result(), target.expectation)
468-
except OSError as exc:
469-
matched, observed = False, str(exc)
470-
last_observed[url] = observed
471-
if matched:
472-
print(f"Verified {url}: {target.expectation.description}", flush=True)
473-
del pending[url]
474-
if pending and attempt + 1 < attempts:
475-
delay = RETRY_DELAYS[min(attempt, len(RETRY_DELAYS) - 1)]
476-
print(f"Waiting {delay}s for {len(pending)} CDN cache keys", flush=True)
477-
sleeper(delay)
478-
479-
if not pending:
480-
return
481-
details = "\n".join(
482-
f"{url}: {last_observed.get(url, 'no response')}" for url in sorted(pending)
483-
)
484-
raise PublishError(f"CDN verification failed for {len(pending)} cache keys:\n{details}")
485-
486-
487385
def _own_jsdelivr_urls(repository: str, revision: str) -> Iterable[str]:
488386
pattern = rf"https://(cdn|testingcf)\.jsdelivr\.net/gh/{re.escape(repository)}@"
489387
completed = subprocess.run(
@@ -576,7 +474,6 @@ def command_run(args: argparse.Namespace) -> None:
576474
if not expectations:
577475
return
578476
purge_all(expectations, contract)
579-
verify_all(verification_targets(expectations, contract))
580477

581478

582479
def build_parser() -> argparse.ArgumentParser:
@@ -592,14 +489,14 @@ def build_parser() -> argparse.ArgumentParser:
592489
check = subparsers.add_parser("check-contract", help="Validate owned jsDelivr URLs")
593490
check.add_argument("--revision", default="HEAD")
594491

595-
run = subparsers.add_parser("run", help="Purge and verify changed public files")
492+
run = subparsers.add_parser("run", help="Purge changed public files")
596493
run.add_argument("--repository", required=True)
597494
run.add_argument("--before", required=True)
598495
run.add_argument("--after", required=True)
599496
run.add_argument(
600497
"--published",
601498
required=True,
602-
help="Latest main snapshot whose bytes mutable aliases must serve",
499+
help="Latest main snapshot used to resolve current asset state",
603500
)
604501
run.add_argument("--mode", choices=("direct", "complete"), required=True)
605502
return parser

.github/scripts/test_jsdelivr_purge.py

Lines changed: 29 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import sys
99
import tempfile
1010
import unittest
11+
import unittest.mock
1112
from pathlib import Path
1213

1314
import jsdelivr_purge as purge
@@ -21,7 +22,6 @@ def contract() -> purge.PublishContract:
2122
repository=REPOSITORY,
2223
branch="main",
2324
ref_aliases=("main", "refs/heads/main"),
24-
verify_hosts=("cdn.jsdelivr.net", "testingcf.jsdelivr.net"),
2525
public_roots=frozenset(
2626
{"cfg", "game_rule", "icon", "overwrite", "rule", "script", "shell"}
2727
),
@@ -252,17 +252,18 @@ def response(self, path: str, *, throttled=False, providers=None):
252252
}
253253
return purge.HttpResult(200, json.dumps(body).encode())
254254

255-
def test_requires_exact_path_and_all_providers(self):
255+
def test_finished_status_is_sufficient(self):
256256
path = "/gh/Aethersailor/Custom_OpenClash_Rules@main/cfg/a.ini"
257257
purge.validate_purge_response(self.response(path), path)
258-
with self.assertRaisesRegex(purge.PublishError, "omitted exact path"):
259-
purge.validate_purge_response(self.response(path + ".other"), path)
260-
with self.assertRaisesRegex(purge.PublishError, "providers failed"):
261-
purge.validate_purge_response(
262-
self.response(path, providers={"CF": True, "FY": False}), path
263-
)
264-
with self.assertRaisesRegex(purge.PublishError, "throttled"):
265-
purge.validate_purge_response(self.response(path, throttled=True), path)
258+
purge.validate_purge_response(self.response(path + ".other"), path)
259+
purge.validate_purge_response(
260+
self.response(path, providers={"CF": True, "FY": False}), path
261+
)
262+
purge.validate_purge_response(self.response(path, throttled=True), path)
263+
264+
unfinished = purge.HttpResult(200, json.dumps({"status": "processing"}).encode())
265+
with self.assertRaisesRegex(purge.PublishError, "did not finish"):
266+
purge.validate_purge_response(unfinished, path)
266267

267268
def test_retry_then_success(self):
268269
calls: list[str] = []
@@ -287,44 +288,29 @@ def requester(url: str) -> purge.HttpResult:
287288
self.assertEqual(len(calls), 2)
288289
self.assertEqual(sleeps, [2])
289290

290-
291-
class VerificationTests(unittest.TestCase):
292-
def test_content_and_deletion_expectations(self):
293-
content = purge.AssetExpectation("cfg/a.ini", b"expected")
294-
deleted = purge.AssetExpectation("cfg/deleted.ini", None)
295-
self.assertTrue(purge.result_matches(purge.HttpResult(200, b"expected"), content)[0])
296-
self.assertFalse(purge.result_matches(purge.HttpResult(200, b"stale"), content)[0])
297-
self.assertTrue(purge.result_matches(purge.HttpResult(404, b""), deleted)[0])
298-
self.assertFalse(purge.result_matches(purge.HttpResult(200, b"old"), deleted)[0])
299-
300-
def test_verification_targets_skip_deleted_assets(self):
291+
def test_purge_all_requests_every_alias_and_path(self):
301292
value = contract()
302-
content = purge.AssetExpectation("cfg/a.ini", b"expected")
303-
deleted = purge.AssetExpectation("cfg/deleted.ini", None)
293+
expectations = [
294+
purge.AssetExpectation("cfg/a.ini", b"one"),
295+
purge.AssetExpectation("rule/b.yaml", b"two"),
296+
]
297+
calls: list[tuple[str, str, str]] = []
304298

305-
targets = purge.verification_targets([content, deleted], value)
299+
def record(repository: str, alias: str, path: str) -> str:
300+
calls.append((repository, alias, path))
301+
return f"https://purge.jsdelivr.net/{alias}/{path}"
306302

307-
self.assertEqual(len(targets), len(value.ref_aliases) * len(value.verify_hosts))
308-
self.assertTrue(all(target.expectation == content for target in targets))
309-
self.assertFalse(any("deleted.ini" in target.url for target in targets))
303+
with unittest.mock.patch.object(purge, "purge_target", side_effect=record):
304+
purge.purge_all(expectations, value, workers=1)
310305

311-
def test_verification_retries_only_stale_targets(self):
312-
target = purge.VerificationTarget(
313-
"https://cdn.jsdelivr.net/example", purge.AssetExpectation("cfg/a", b"new")
314-
)
315-
calls = 0
316-
sleeps: list[float] = []
317-
318-
def requester(_url: str) -> purge.HttpResult:
319-
nonlocal calls
320-
calls += 1
321-
return purge.HttpResult(200, b"old" if calls == 1 else b"new")
322-
323-
purge.verify_all(
324-
[target], requester=requester, attempts=2, workers=1, sleeper=sleeps.append
306+
self.assertEqual(
307+
set(calls),
308+
{
309+
(REPOSITORY, alias, expectation.path)
310+
for alias in value.ref_aliases
311+
for expectation in expectations
312+
},
325313
)
326-
self.assertEqual(calls, 2)
327-
self.assertEqual(sleeps, [2])
328314

329315

330316
class UrlContractTests(unittest.TestCase):

.github/workflows/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@
1717
| [`dependabot-auto-merge.yml`](dependabot-auto-merge.yml) | 等待 Validate、Dependency Review 和 CodeQL 全部成功后,自动压缩合并(squash merge)带有 `automerge` 标签的 Dependabot PR | 上述检查完成 |
1818
| [`dependency-review.yml`](dependency-review.yml) | 阻止 PR 引入任何已知等级的漏洞依赖,并展示 OpenSSF Scorecard 信息 | Pull Request |
1919
| [`pages.yml`](pages.yml) | 构建并部署 MkDocs 文档站点到 GitHub Pages | `wiki/**``mkdocs.yml` 变更或手动触发 |
20-
| [`purge-jsdelivr.yml`](purge-jsdelivr.yml) |[CDN 发布契约](../jsdelivr-publish.json) 精确刷新 `@main``@refs/heads/main` 缓存键;删除资源以 jsDelivr 接受刷新为完成,仍存在的资源继续逐字节验证;无公开文件变化时自动跳过刷新 | `main` 分支推送、规则生成且校验成功,或手动修复范围 |
20+
| [`purge-jsdelivr.yml`](purge-jsdelivr.yml) |[CDN 发布契约](../jsdelivr-publish.json) 精确刷新 `@main``@refs/heads/main` 缓存键;每个请求以 jsDelivr 返回 `finished` 为成功,不等待 CDN 节点传播;无公开文件变化时自动跳过刷新 | `main` 分支推送、规则生成且校验成功,或手动修复范围 |
2121
| [`sync-openclash-overwrite-submodule.yml`](sync-openclash-overwrite-submodule.yml) |`overwrite/OpenClash_Overwrite` 子模块同步到上游 `main` 分支 | 每 2 小时或手动触发 |
2222
| [`validate.yml`](validate.yml) | 校验 Shell、Python、Sub-Store、Wiki 备份、规则派生文件、MRS 和完整 Mihomo 模板 | 代码推送、Pull Request 或手动触发 |

.github/workflows/auto-generate-rules.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ jobs:
117117
echo "publication_sha=$publication_sha" >> "$GITHUB_OUTPUT"
118118
119119
# Generation and Mihomo validation already ran against the exact worktree that
120-
# was published. Verify the public bytes without re-running unrelated tests.
120+
# was published. Purge its changed cache keys without re-running unrelated tests.
121121
purge-generated:
122122
needs: generate-rules
123123
permissions:

.github/workflows/purge-jsdelivr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ jobs:
103103
echo "published=$published"
104104
} >> "$GITHUB_OUTPUT"
105105
106-
- name: Purge and verify every changed cache key
106+
- name: Purge every changed cache key
107107
env:
108108
REPOSITORY: ${{ github.repository }}
109109
BEFORE_SHA: ${{ steps.range.outputs.before }}

0 commit comments

Comments
 (0)