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
44from __future__ import annotations
55
2323PURGE_HOST = "purge.jsdelivr.net"
2424DEFAULT_PURGE_ATTEMPTS = 5
2525DEFAULT_PURGE_WORKERS = 2
26- DEFAULT_VERIFY_ATTEMPTS = 8
27- DEFAULT_VERIFY_WORKERS = 4
2826RETRY_DELAYS = (2 , 5 , 10 , 20 , 30 , 45 , 60 )
2927USER_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-
7566def _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
365334def 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-
487385def _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
582479def 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
0 commit comments