77import concurrent .futures
88import dataclasses
99import hashlib
10+ import html
1011import json
1112import re
1213import subprocess
2526DEFAULT_PURGE_WORKERS = 2
2627RETRY_DELAYS = (2 , 5 , 10 , 20 , 30 , 45 , 60 )
2728USER_AGENT = "Custom_OpenClash_Rules-jsDelivr-publisher/1.0"
29+ WORKER_ROOT_FILES = ("README.md" , "LICENCE" )
30+ WORKER_CANARY_PATH = "rule/Custom_Direct.list"
31+ WORKER_MAX_ASSET_COUNT = 20_000
32+ WORKER_MAX_ASSET_BYTES = 25 * 1024 * 1024
2833
2934
3035class PublishError (RuntimeError ):
@@ -38,6 +43,7 @@ class PublishContract:
3843 ref_aliases : tuple [str , ...]
3944 public_roots : frozenset [str ]
4045 deferred_sources : frozenset [str ]
46+ snapshot_deferred_inputs : frozenset [str ]
4147 generated_suffixes : tuple [str , ...]
4248 excluded_prefixes : tuple [str , ...]
4349 excluded_path_parts : frozenset [str ]
@@ -103,6 +109,9 @@ def load_contract(path: Path) -> PublishContract:
103109 raise PublishError (f"Invalid public root: { root !r} " )
104110
105111 deferred = _require_string_list (data , "deferred_sources" )
112+ snapshot_deferred_inputs = _require_string_list (
113+ data , "snapshot_deferred_inputs"
114+ )
106115 prefixes = _require_string_list (data , "excluded_prefixes" )
107116 excluded_parts = _require_string_list (data , "excluded_path_parts" )
108117 excluded_names = _require_string_list (data , "excluded_basenames" )
@@ -117,6 +126,7 @@ def load_contract(path: Path) -> PublishContract:
117126 ref_aliases = aliases ,
118127 public_roots = frozenset (roots ),
119128 deferred_sources = frozenset (deferred ),
129+ snapshot_deferred_inputs = frozenset (snapshot_deferred_inputs ),
120130 generated_suffixes = generated_suffixes ,
121131 excluded_prefixes = prefixes ,
122132 excluded_path_parts = frozenset (part .casefold () for part in excluded_parts ),
@@ -125,6 +135,8 @@ def load_contract(path: Path) -> PublishContract:
125135 for source in contract .deferred_sources :
126136 if not is_public_path (source , contract ):
127137 raise PublishError (f"Deferred source is not a public path: { source } " )
138+ for path in contract .snapshot_deferred_inputs :
139+ normalize_repo_path (path )
128140 for path in deferred_publication_paths (contract ):
129141 if not is_public_path (path , contract ):
130142 raise PublishError (f"Deferred generated path is not public: { path } " )
@@ -299,6 +311,195 @@ def build_expectations(
299311 ]
300312
301313
314+ def changed_paths_between (before_sha : str , after_sha : str ) -> frozenset [str ]:
315+ raw = run_git (
316+ ["diff" , "--name-status" , "-z" , "--find-renames" , before_sha , after_sha , "--" ]
317+ )
318+ assert isinstance (raw , bytes )
319+ paths : set [str ] = set ()
320+ for _status , changed_paths in parse_name_status_z (raw ):
321+ for path in changed_paths :
322+ paths .add (normalize_repo_path (path ))
323+ return frozenset (paths )
324+
325+
326+ def worker_snapshot_generation_inputs (
327+ contract : PublishContract ,
328+ ) -> frozenset [str ]:
329+ return contract .deferred_sources | contract .snapshot_deferred_inputs
330+
331+
332+ def plan_worker_snapshot (
333+ before_sha : str ,
334+ after_sha : str ,
335+ contract : PublishContract ,
336+ * ,
337+ generation_complete : bool ,
338+ ) -> tuple [bool , tuple [str , ...]]:
339+ changed = changed_paths_between (before_sha , after_sha )
340+ blocked = tuple (sorted (changed & worker_snapshot_generation_inputs (contract )))
341+ if blocked and not generation_complete :
342+ return False , blocked
343+ return True , blocked
344+
345+
346+ def worker_asset_prefix (contract : PublishContract ) -> str :
347+ repository_name = contract .repository .split ("/" , 1 )[1 ]
348+ return f"{ repository_name } /{ contract .branch } "
349+
350+
351+ def list_worker_snapshot_blobs (
352+ revision_sha : str , contract : PublishContract
353+ ) -> list [tuple [str , bytes ]]:
354+ raw = run_git (["ls-tree" , "-r" , "-z" , "--full-tree" , revision_sha ])
355+ assert isinstance (raw , bytes )
356+ selected : list [tuple [str , bytes ]] = []
357+ root_files = frozenset (WORKER_ROOT_FILES )
358+ for entry in raw .split (b"\0 " ):
359+ if not entry :
360+ continue
361+ try :
362+ metadata , encoded_path = entry .split (b"\t " , 1 )
363+ mode , object_type , _object_sha = metadata .decode ("ascii" ).split (" " )
364+ path = encoded_path .decode ("utf-8" , "surrogateescape" )
365+ except (ValueError , UnicodeDecodeError ) as exc :
366+ raise PublishError ("Malformed git ls-tree output" ) from exc
367+
368+ selected_path = path in root_files or is_public_path (path , contract )
369+ if not selected_path :
370+ continue
371+ normalize_repo_path (path )
372+ if object_type != "blob" or mode not in ("100644" , "100755" ):
373+ raise PublishError (
374+ f"Worker snapshot path is not a regular file: { path } "
375+ f"(mode={ mode } , type={ object_type } )"
376+ )
377+ content = blob_at (revision_sha , path )
378+ if len (content ) > WORKER_MAX_ASSET_BYTES :
379+ raise PublishError (
380+ f"Worker snapshot file exceeds 25 MiB: { path } ({ len (content )} bytes)"
381+ )
382+ selected .append ((path , content ))
383+
384+ selected .sort (key = lambda item : item [0 ])
385+ if not selected :
386+ raise PublishError ("Worker snapshot contains no public files" )
387+ if len (selected ) + 2 > WORKER_MAX_ASSET_COUNT :
388+ raise PublishError (
389+ f"Worker snapshot exceeds the free-plan file limit: { len (selected ) + 2 } "
390+ )
391+ return selected
392+
393+
394+ def commit_timestamp (revision_sha : str ) -> str :
395+ output = run_git (["show" , "-s" , "--format=%cI" , revision_sha ], text = True )
396+ assert isinstance (output , str )
397+ value = output .strip ()
398+ if not value :
399+ raise PublishError (f"Commit { revision_sha } has no timestamp" )
400+ return value
401+
402+
403+ def write_worker_snapshot (
404+ revision : str , output : Path , contract : PublishContract
405+ ) -> dict [str , object ]:
406+ revision_sha = resolve_commit (revision )
407+ output = output .resolve ()
408+ if output .exists ():
409+ if not output .is_dir ():
410+ raise PublishError (f"Worker asset output path is not a directory: { output } " )
411+ if any (output .iterdir ()):
412+ raise PublishError (f"Worker asset output directory is not empty: { output } " )
413+ output .mkdir (parents = True , exist_ok = True )
414+
415+ prefix = worker_asset_prefix (contract )
416+ blobs = list_worker_snapshot_blobs (revision_sha , contract )
417+ file_entries : dict [str , dict [str , object ]] = {}
418+ total_bytes = 0
419+ for path , content in blobs :
420+ destination = output .joinpath (* PurePosixPath (prefix , path ).parts )
421+ destination .parent .mkdir (parents = True , exist_ok = True )
422+ destination .write_bytes (content )
423+ file_entries [path ] = {
424+ "bytes" : len (content ),
425+ "sha256" : hashlib .sha256 (content ).hexdigest (),
426+ }
427+ total_bytes += len (content )
428+
429+ canary = file_entries .get (WORKER_CANARY_PATH )
430+ if canary is None :
431+ raise PublishError (f"Worker canary is missing: { WORKER_CANARY_PATH } " )
432+
433+ published_at = commit_timestamp (revision_sha )
434+ index = f"""<!doctype html>
435+ <html lang=\" zh-CN\" >
436+ <head>
437+ <meta charset=\" utf-8\" >
438+ <meta name=\" viewport\" content=\" width=device-width,initial-scale=1\" >
439+ <title>Custom_OpenClash_Rules main 镜像</title>
440+ </head>
441+ <body>
442+ <h1>Custom_OpenClash_Rules</h1>
443+ <p>这里提供 <code>main</code> 分支最后一次完整验证并部署的只读文件快照。</p>
444+ <p>快照提交:<code>{ html .escape (revision_sha )} </code></p>
445+ <p>提交时间:<time>{ html .escape (published_at )} </time></p>
446+ <ul>
447+ <li><a href=\" ./README.md\" >README.md</a></li>
448+ <li><a href=\" ./rule/Custom_Direct.list\" >Custom_Direct.list</a></li>
449+ <li><a href=\" /_mirror/Custom_OpenClash_Rules/main.json\" >快照清单</a></li>
450+ </ul>
451+ </body>
452+ </html>
453+ """
454+ index_path = output .joinpath (* PurePosixPath (prefix , "index.html" ).parts )
455+ index_path .write_text (index , encoding = "utf-8" , newline = "\n " )
456+
457+ manifest : dict [str , object ] = {
458+ "repository" : contract .repository ,
459+ "branch" : contract .branch ,
460+ "commit" : revision_sha ,
461+ "published_at" : published_at ,
462+ "file_count" : len (file_entries ),
463+ "total_bytes" : total_bytes ,
464+ "canary" : {
465+ "path" : WORKER_CANARY_PATH ,
466+ "bytes" : canary ["bytes" ],
467+ "sha256" : canary ["sha256" ],
468+ },
469+ "files" : file_entries ,
470+ }
471+ manifest_path = output / "_mirror" / contract .repository .split ("/" , 1 )[1 ]
472+ manifest_path .mkdir (parents = True , exist_ok = True )
473+ (manifest_path / f"{ contract .branch } .json" ).write_text (
474+ json .dumps (manifest , ensure_ascii = False , indent = 2 ) + "\n " ,
475+ encoding = "utf-8" ,
476+ newline = "\n " ,
477+ )
478+
479+ headers = f"""/{ prefix } /*
480+ Access-Control-Allow-Origin: *
481+ Cache-Control: public, max-age=300
482+ X-Content-Type-Options: nosniff
483+ X-Robots-Tag: noindex, nofollow, nosnippet
484+
485+ /{ prefix } /
486+ Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'
487+
488+ /_mirror/*
489+ Access-Control-Allow-Origin: *
490+ Cache-Control: no-store, max-age=0
491+ X-Content-Type-Options: nosniff
492+ X-Robots-Tag: noindex, nofollow, nosnippet
493+ """
494+ (output / "_headers" ).write_text (headers , encoding = "utf-8" , newline = "\n " )
495+
496+ print (
497+ f"Built Worker snapshot { revision_sha } : "
498+ f"{ len (file_entries )} files, { total_bytes } bytes"
499+ )
500+ return manifest
501+
502+
302503def encoded_asset_path (path : str ) -> str :
303504 return urllib .parse .quote (normalize_repo_path (path ), safe = "/" )
304505
@@ -476,6 +677,37 @@ def command_run(args: argparse.Namespace) -> None:
476677 purge_all (expectations , contract )
477678
478679
680+ def command_plan_worker_snapshot (args : argparse .Namespace ) -> None :
681+ contract = load_contract (args .contract )
682+ before_sha , after_sha = resolve_range (args .before , args .after )
683+ deployable , blocked = plan_worker_snapshot (
684+ before_sha ,
685+ after_sha ,
686+ contract ,
687+ generation_complete = args .generation_complete ,
688+ )
689+ reason = "ready"
690+ if blocked and not args .generation_complete :
691+ reason = "waiting_for_generated_outputs"
692+ elif blocked :
693+ reason = "generation_complete"
694+ print (f"Worker snapshot plan: deployable={ str (deployable ).lower ()} , reason={ reason } " )
695+ if blocked :
696+ print ("Generation-sensitive paths in range:" )
697+ for path in blocked :
698+ print (f" { path } " )
699+ if args .github_output is not None :
700+ with args .github_output .open ("a" , encoding = "utf-8" , newline = "\n " ) as handle :
701+ handle .write (f"worker_deployable={ str (deployable ).lower ()} \n " )
702+ handle .write (f"worker_plan_reason={ reason } \n " )
703+ handle .write (f"worker_after_sha={ after_sha } \n " )
704+
705+
706+ def command_build_worker_assets (args : argparse .Namespace ) -> None :
707+ contract = load_contract (args .contract )
708+ write_worker_snapshot (args .revision , args .output , contract )
709+
710+
479711def build_parser () -> argparse .ArgumentParser :
480712 parser = argparse .ArgumentParser (description = __doc__ )
481713 parser .add_argument (
@@ -499,6 +731,22 @@ def build_parser() -> argparse.ArgumentParser:
499731 help = "Latest main snapshot used to resolve current asset state" ,
500732 )
501733 run .add_argument ("--mode" , choices = ("direct" , "complete" ), required = True )
734+
735+ plan_worker = subparsers .add_parser (
736+ "plan-worker-snapshot" ,
737+ help = "Decide whether an exact Worker snapshot is generation-complete" ,
738+ )
739+ plan_worker .add_argument ("--before" , required = True )
740+ plan_worker .add_argument ("--after" , required = True )
741+ plan_worker .add_argument ("--generation-complete" , action = "store_true" )
742+ plan_worker .add_argument ("--github-output" , type = Path )
743+
744+ build_worker = subparsers .add_parser (
745+ "build-worker-assets" ,
746+ help = "Build an exact Git revision as Workers Static Assets" ,
747+ )
748+ build_worker .add_argument ("--revision" , required = True )
749+ build_worker .add_argument ("--output" , type = Path , required = True )
502750 return parser
503751
504752
@@ -511,6 +759,10 @@ def main(argv: Sequence[str] | None = None) -> int:
511759 validate_contract_urls (contract , args .revision )
512760 elif args .command == "run" :
513761 command_run (args )
762+ elif args .command == "plan-worker-snapshot" :
763+ command_plan_worker_snapshot (args )
764+ elif args .command == "build-worker-assets" :
765+ command_build_worker_assets (args )
514766 else : # pragma: no cover - argparse enforces the command set
515767 parser .error (f"Unknown command: { args .command } " )
516768 except PublishError as exc :
0 commit comments