mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-11-03 03:00:43 +08:00
add # 新增对zhenxun的工作流
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""从主仓库白名单构建真寻独立预览包,无远端操作。"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
||||
|
||||
from verify_zhenxun import MANIFEST, ROOT_NAME, verify_files, verify_package
|
||||
|
||||
|
||||
def source_identity(root):
|
||||
def git(*args):
|
||||
return subprocess.check_output(
|
||||
["git", *args], cwd=root, text=True, encoding="utf-8",
|
||||
).strip()
|
||||
return {
|
||||
"repository": "https://github.com/Shu-Ying/main_plugin_farm.git",
|
||||
"commit": git("rev-parse", "HEAD"),
|
||||
"dirty": bool(git("status", "--porcelain", "--untracked-files=normal")),
|
||||
}
|
||||
|
||||
|
||||
def build(root, output, version):
|
||||
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z0-9.-]+)?", version):
|
||||
raise ValueError("Invalid preview version")
|
||||
root, output = Path(root).resolve(), Path(output).resolve()
|
||||
if output.exists():
|
||||
raise FileExistsError("Use a new output directory")
|
||||
if not output.is_relative_to(root / "dist") and output.is_relative_to(root):
|
||||
if any(output.is_relative_to(root / part) for part in (
|
||||
"src", "integrations", "scripts", "legacy", ".git",
|
||||
)):
|
||||
raise ValueError("Output must not be a source directory")
|
||||
files = {}
|
||||
integration = root / "integrations/zhenxun"
|
||||
for name in (
|
||||
"__init__.py", "runtime.py", "commands.py", "requirements.txt",
|
||||
):
|
||||
files[name] = (integration / name).read_bytes()
|
||||
files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n'
|
||||
for package in ("farm_core", "farm_render"):
|
||||
source = root / "src" / package
|
||||
selected = list(source.glob("*.py")) + [
|
||||
p for p in (source / "data").iterdir() if p.suffix in (
|
||||
".json", ".zip", ".md",
|
||||
)
|
||||
]
|
||||
for path in selected:
|
||||
if path.is_symlink():
|
||||
raise ValueError("Source links are not allowed")
|
||||
content = path.read_bytes()
|
||||
if package == "farm_render" and path.suffix == ".py":
|
||||
content = re.sub(
|
||||
rb"(?m)^from farm_core(?=[. ])", b"from ..farm_core",
|
||||
content,
|
||||
)
|
||||
relative = path.relative_to(source).as_posix()
|
||||
files[f"_vendor/{package}/{relative}"] = content
|
||||
for name, source in {
|
||||
"LICENSE": root / "LICENSE",
|
||||
"README.md": root / "docs/11-zhenxun-preview.md",
|
||||
"verify_package.py": root / "scripts/verify_zhenxun.py",
|
||||
".github/workflows/verify.yml": root / "release/zhenxun/verify.yml",
|
||||
}.items():
|
||||
files[name] = source.read_bytes()
|
||||
files["_version.py"] = f'__version__ = "{version}"\n'.encode()
|
||||
for name, content in files.items():
|
||||
if name.endswith(".py"):
|
||||
ast.parse(content, filename=name, feature_version=(3, 10))
|
||||
manifest = {
|
||||
"schema": 1, "target": "zhenxun", "plugin": ROOT_NAME,
|
||||
"version": version, "source": source_identity(root),
|
||||
"files": {name: hashlib.sha256(content).hexdigest()
|
||||
for name, content in sorted(files.items())},
|
||||
}
|
||||
files[MANIFEST] = (json.dumps(
|
||||
manifest, ensure_ascii=False, sort_keys=True, indent=2,
|
||||
) + "\n").encode()
|
||||
verify_files(files)
|
||||
output.mkdir(parents=True)
|
||||
repository = output / "repository"
|
||||
for name, content in sorted(files.items()):
|
||||
path = repository / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
archive_path = output / f"{ROOT_NAME}-{version}.zip"
|
||||
with ZipFile(archive_path, "w", compression=ZIP_DEFLATED) as archive:
|
||||
for name, content in sorted(files.items()):
|
||||
item = ZipInfo(f"{ROOT_NAME}/{name}", (2020, 1, 1, 0, 0, 0))
|
||||
item.create_system = 3
|
||||
item.compress_type = ZIP_DEFLATED
|
||||
item.external_attr = 0o100644 << 16
|
||||
archive.writestr(item, content)
|
||||
verify_package(archive_path)
|
||||
checksum = hashlib.sha256(archive_path.read_bytes()).hexdigest()
|
||||
archive_path.with_suffix(".zip.sha256").write_text(
|
||||
f"{checksum} {archive_path.name}\n", encoding="ascii",
|
||||
)
|
||||
return archive_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--version", default="0.1.0-preview.1")
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
print(build(root, args.output, args.version))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""在显式隔离依赖路径下检查绘图及真寻测试,不修改宿主环境。"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
def main():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dependency-path", type=Path, required=True)
|
||||
parser.add_argument("--expected-version", default="10.4.0")
|
||||
parser.add_argument("--pattern", action="append")
|
||||
args = parser.parse_args()
|
||||
sys.path.insert(0, str(root / "src"))
|
||||
sys.path.insert(0, str(args.dependency_path.resolve(strict=True)))
|
||||
import PIL
|
||||
if PIL.__version__ != args.expected_version:
|
||||
raise SystemExit("Unexpected Pillow version")
|
||||
print(f"Pillow compatibility check: {PIL.__version__}", flush=True)
|
||||
suite = unittest.TestSuite()
|
||||
for pattern in args.pattern or ("test_render.py", "test_zhenxun*.py"):
|
||||
suite.addTests(unittest.defaultTestLoader.discover(
|
||||
str(root / "tests"), pattern=pattern,
|
||||
))
|
||||
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,44 @@
|
||||
"""检查两个生成工作流的YAML及发布权限;依赖仅用于开发检查。"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dependency-path", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.dependency_path:
|
||||
sys.path.insert(0, str(args.dependency_path.resolve(strict=True)))
|
||||
import yaml
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = yaml.load((
|
||||
root / ".github/workflows/zhenxun-preview.yml"
|
||||
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
|
||||
generated = yaml.load((
|
||||
root / "release/zhenxun/verify.yml"
|
||||
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
|
||||
if source["permissions"] != {"contents":"read"}:
|
||||
raise ValueError("Source workflow permissions differ")
|
||||
trigger = source["on"]
|
||||
if set(trigger) != {"push", "pull_request", "workflow_dispatch"}:
|
||||
raise ValueError("Unsafe or missing workflow trigger")
|
||||
inputs = trigger["workflow_dispatch"]["inputs"]
|
||||
if inputs["publish"]["default"] != "false":
|
||||
raise ValueError("Publish must be opt-in")
|
||||
sync = source["jobs"]["sync-preview"]
|
||||
if (sync["environment"] != "zhenxun-preview"
|
||||
or "refs/heads/main" not in sync["if"]
|
||||
or "workflow_dispatch" not in sync["if"]
|
||||
or sync["needs"] != ["test", "build"]):
|
||||
raise ValueError("Publish boundary differs")
|
||||
if generated["permissions"] != {"contents":"read"}:
|
||||
raise ValueError("Generated workflow permissions differ")
|
||||
if generated["on"]["push"]["branches"] != ["farm-preview"]:
|
||||
raise ValueError("Generated branch differs")
|
||||
print("Workflow YAML and publication guards checked")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""为CI选择现有字体;部署仍须显式配置中文字体。"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def choose_font():
|
||||
configured = os.environ.get("FARM_TEST_FONT")
|
||||
candidates = ([configured] if configured else []) + [
|
||||
"C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simsun.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"C:/Windows/Fonts/arial.ttf",
|
||||
]
|
||||
for name in candidates:
|
||||
if Path(name).is_file():
|
||||
return name
|
||||
raise RuntimeError("Set FARM_TEST_FONT to an existing font")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
path = choose_font()
|
||||
environment_file = os.environ.get("GITHUB_ENV")
|
||||
if environment_file:
|
||||
with open(environment_file, "a", encoding="utf-8") as output:
|
||||
output.write(f"FARM_TEST_FONT={path}\n")
|
||||
print(path)
|
||||
@@ -45,7 +45,10 @@ def export(root):
|
||||
metadata["images"][name] = {
|
||||
"size": size, "sha256": hashlib.sha256(content).hexdigest(),
|
||||
}
|
||||
bundle.writestr(ZipInfo(name, (2020, 1, 1, 0, 0, 0)), content)
|
||||
item = ZipInfo(name, (2020, 1, 1, 0, 0, 0))
|
||||
item.create_system = 3
|
||||
item.external_attr = 0o100644 << 16
|
||||
bundle.writestr(item, content)
|
||||
return output.getvalue(), (
|
||||
json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
+ "\n"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""安装到显式指定的插件父目录;拒绝覆盖,不安装依赖或重启宿主。"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from verify_zhenxun import ROOT_NAME, read_package
|
||||
|
||||
|
||||
def install(package, plugins):
|
||||
files = read_package(package)
|
||||
plugins = Path(plugins).resolve(strict=True)
|
||||
if not plugins.is_dir():
|
||||
raise ValueError("Plugin parent must be an existing directory")
|
||||
target = plugins / ROOT_NAME
|
||||
if target.exists() or target.is_symlink():
|
||||
raise FileExistsError(
|
||||
"Target already exists; backup/uninstall manually",
|
||||
)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=".farm-install-", dir=plugins,
|
||||
) as temporary:
|
||||
staging = Path(temporary) / ROOT_NAME
|
||||
staging.mkdir()
|
||||
for name, content in files.items():
|
||||
path = staging / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
# 独占创建避免并发覆盖;失败仅清理本次新建目标。
|
||||
target.mkdir()
|
||||
try:
|
||||
for item in staging.iterdir():
|
||||
shutil.move(str(item), str(target / item.name))
|
||||
except BaseException:
|
||||
if target.resolve().parent == plugins:
|
||||
shutil.rmtree(target)
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", type=Path)
|
||||
parser.add_argument("--plugins-dir", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
print(install(args.package, args.plugins_dir))
|
||||
print("Installed files only. Configure font/data, disable old plugin, "
|
||||
"then start the bot manually.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""只向固定真寻预览分支普通更新;默认离线dry-run。"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from verify_zhenxun import read_package, verify_files
|
||||
|
||||
REPOSITORY = "Shu-Ying/zhenxun_plugin_farm"
|
||||
BRANCH = "farm-preview"
|
||||
|
||||
|
||||
def github(method, path, payload, token):
|
||||
request = Request(
|
||||
f"https://api.github.com/repos/{REPOSITORY}/{path}",
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "farm-preview-publisher"}, method=method,
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return json.load(response)
|
||||
except HTTPError as error:
|
||||
if error.code == 404 and method == "GET":
|
||||
return None
|
||||
raise RuntimeError(
|
||||
f"GitHub request failed: HTTP {error.code}",
|
||||
) from None
|
||||
|
||||
|
||||
def publish(package, confirmed=False, request=None):
|
||||
files = read_package(package)
|
||||
manifest = verify_files(files)
|
||||
result = {"repository": REPOSITORY, "branch": BRANCH,
|
||||
"version": manifest["version"], "published": False}
|
||||
if not confirmed:
|
||||
return result
|
||||
if manifest["source"]["dirty"]:
|
||||
raise ValueError("Publish requires a clean source checkout")
|
||||
token = os.environ.get("FARM_RELEASE_TOKEN")
|
||||
if not token:
|
||||
raise ValueError("Configure FARM_RELEASE_TOKEN in a protected job")
|
||||
call = request or (lambda method, path, data: github(
|
||||
method, path, data, token,
|
||||
))
|
||||
repository = call("GET", "", None)
|
||||
if not repository or repository.get("default_branch") == BRANCH:
|
||||
raise ValueError("Preview must not be the repository default branch")
|
||||
ref = call("GET", f"git/ref/heads/{BRANCH}", None)
|
||||
parent = ref["object"]["sha"] if ref else None
|
||||
if parent:
|
||||
owned = call("GET", f"contents/FARM-MANIFEST.json?ref={BRANCH}", None)
|
||||
if not owned or owned.get("encoding") != "base64":
|
||||
raise ValueError("Existing preview branch is not generated by Farm")
|
||||
previous = json.loads(base64.b64decode(owned["content"]))
|
||||
if (previous.get("target") != "zhenxun"
|
||||
or previous.get("plugin") != manifest["plugin"]):
|
||||
raise ValueError("Existing preview branch ownership differs")
|
||||
tree = []
|
||||
for name, content in sorted(files.items()):
|
||||
blob = call("POST", "git/blobs", {
|
||||
"content": base64.b64encode(content).decode(), "encoding":"base64",
|
||||
})
|
||||
tree.append({"path":name, "mode":"100644", "type":"blob",
|
||||
"sha":blob["sha"]})
|
||||
generated = call("POST", "git/trees", {"tree":tree})
|
||||
commit = call("POST", "git/commits", {
|
||||
"message": f"Generate farm preview {manifest['version']} "
|
||||
f"from {manifest['source']['commit']}",
|
||||
"tree": generated["sha"], "parents": [parent] if parent else [],
|
||||
})
|
||||
if parent:
|
||||
current = call("GET", f"git/ref/heads/{BRANCH}", None)
|
||||
if not current or current["object"]["sha"] != parent:
|
||||
raise RuntimeError("Preview branch changed; retry explicitly")
|
||||
call("PATCH", f"git/refs/heads/{BRANCH}", {
|
||||
"sha": commit["sha"], "force": False,
|
||||
})
|
||||
else:
|
||||
call("POST", "git/refs", {
|
||||
"ref": f"refs/heads/{BRANCH}", "sha": commit["sha"],
|
||||
})
|
||||
return {**result, "published": True, "commit": commit["sha"]}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package")
|
||||
parser.add_argument("--publish", action="store_true")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(publish(args.package, args.publish), ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""独立验证生成的真寻包;仅标准库,不导入或启动插件。"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import stat
|
||||
from zipfile import ZipFile
|
||||
|
||||
ROOT_NAME = "zhenxun_plugin_farm_preview"
|
||||
MANIFEST = "FARM-MANIFEST.json"
|
||||
MAX_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def safe_path(name):
|
||||
path = PurePosixPath(name)
|
||||
if (not name or "\\" in name or ":" in name or path.is_absolute()
|
||||
or any(part in (".", "..", "") for part in name.split("/"))
|
||||
or path.as_posix() != name):
|
||||
raise ValueError("Unsafe package path")
|
||||
return path
|
||||
|
||||
|
||||
def verify_files(files):
|
||||
if MANIFEST not in files or len(files[MANIFEST]) > 1024 * 1024:
|
||||
raise ValueError("Missing or oversized manifest")
|
||||
manifest = json.loads(files[MANIFEST])
|
||||
if (manifest.get("schema") != 1 or manifest.get("target") != "zhenxun"
|
||||
or manifest.get("plugin") != ROOT_NAME):
|
||||
raise ValueError("Invalid package identity")
|
||||
expected = manifest["files"]
|
||||
if (not isinstance(expected, dict)
|
||||
or set(files) != set(expected) | {MANIFEST}):
|
||||
raise ValueError("Package files differ from manifest")
|
||||
for name, digest in expected.items():
|
||||
safe_path(name)
|
||||
if hashlib.sha256(files[name]).hexdigest() != digest:
|
||||
raise ValueError("Package checksum mismatch")
|
||||
required = {"__init__.py", "runtime.py", "commands.py", "requirements.txt",
|
||||
"LICENSE", "_vendor/farm_core/__init__.py",
|
||||
"_vendor/farm_render/data/assets.zip"}
|
||||
if not required <= set(files):
|
||||
raise ValueError("Incomplete plugin")
|
||||
return manifest
|
||||
|
||||
|
||||
def read_package(path):
|
||||
with ZipFile(path) as archive:
|
||||
items = archive.infolist()
|
||||
names = [item.filename for item in items]
|
||||
if (len(names) != len(set(names)) or len(names) > 200
|
||||
or sum(item.file_size for item in items) > MAX_BYTES):
|
||||
raise ValueError("Duplicate paths or package too large")
|
||||
files = {}
|
||||
for item in items:
|
||||
name = safe_path(item.filename)
|
||||
if (len(name.parts) < 2 or name.parts[0] != ROOT_NAME
|
||||
or item.is_dir()
|
||||
or stat.S_ISLNK(item.external_attr >> 16)):
|
||||
raise ValueError("Unexpected package root or link")
|
||||
files[name.relative_to(ROOT_NAME).as_posix()] = archive.read(item)
|
||||
verify_files(files)
|
||||
return files
|
||||
|
||||
|
||||
def verify_package(path):
|
||||
return verify_files(read_package(path))
|
||||
|
||||
|
||||
def verify_directory(directory):
|
||||
directory = Path(directory).resolve()
|
||||
files = {}
|
||||
for path in directory.rglob("*"):
|
||||
relative = path.relative_to(directory)
|
||||
if any(part in (".git", "__pycache__") for part in relative.parts):
|
||||
continue
|
||||
if path.is_symlink():
|
||||
raise ValueError("Unexpected symbolic link")
|
||||
if path.is_file():
|
||||
files[relative.as_posix()] = path.read_bytes()
|
||||
return verify_files(files)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path)
|
||||
parser.add_argument("--directory", type=Path)
|
||||
args = parser.parse_args()
|
||||
if bool(args.package) == bool(args.directory):
|
||||
parser.error("Choose a ZIP or --directory")
|
||||
manifest = (verify_directory(args.directory) if args.directory
|
||||
else verify_package(args.package))
|
||||
print(f"Verified zhenxun {manifest['version']}: "
|
||||
f"{len(manifest['files'])} files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user