Skip to content

Commit 1e585c4

Browse files
committed
feat(mirror): add automatic Cloudflare repository fallback
1 parent 83d9b8f commit 1e585c4

18 files changed

Lines changed: 3543 additions & 3 deletions

.github/jsdelivr-publish.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@
4242
"rule/game_rule/Microsoft-Flight-Simulator-2020/Microsoft-Flight-Simulator-2020_All-Servers.list",
4343
"rule/game_rule/Overwatch2/Overwatch2_Asia-Singapore.list"
4444
],
45+
"snapshot_deferred_inputs": [
46+
"py/generate_rules.py",
47+
"py/generate_stash_configs.py",
48+
"cfg/Custom_Clash.ini",
49+
"cfg/Custom_Clash_Fallback.ini",
50+
"cfg/Custom_Clash_Lite.ini",
51+
"cfg/Custom_Clash_Lite_Fallback.ini",
52+
"cfg/Custom_Clash_GFW.ini",
53+
"cfg/Custom_Clash_GFW_Fallback.ini",
54+
"cfg/Custom_Clash_Full.ini",
55+
"cfg/Custom_Clash_Full_Fallback.ini"
56+
],
4557
"generated_suffixes": [
4658
"Domain.yaml",
4759
"Domain.mrs",

.github/scripts/jsdelivr_purge.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import concurrent.futures
88
import dataclasses
99
import hashlib
10+
import html
1011
import json
1112
import re
1213
import subprocess
@@ -25,6 +26,10 @@
2526
DEFAULT_PURGE_WORKERS = 2
2627
RETRY_DELAYS = (2, 5, 10, 20, 30, 45, 60)
2728
USER_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

3035
class 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+
302503
def 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+
479711
def 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

Comments
 (0)