add # 新增Nonebot发布工作流

This commit is contained in:
2026-10-21 18:35:16 +08:00
parent c7d97e294a
commit 531409b8b6
26 changed files with 1585 additions and 19 deletions
+7 -1
View File
@@ -5,4 +5,10 @@ check_baseline.py 为已实现的离线校验工具。
检查清单哈希、缺失/新增文件、Python 3.10 语法、JSON 与静态作物库,
不导入旧插件、不联网、不读真实玩家库。
迁移、分包和远程同步工具尚未实施,不提供会覆盖仓库的占位脚本。
`build_zhenxun.py` / `build_nonebot.py` 从白名单生成对应框架仓库和 ZIP。
`verify_zhenxun.py` / `verify_nonebot.py` 用标准库校验生成清单与文件。
`publish_zhenxun.py` / `publish_nonebot.py` 默认离线报告,显式
`--publish` 才读取 `FARM_RELEASE_TOKEN` 并同步固定目标仓库。
`check_nonebot_runtime.py` 在临时数据目录检查已安装的 NoneBot 插件;
也可用 `--repository` 指定生成仓库,需要 NoneBot 依赖和 OneBot 适配器。
`check_workflows.py` 检查源工作流、生成工作流及发布权限边界。
+103
View File
@@ -0,0 +1,103 @@
"""从主仓库白名单生成标准 NoneBot 包仓库及可复现 ZIP。"""
import argparse
import ast
import hashlib
import json
from pathlib import Path
import re
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from build_zhenxun import read_version, source_identity
from verify_nonebot import MANIFEST, ROOT_NAME, verify_files, verify_package
def build(root, output):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
raise FileExistsError("Use a new output directory")
if output.is_relative_to(root) and not output.is_relative_to(root / "dist"):
raise ValueError("In-project output must be inside dist")
files = {}
prefix = ROOT_NAME + "/"
integration = root / "integrations/nonebot"
for name in ("__init__.py", "config.py", "commands.py", "data_paths.py"):
files[prefix + name] = (integration / name).read_bytes()
# 同一运行时源,避免两个适配器的业务命令与渲染行为发生分叉。
files[prefix + "runtime.py"] = (
root / "integrations/zhenxun/runtime.py"
).read_bytes()
files[prefix + "_vendor/__init__.py"] = b'"""Private farm modules."""\n'
for package in ("farm_core", "farm_render"):
source = root / "src" / package
selected = list(source.glob("*.py")) + [
path for path in (source / "data").iterdir()
if path.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"{prefix}_vendor/{package}/{relative}"] = content
for name, source in {
"LICENSE": "LICENSE", "README.md": "docs/12-nonebot.md",
"version.txt": "version.txt",
"images/farm-preview.png": "images/farm-preview.png",
"pyproject.toml": "release/nonebot/pyproject.toml",
"MANIFEST.in": "release/nonebot/MANIFEST.in",
"verify_package.py": "scripts/verify_nonebot.py",
".github/workflows/verify.yml": "release/nonebot/verify.yml",
".github/workflows/publish.yaml": "release/nonebot/publish.yaml",
}.items():
files[name] = (root / source).read_bytes()
files[prefix + "_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": "nonebot", "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)
for name, content in sorted(files.items()):
path = output / "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)
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
archive_path.with_suffix(".zip.sha256").write_text(
f"{digest} {archive_path.name}\n", encoding="ascii",
)
return archive_path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
print(build(Path(__file__).resolve().parents[1], args.output))
if __name__ == "__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
"""在临时目录加载真实 NoneBot 插件,离线验证生命周期与命令适配。"""
import argparse
import asyncio
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
from unittest.mock import patch
async def exercise(module, bot, event, Message, UniMessage):
from nonebot_plugin_alconna import Image
runtime = module.runtime
await runtime.start()
try:
session = SimpleNamespace(user=SimpleNamespace(id="10001", name="测试"))
with patch.object(UniMessage, "send", autospec=True) as send:
await module.handler_for("register")(
bot, event, session, Message(),
)
if "开通" not in str(send.call_args.args[0]):
raise AssertionError("Registration handler failed")
await module.handler_for("farm")(
bot, event, session, Message(),
)
# 无字体环境允许文字退化;有字体时必须生成统一图片段。
response = send.call_args.args[0]
if runtime.rendering and not response[Image]:
raise AssertionError("Image response was not a UniMessage")
await module.handler_for("steal")(
bot, event, session, Message("[CQ:at,qq=10002]"),
)
if "目标尚未" not in str(send.call_args.args[0]):
raise AssertionError("Mention target was not mapped")
await module.handler_for("steal")(
bot, event, session, Message("[CQ:at,qq=all]"),
)
if "具体" not in str(send.call_args.args[0]):
raise AssertionError("Everyone mention was not rejected")
context = dict(
bot_id=str(bot.self_id), platform=bot.adapter.get_name(),
user_id="10001", message_id="buy",
)
first = await runtime.execute("buy", "胡萝卜 1", **context)
second = await runtime.execute("buy", "胡萝卜 1", **context)
if first != second:
raise AssertionError("Repeated event was not idempotent")
context["message_id"] = None
refused = await runtime.execute("sow", "胡萝卜 1", **context)
if "未执行" not in refused:
raise AssertionError("Write without message ID was accepted")
finally:
await runtime.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", type=Path)
args = parser.parse_args()
if args.repository:
sys.path.insert(0, str(args.repository.resolve()))
import nonebot
from nonebot.adapters.onebot.v11 import Adapter, Bot, Message
from nonebot.adapters.onebot.v11.event import PrivateMessageEvent
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
nonebot.init(
driver="~none",
localstore_data_dir=root / "data",
localstore_config_dir=root / "config",
localstore_cache_dir=root / "cache",
)
driver = nonebot.get_driver()
driver.register_adapter(Adapter)
plugin = nonebot.load_plugin("nonebot_plugin_farm")
if plugin is None:
raise AssertionError("NoneBot plugin failed to load")
from nonebot_plugin_farm import commands, __version__
from nonebot_plugin_alconna import UniMessage
expected = (root / "data/nonebot_plugin_farm"
/ "nonebot_plugin_farm/farm_db")
if commands.runtime.data_dir != expected.resolve():
raise AssertionError("Legacy localstore directory changed")
adapter = driver._adapters[Adapter.get_name()]
bot = Bot(adapter, "12345")
event = PrivateMessageEvent(
time=0, self_id=12345, post_type="message",
sub_type="friend", user_id=10001, message_type="private",
message_id=1, message=Message(), original_message=Message(),
raw_message="", font=0, sender={"user_id": 10001},
)
asyncio.run(exercise(commands, bot, event, Message, UniMessage))
if any(name == "zhenxun" or name.startswith("zhenxun.")
for name in sys.modules):
raise AssertionError("Unexpected Zhenxun import")
print(f"NoneBot {__version__}: load, lifecycle, messages checked")
if __name__ == "__main__":
main()
+41 -1
View File
@@ -1,4 +1,4 @@
"""检查两个生成工作流的YAML及发布权限;依赖仅用于开发检查。"""
"""检查真寻与 NoneBot 工作流的 YAML 和发布边界。"""
import argparse
from pathlib import Path
@@ -46,6 +46,46 @@ def main():
raise ValueError("Generated workflow permissions differ")
if generated["on"]["push"]["branches"] != ["main"]:
raise ValueError("Generated branch differs")
nonebot = yaml.load((
root / ".github/workflows/nonebot-preview.yml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
if nonebot["permissions"] != {"contents": "read"}:
raise ValueError("NoneBot source permissions differ")
trigger = nonebot["on"]
if (set(trigger) != {"push", "pull_request", "workflow_dispatch"}
or trigger["push"]["branches"] != ["main"]
or set(trigger["workflow_dispatch"]["inputs"]) != {"publish"}
or trigger["workflow_dispatch"]["inputs"]["publish"]["default"]
!= "false"):
raise ValueError("NoneBot source triggers differ")
sync = nonebot["jobs"]["sync-preview"]
if (sync["environment"] != "zhenxun-preview"
or " ".join(sync["if"].split()) != expected_condition
or nonebot["jobs"]["build"]["needs"] != "test"
or sync["needs"] != ["test", "build"]):
raise ValueError("NoneBot sync boundary differs")
token = sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"]
if token != "${{ secrets.FARM_RELEASE_TOKEN }}":
raise ValueError("NoneBot must reuse FARM_RELEASE_TOKEN")
generated = yaml.load((
root / "release/nonebot/verify.yml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
if (generated["permissions"] != {"contents": "read"}
or generated["on"]["push"]["branches"] != ["main"]):
raise ValueError("NoneBot generated verification boundary differs")
publishing = yaml.load((
root / "release/nonebot/publish.yaml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
if (publishing["permissions"] != {"contents": "read"}
or publishing["on"] != {"push": {"tags": ["v*"]}}
or publishing["jobs"]["publish"]["needs"] != "build"
or publishing["jobs"]["publish"]["if"] != (
"github.repository == 'Shu-Ying/nonebot_plugin_farm'"
)):
raise ValueError("NoneBot PyPI boundary differs")
steps = publishing["jobs"]["publish"]["steps"]
if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}":
raise ValueError("Keep the existing PyPI secret")
print("Workflow YAML and publication guards checked")
+6
View File
@@ -156,6 +156,12 @@ def export(root):
bundle.writestr(item, content)
apply_positions(root, metadata)
apply_decorations(root, metadata)
# 与已发布的地块编号一致:前两行交错排列,初始六块组成 2×3。
# 标定仍使用上面的旧资源顺序;这里只调整最终运行时的编号位置。
positions = metadata["positions"]
metadata["positions"] = [positions[index] for index in (
0, 6, 1, 7, 2, 8, 3, 9, 4, 10, 5, 11,
)] + positions[12:]
return output.getvalue(), (
json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2)
+ "\n"
+105
View File
@@ -0,0 +1,105 @@
"""固定同步 NoneBot main;默认离线 dry-run,普通提交保留历史。"""
import argparse
import base64
import json
import os
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from verify_nonebot import read_package, verify_files
REPOSITORY = "Shu-Ying/nonebot_plugin_farm"
BRANCH = "main"
def github(method, path, payload, token, allow_missing=False):
url = f"https://api.github.com/repos/{REPOSITORY}"
if path:
url += f"/{path}"
request = Request(
url,
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-nonebot-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" and allow_missing:
return None
raise RuntimeError(
f"GitHub request failed: {method} {url}: 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,
allow_missing=path.startswith(("git/ref/heads/", "contents/")),
))
if not call("GET", "", None):
raise ValueError(f"Target repository is not accessible: {REPOSITORY}")
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 owned is not None:
if owned.get("encoding") != "base64":
raise ValueError("Existing Farm manifest encoding is invalid")
previous = json.loads(base64.b64decode(owned["content"]))
if (previous.get("target") != "nonebot"
or previous.get("plugin") != manifest["plugin"]):
raise ValueError("Existing 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 NoneBot farm {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("Target 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()
+105
View File
@@ -0,0 +1,105 @@
"""仅用标准库校验 NoneBot 生成仓库或 ZIP;不启动插件。"""
import argparse
import hashlib
import json
from pathlib import Path, PurePosixPath
import stat
from zipfile import ZipFile
ROOT_NAME = "nonebot_plugin_farm"
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") != "nonebot"
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 = {
"pyproject.toml", "MANIFEST.in", "version.txt", "LICENSE",
"nonebot_plugin_farm/__init__.py", "nonebot_plugin_farm/config.py",
"nonebot_plugin_farm/commands.py", "nonebot_plugin_farm/runtime.py",
"nonebot_plugin_farm/_vendor/farm_core/__init__.py",
"nonebot_plugin_farm/_vendor/farm_render/data/assets.zip",
".github/workflows/publish.yaml", ".github/workflows/verify.yml",
}
if not required <= set(files):
raise ValueError("Incomplete plugin repository")
if files["version.txt"].decode().strip() != manifest["version"]:
raise ValueError("Version file differs from manifest")
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 nonebot {manifest['version']}: "
f"{len(manifest['files'])} files")
if __name__ == "__main__":
main()