diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..98e88e17 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +/plugins/registry/ @EstrellaXD +/plugins/revoked.toml @EstrellaXD diff --git a/.github/PULL_REQUEST_TEMPLATE/plugin.md b/.github/PULL_REQUEST_TEMPLATE/plugin.md new file mode 100644 index 00000000..f05ce401 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/plugin.md @@ -0,0 +1,28 @@ + + +## Plugin + +- id: +- repo: +- commit (40-char SHA): +- New plugin / update (old version -> new version): + +## Author checklist + +- [ ] This PR changes only files under `plugins/registry/`. +- [ ] `commit` is a full 40-character SHA, and it is pushed to a public repository. +- [ ] The file name equals the `id` in `plugin.toml`. +- [ ] `tests/` has at least 1 test, and all tests pass. +- [ ] `ab-plugin validate` passes. +- [ ] For an update, `version` is higher than the listed version, and `repo` is unchanged. +- [ ] If the plugin has a frontend, its source is in `web-src/` with `package.json` and `pnpm-lock.yaml`. +- [ ] The plugin depends only on the standard library and packages that AutoBangumi ships. Other pure-Python libraries are in `vendor/`. +- [ ] I agree that AutoBangumi redistributes the packed plugin in its release. + +## Maintainer review checklist + +- [ ] I read all code at the compare link (update) or tree link (new plugin) in the Job Summary. +- [ ] The plugin connects only to documented hosts. +- [ ] The plugin writes nothing outside `config/`. +- [ ] If there is a frontend, I read `web-src/` line by line. It runs on the main origin. +- [ ] The declared `permissions` match what the code does. diff --git a/.github/workflows/plugin-registry.yml b/.github/workflows/plugin-registry.yml new file mode 100644 index 00000000..8b9c4ea5 --- /dev/null +++ b/.github/workflows/plugin-registry.yml @@ -0,0 +1,119 @@ +name: Plugin Registry + +# 插件市场:plugins/registry/.toml 指向作者仓库的固定 commit。 +# - check:PR 上运行作者的代码(测试、pnpm 构建),所以只读权限、不读任何 secret。 +# - build:合并后打包,同样没有密钥;commit 未变的条目复用已发布的 zip。 +# - sign:只有这个 job 读 PLUGIN_SIGNING_KEY,它不运行作者的任何代码, +# 只用 build_plugin_catalog.py 读 zip 里的 plugin.toml 并签名。 +on: + pull_request: + branches: ["4.0-dev"] + paths: ["plugins/**", "scripts/plugin_registry.py", ".github/workflows/plugin-registry.yml"] + push: + branches: ["4.0-dev"] + paths: ["plugins/**"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: plugin-registry-${{ github.event_name == 'pull_request' && github.ref || 'publish' }} + cancel-in-progress: false + +jobs: + check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # 需要 base 分支上的旧条目 + - &python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - &uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + - &pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + run_install: false + - &node + uses: actions/setup-node@v4 + with: + node-version: 20 + - &deps + name: Install backend + run: mkdir -p backend/config && cd backend && uv sync --group dev + - name: Check changed entries + run: | + set -o pipefail + uv run --project backend python scripts/plugin_registry.py check \ + --base "origin/${{ github.base_ref }}" --out dist | tee -a "$GITHUB_STEP_SUMMARY" + + build: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - *python + - *uv + - *pnpm + - *node + - *deps + - name: Download current catalog + env: + GH_TOKEN: ${{ github.token }} + run: gh release download plugins -p catalog.json -D old || echo "release plugins 还不存在,全部重新打包" + - name: Build + run: uv run --project backend python scripts/plugin_registry.py build --old old/catalog.json --out dist + - uses: actions/upload-artifact@v4 + with: + name: plugins-dist + path: dist/ + retention-days: 7 + + sign: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + version: "latest" + - uses: actions/download-artifact@v4 + with: + name: plugins-dist + path: dist + - name: Sign catalog + env: + PLUGIN_SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }} + run: | + if [ -z "$PLUGIN_SIGNING_KEY" ]; then + echo "::error::PLUGIN_SIGNING_KEY secret is not set" >&2 + exit 1 + fi + umask 077 + printf '%s\n' "$PLUGIN_SIGNING_KEY" > "$RUNNER_TEMP/key.pem" + uv run --no-project --with cryptography python scripts/build_plugin_catalog.py \ + --key "$RUNNER_TEMP/key.pem" --sources dist/sources.json --out release-assets dist/*.zip + rm -f "$RUNNER_TEMP/key.pem" + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + # tag 名不含 beta / alpha,不会触发 build.yml 的镜像构建 + gh release view plugins >/dev/null 2>&1 || gh release create plugins \ + --target "${{ github.sha }}" --latest=false --title "Plugin catalog" \ + --notes "AutoBangumi 插件市场的签名目录,由 plugin-registry 工作流维护。" + gh release upload plugins release-assets/* --clobber + # 下架:删除不在新目录中的旧 zip 与签名 + for asset in $(gh release view plugins --json assets -q '.assets[].name'); do + [ -e "release-assets/$asset" ] || gh release delete-asset plugins "$asset" -y + done diff --git a/backend/src/ab_plugin_pubkey.pem b/backend/src/ab_plugin_pubkey.pem new file mode 100644 index 00000000..aeff9fd8 --- /dev/null +++ b/backend/src/ab_plugin_pubkey.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAibhk4Ff1MpvKlC7048XFZ586nbSKUhN/Af9omqBMObg= +-----END PUBLIC KEY----- diff --git a/backend/src/ab_sdk/cli.py b/backend/src/ab_sdk/cli.py index 06a23a89..0d85b9f5 100644 --- a/backend/src/ab_sdk/cli.py +++ b/backend/src/ab_sdk/cli.py @@ -15,7 +15,10 @@ from . import SDK_VERSION, points from .manifest import ID_RE, MANIFEST_NAME, TOOLING_DIRS, PluginManifest, check # 打包时排除的目录名与文件后缀:开发产物、测试与作者侧的工程文件 -_PACK_EXCLUDE_DIRS = TOOLING_DIRS | {"tests", "web-src"} # web-src 是前端源码,运行时只用构建出的 web/ +_PACK_EXCLUDE_DIRS = TOOLING_DIRS | { + "tests", + "web-src", +} # web-src 是前端源码,运行时只用构建出的 web/ _PACK_EXCLUDE_FILES = frozenset({".DS_Store", "pyproject.toml", "uv.lock"}) _ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) # 固定时间戳,同样的内容打出同样的 sha256 diff --git a/backend/src/module/api/plugins.py b/backend/src/module/api/plugins.py index d5a73177..15edef25 100644 --- a/backend/src/module/api/plugins.py +++ b/backend/src/module/api/plugins.py @@ -63,6 +63,13 @@ class CatalogEntry(BaseModel): extension_points: list[str] = [] description: str = "" min_ab_version: str = "0.0.0" + authors: list[str] = [] + permissions: list[str] = [] + has_web: bool = False + # 源码位置:作者仓库与固定的 commit(插件市场的索引条目) + repo: str = "" + commit: str = "" + readme: str = "" installed_version: str | None diff --git a/backend/src/module/plugin/installer.py b/backend/src/module/plugin/installer.py index 4e23437b..7d2c2bd5 100644 --- a/backend/src/module/plugin/installer.py +++ b/backend/src/module/plugin/installer.py @@ -39,6 +39,9 @@ GITHUB_OWNER = "EstrellaXD" GITHUB_REPO = "Auto_Bangumi" _DL_HEADERS = {"User-Agent": "AutoBangumi-Plugins"} DEFAULT_PLUGINS_ROOT = Path("config") / "plugins" +# 通用插件目录的公钥(与在线更新分开的一把密钥,私钥在 CI 的 PLUGIN_SIGNING_KEY): +# 插件签名只代表“维护者审查过这份代码”,不能用来批准更新包 +PLUGIN_PUBKEY_PATH = Path("ab_plugin_pubkey.pem") def release_base(tag: str) -> str: @@ -266,6 +269,10 @@ class PluginInstaller(SignedCatalogInstaller): catalog_schema = 2 manifest_name = MANIFEST_NAME + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("pubkey_path", PLUGIN_PUBKEY_PATH) + super().__init__(**kwargs) + def _reject_reason(self, plugin_id: str) -> Optional[str]: if (BUILTIN_ROOT / plugin_id).is_dir(): return f"{plugin_id} is a builtin plugin" diff --git a/backend/src/test/test_plugin_catalog.py b/backend/src/test/test_plugin_catalog.py index 4a80c004..e66453a9 100644 --- a/backend/src/test/test_plugin_catalog.py +++ b/backend/src/test/test_plugin_catalog.py @@ -23,8 +23,9 @@ from cryptography.hazmat.primitives.serialization import ( from ab_sdk.cli import main, pack from ab_sdk.manifest import load_manifest from module.models.config import Plugins -from module.plugin.installer import PluginInstaller +from module.plugin.installer import PLUGIN_PUBKEY_PATH, PluginInstaller from module.plugin.loader import discover +from module.update.signing import DEFAULT_PUBKEY_PATH PLUGIN_ID = "catalog-demo" @@ -227,6 +228,10 @@ class TestInstall: assert not result.success and unmanaged.is_dir() + def test_default_pubkey_is_the_plugin_key_not_the_update_key(self): + assert PluginInstaller().pubkey_path == PLUGIN_PUBKEY_PATH + assert PLUGIN_PUBKEY_PATH != DEFAULT_PUBKEY_PATH + async def test_uninstall_leaves_llm_provider_plugins_alone(self, tmp_path, keypair): installer = make_installer(tmp_path, keypair, build_zip(tmp_path)) # LLM 插件:有 installed.json 与 plugin.json,没有 plugin.toml @@ -262,7 +267,10 @@ class TestReleaseScript: archive.parent.mkdir() archive.write_bytes(build_zip(tmp_path)) - module.build([archive], tmp_path / "release", key_path) + source = {"repo": "alice/ab-demo", "commit": "a" * 40, "readme": "# demo"} + module.build( + [archive], tmp_path / "release", key_path, sources={PLUGIN_ID: source} + ) released = tmp_path / "release" @@ -278,6 +286,8 @@ class TestReleaseScript: ) [entry] = await installer.fetch_catalog() assert entry["extension_points"] == ["rename_strategy"] + assert {k: entry[k] for k in source} == source + assert entry["has_web"] is False assert (await installer.install(PLUGIN_ID)).success diff --git a/backend/src/test/test_plugin_registry.py b/backend/src/test/test_plugin_registry.py new file mode 100644 index 00000000..d39198e9 --- /dev/null +++ b/backend/src/test/test_plugin_registry.py @@ -0,0 +1,187 @@ +"""插件市场索引工具 scripts/plugin_registry.py:条目解析、上架规则、测试门槛与增量构建。""" + +import hashlib +import importlib.util +from pathlib import Path + +import pytest + +from ab_sdk.cli import main, pack +from ab_sdk.manifest import load_manifest + +SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "plugin_registry.py" +SHA = "a" * 40 +PLUGIN_ID = "registry-demo" + + +@pytest.fixture(scope="module") +def reg(): + spec = importlib.util.spec_from_file_location("plugin_registry", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def scaffold(tmp_path: Path, plugin_id: str = PLUGIN_ID) -> Path: + assert main(["new", plugin_id, "--dir", str(tmp_path)]) == 0 + return tmp_path / plugin_id + + +def entry_text(**fields: str) -> str: + data = {"repo": "alice/ab-demo", "commit": SHA} | fields + return "".join(f'{k} = "{v}"\n' for k, v in data.items()) + + +class TestEntryParse: + def test_valid_entry_defaults_path_to_repo_root(self, reg): + entry = reg.Entry.parse(PLUGIN_ID, entry_text()) + assert (entry.repo, entry.commit, entry.path) == ("alice/ab-demo", SHA, ".") + + @pytest.mark.parametrize( + ("plugin_id", "text", "reason"), + [ + (PLUGIN_ID, entry_text(commit="abc1234"), "40 位"), + (PLUGIN_ID, entry_text(repo="https://github.com/a/b"), "owner/name"), + (PLUGIN_ID, entry_text(path="../other"), "相对路径"), + (PLUGIN_ID, entry_text(tag="v1"), "未知字段"), + (PLUGIN_ID, 'repo = "alice/ab-demo"\n', "缺少字段"), + ("Bad_Id", entry_text(), "不是合法的插件 id"), + ("local", entry_text(), "不是合法的插件 id"), + ], + ) + def test_invalid_entry_is_rejected(self, reg, plugin_id, text, reason): + with pytest.raises(reg.Reject, match=reason): + reg.Entry.parse(plugin_id, text) + + +class TestVerify: + def test_matching_new_entry_passes(self, reg, tmp_path): + src = scaffold(tmp_path) + entry = reg.Entry.parse(PLUGIN_ID, entry_text()) + assert reg.verify(entry, src, None, None).id == PLUGIN_ID + + def test_file_name_must_match_manifest_id(self, reg, tmp_path): + src = scaffold(tmp_path) + entry = reg.Entry.parse("other-id", entry_text()) + with pytest.raises(reg.Reject, match="不一致"): + reg.verify(entry, src, None, None) + + def test_builtin_id_is_rejected(self, reg, tmp_path): + builtin = next(p.name for p in reg.BUILTIN_ROOT.iterdir() if p.is_dir()) + src = scaffold(tmp_path, builtin) + entry = reg.Entry.parse(builtin, entry_text()) + with pytest.raises(reg.Reject, match="内置插件"): + reg.verify(entry, src, None, None) + + def test_repo_cannot_change_on_update(self, reg, tmp_path): + src = scaffold(tmp_path) + entry = reg.Entry.parse(PLUGIN_ID, entry_text(repo="mallory/fork")) + old = reg.Entry.parse(PLUGIN_ID, entry_text()) + with pytest.raises(reg.Reject, match="repo"): + reg.verify(entry, src, old, "0.0.1") + + @pytest.mark.parametrize("old_version", ["0.1.0", "0.2.0"]) + def test_update_must_raise_version(self, reg, tmp_path, old_version): + src = scaffold(tmp_path) # 脚手架版本为 0.1.0 + entry = reg.Entry.parse(PLUGIN_ID, entry_text()) + with pytest.raises(reg.Reject, match="version 必须高于"): + reg.verify(entry, src, entry, old_version) + + +class TestRunTests: + def test_scaffold_tests_pass(self, reg, tmp_path): + assert "passed" in reg.run_tests(scaffold(tmp_path)) + + def test_zero_collected_tests_is_rejected(self, reg, tmp_path): + src = scaffold(tmp_path) + for test in (src / "tests").glob("test_*.py"): + test.unlink() + with pytest.raises(reg.Reject, match="没有收集到测试"): + reg.run_tests(src) + + def test_failing_test_is_rejected(self, reg, tmp_path): + src = scaffold(tmp_path) + (src / "tests" / "test_fail.py").write_text("def test_x():\n assert False\n") + with pytest.raises(reg.Reject, match="测试失败"): + reg.run_tests(src) + + +def test_committed_web_without_web_src_is_rejected(reg, tmp_path): + src = scaffold(tmp_path) + (src / "web").mkdir() + (src / "web" / "index.js").write_text("export {}\n") + with pytest.raises(reg.Reject, match="web-src"): + reg.build_web(src) + + +class TestBuild: + @pytest.fixture + def registry(self, reg, tmp_path, monkeypatch): + root = tmp_path / "registry" + root.mkdir() + (root / f"{PLUGIN_ID}.toml").write_text(entry_text()) + monkeypatch.setattr(reg, "REGISTRY", root) + return root + + def test_unchanged_commit_reuses_released_zip( + self, reg, registry, tmp_path, monkeypatch + ): + src = scaffold(tmp_path / "src") + archive = pack(src, tmp_path / "old", load_manifest(src)) + data = archive.read_bytes() + old = { + "plugins": [ + { + "id": PLUGIN_ID, + "version": "0.1.0", + "repo": "alice/ab-demo", + "commit": SHA, + "asset": archive.name, + "readme": "hi", + "sha256": hashlib.sha256(data).hexdigest(), + } + ] + } + monkeypatch.setattr(reg, "_download", lambda url: data) + monkeypatch.setattr(reg, "fetch", lambda *a: pytest.fail("must not fetch")) + + sources = reg.build(old, tmp_path / "out", tmp_path / "tmp") + + assert (tmp_path / "out" / archive.name).read_bytes() == data + assert sources[PLUGIN_ID] == { + "repo": "alice/ab-demo", + "commit": SHA, + "readme": "hi", + } + + def test_reused_zip_with_wrong_sha256_fails( + self, reg, registry, tmp_path, monkeypatch + ): + old = { + "plugins": [ + { + "id": PLUGIN_ID, + "version": "0.1.0", + "repo": "alice/ab-demo", + "commit": SHA, + "asset": "x.zip", + "sha256": "0" * 64, + } + ] + } + monkeypatch.setattr(reg, "_download", lambda url: b"tampered") + with pytest.raises(SystemExit, match="sha256"): + reg.build(old, tmp_path / "out", tmp_path / "tmp") + + def test_changed_commit_is_fetched_and_packed( + self, reg, registry, tmp_path, monkeypatch + ): + src = scaffold(tmp_path / "src") + (src / "README.md").write_text("# demo\n") + monkeypatch.setattr(reg, "fetch", lambda repo, commit, path, workdir: src) + + sources = reg.build({}, tmp_path / "out", tmp_path / "tmp") + + assert (tmp_path / "out" / f"{PLUGIN_ID}-0.1.0.zip").is_file() + assert sources[PLUGIN_ID]["readme"] == "# demo\n" diff --git a/backend/vulture_whitelist.py b/backend/vulture_whitelist.py index 701f7b2d..e3dd4c5c 100644 --- a/backend/vulture_whitelist.py +++ b/backend/vulture_whitelist.py @@ -31,6 +31,9 @@ percent candidate_ids extension_points min_ab_version +authors +has_web +readme # --- 协议 / 回调签名要求的参数 --- connection_record # SQLAlchemy connect 事件回调 diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7ff0586d..3a79019c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -43,6 +43,7 @@ const pluginPages: [string, string, string, string][] = [ ['/frontend-slots', '前端挂载点', 'Frontend slots', 'フロントエンドスロット'], ['/cli', '命令行 ab-plugin', 'The ab-plugin command', 'コマンド ab-plugin'], ['/signing', '签名与分发', 'Signing and distribution', '署名と配布'], + ['/publish', '上架插件', 'List a plugin', 'プラグインの掲載'], ['/builtin', '内置插件', 'Built-in plugins', '組み込みプラグイン'], ['/examples', '示例插件', 'Example plugins', 'サンプルプラグイン'], ] diff --git a/docs/dev/plugins/publish.md b/docs/dev/plugins/publish.md new file mode 100644 index 00000000..d800bd4b --- /dev/null +++ b/docs/dev/plugins/publish.md @@ -0,0 +1,101 @@ +# 上架插件 + +插件的源码留在作者自己的公开 GitHub 仓库。要上架或更新插件,作者向 `EstrellaXD/Auto_Bangumi` 提交一个 PR(目标分支 `4.0-dev`),新增或修改一个文件:`plugins/registry/.toml`。 + +CI 自动检查这个文件指向的代码。维护者阅读代码后合并。合并后 CI 打包、签名并发布到签名目录。 + +::: tip 版本要求 +AutoBangumi 4.0.0-beta.2 及之后的版本信任插件密钥。4.0.0-beta.1 无法从新目录安装插件。 +::: + +## 登记文件 + +文件 `plugins/registry/.toml` 只有三个字段: + +```toml +repo = "owner/name" # 公开的 GitHub 仓库 +commit = "<40 位完整 SHA>" # tag 可以移动,SHA 不能 +path = "." # 可选。插件所在子目录,默认是仓库根 +``` + +- 文件名必须等于 `plugin.toml` 里的 `id`。 +- `commit` 必须是 40 位完整 SHA。短 SHA 和 tag 会被拒绝。 +- 名称、版本、`sdk` 范围、作者、权限都从该 commit 的 `plugin.toml` 读取,不在登记文件里重复写。 + +一个 PR 可以改多个登记文件。建议一个 PR 只含一个插件。 + +## 步骤 + +1. 准备仓库。仓库必须公开。`plugin.toml`、插件代码和 `tests/` 都要提交。有前端时,源码放在 `web-src/`,并提交 `package.json` 和 `pnpm-lock.yaml`。 +2. 在本地检查: + + ```bash + ab-plugin validate . + uv run pytest + pnpm install --frozen-lockfile && pnpm build # 仅有前端时 + ``` + + `tests/` 至少要有 1 个测试,且全部通过。`ab-plugin new` 生成的契约测试可以满足这一条。 +3. 把代码推送到公开仓库。 +4. 取得完整 SHA: + + ```bash + git rev-parse HEAD + ``` + +5. Fork `EstrellaXD/Auto_Bangumi`,新增 `plugins/registry/.toml`,向 `4.0-dev` 提交 PR。PR 里只能改 `plugins/registry/` 下的文件。在 PR 描述中选用插件模板(`?template=plugin.md`),逐项勾选清单。 + +## CI 规则 + +工作流 `plugin-registry.yml` 的 `check` 任务在 PR 上运行。它只有只读权限,没有任何 secret。对每个改动的登记文件,它先取回固定的 commit,再按下列规则检查。任何一条不满足,CI 拒绝该条目: + +- `commit` 不是 40 位完整 SHA。 +- 文件名与清单里的 `id` 不同。 +- `id` 与内置插件冲突,或是保留 id。 +- `ab-plugin validate` 失败。 +- 更新时,版本不高于目录中已有的版本。 +- 更新时,`repo` 被修改过。要转让插件,见下文「转让插件」。 +- `tests/` 里没有测试,或有测试失败。 +- 插件有前端,但没有 `web-src/`。前端必须以源码提交在 `web-src/`,带 `package.json` 和 `pnpm-lock.yaml`。CI 用 pnpm 9 / Node 20 运行 `pnpm install --frozen-lockfile --ignore-scripts && pnpm build`,把 `@autobangumi/plugin-ui` 解析为本仓库的包,并丢弃仓库里已提交的 `web/`。 + +检查通过后,CI 用 `ab-plugin pack` 打包,并写入 Job Summary: + +- 版本变化。 +- 对比链接(更新)或目录树链接(新插件)。 +- validate 和测试结果。 +- 扩展点。 +- 声明的权限。 +- 是否有前端。 +- zip 的 sha256。 + +## 合并之后 + +维护者(CODEOWNERS:@EstrellaXD)在对比链接中阅读代码,再合并。 + +合并后,CI 增量重建目录:`commit` 没变的条目复用已有的 zip。然后用专用的插件签名密钥,给 `catalog.json` 和每个 zip 签名。这把密钥不是在线更新密钥。最后 CI 把文件上传到 GitHub release `plugins`。 + +用户通过 `GET /api/v1/plugins/catalog` 看到新插件或新版本。签名与目录格式见 [签名与分发](/dev/plugins/signing)。 + +## 更新插件 + +1. 在自己的仓库里改代码,并提高 `plugin.toml` 里的 `version`。 +2. 推送,用 `git rev-parse HEAD` 取得新的完整 SHA。 +3. 向 `4.0-dev` 提交 PR,只把 `plugins/registry/.toml` 里的 `commit` 改成新 SHA。 + +版本必须高于目录中已有的版本,否则 CI 拒绝。 + +## 转让插件 + +更新时不能修改 `repo`。要把插件转给另一个仓库,分两个 PR: + +1. 第一个 PR 删除 `plugins/registry/.toml`。 +2. 合并后,第二个 PR 用新的 `repo` 重新添加这个文件。此时按新插件检查,版本不受旧版本限制。 + +## 下架插件 + +提交 PR 删除 `plugins/registry/.toml`。合并后,该插件从目录中移除。用户已经安装的副本不会被删除。 + +## 依赖与再分发 + +- 插件只能依赖标准库和 AutoBangumi 已有的包。需要第三方纯 Python 库时,放进 `vendor/`。 +- 不要求许可证。但提交 PR 即表示作者同意 AutoBangumi 在其 release 中再分发打包后的插件。 diff --git a/docs/dev/plugins/signing.md b/docs/dev/plugins/signing.md index 9dc9828c..f80e0972 100644 --- a/docs/dev/plugins/signing.md +++ b/docs/dev/plugins/signing.md @@ -28,6 +28,12 @@ AB 从 GitHub release `plugins` 下载 `catalog.json`。目录包含: "sdk": ">=0.5,<1", "min_ab_version": "4.0.0-beta.1", "description": "…", + "authors": ["…"], + "repo": "owner/ntfy-notifier", + "commit": "…", + "permissions": ["network"], + "has_web": false, + "readme": "…", "asset": "ntfy-notifier-0.1.0.zip", "sha256": "…" } @@ -35,7 +41,18 @@ AB 从 GitHub release `plugins` 下载 `catalog.json`。目录包含: } ``` -`catalog.json` 和每个 zip 都有同名的 `.sig` 文件:对文件全部字节做 ed25519 签名,再用 base64 编码。公钥随 AB 镜像分发,与在线更新使用同一把密钥。 +条目里的字段: + +| 字段 | 说明 | +| --- | --- | +| `authors` | 作者列表,来自 `plugin.toml` | +| `repo` | 源码所在的 GitHub 仓库 | +| `commit` | 打包所用的 40 位完整 SHA | +| `permissions` | 清单声明的权限,仅供展示 | +| `has_web` | 插件是否带前端 | +| `readme` | README 文本,最大 16 KB | + +`catalog.json` 和每个 zip 都有同名的 `.sig` 文件:对文件全部字节做 ed25519 签名,再用 base64 编码。公钥随 AB 镜像分发。目录使用专用的插件签名密钥,与在线更新的密钥不是同一把。4.0.0-beta.2 及之后的版本信任这把插件密钥;4.0.0-beta.1 无法从新目录安装。 安装时 AB 依次检查: @@ -67,22 +84,9 @@ API: ## 发布一个插件 -只有持有签名私钥的维护者能发布目录。流程: +作者向 `EstrellaXD/Auto_Bangumi` 提交 PR,登记插件源码的仓库和 commit。CI 检查,维护者审核并合并。合并后 CI 用插件签名密钥重建并签名目录,上传到 release `plugins`。私钥只存在于 CI 的 secret 中,作者和 PR 的 CI 都接触不到。 -1. 作者:`ab-plugin pack .`,得到 zip。 -2. 作者:在 GitHub issue 里提交 zip 和源码地址,申请上架。 -3. 维护者审核源码后,对一个或多个 zip 运行: - - ```bash - uv run --no-project --with cryptography python scripts/build_plugin_catalog.py \ - --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0-beta.1 \ - --out release-assets dist/*.zip - ``` - - 脚本输出 `catalog.json`、各 zip 及其 `.sig`。`--min-ab` 不要写成 `4.0.0`:按 semver,`4.0.0-beta.N` 低于 `4.0.0`,4.0 beta 用户将无法安装。 -4. 维护者把整个目录上传到 release `plugins`(覆盖旧文件)。 - -用户端通过 `GET /api/v1/plugins/catalog` 看到新版本。 +完整流程见 [上架插件](/dev/plugins/publish)。 ## 发布 SDK diff --git a/docs/en/dev/plugins/publish.md b/docs/en/dev/plugins/publish.md new file mode 100644 index 00000000..ea3c7e92 --- /dev/null +++ b/docs/en/dev/plugins/publish.md @@ -0,0 +1,101 @@ +# List a Plugin + +The source of a plugin stays in the author's own public GitHub repository. To list or update a plugin, the author opens a PR to `EstrellaXD/Auto_Bangumi` (base branch `4.0-dev`). The PR adds or edits one file: `plugins/registry/.toml`. + +CI checks the code that this file points to. A maintainer reads the code and merges. After the merge, CI packs and signs the plugin and publishes it to the signed catalog. + +::: tip Version requirement +AutoBangumi 4.0.0-beta.2 and later trust the plugin key. 4.0.0-beta.1 cannot install plugins from the new catalog. +::: + +## The registry entry + +The file `plugins/registry/.toml` has three fields: + +```toml +repo = "owner/name" # public GitHub repository +commit = "<40-char full SHA>" # tags can move, a SHA cannot +path = "." # optional subdirectory of the plugin, default is the repo root +``` + +- The file name must equal the `id` in `plugin.toml`. +- `commit` must be a full 40-character SHA. A short SHA or a tag is rejected. +- The name, version, `sdk` range, authors and permissions come from `plugin.toml` at that commit. Do not repeat them in the entry. + +A PR can change several entries. We recommend one plugin per PR. + +## Steps + +1. Prepare the repository. It must be public. Commit `plugin.toml`, the plugin code and `tests/`. If the plugin has a frontend, put its source in `web-src/` and commit `package.json` and `pnpm-lock.yaml`. +2. Check locally: + + ```bash + ab-plugin validate . + uv run pytest + pnpm install --frozen-lockfile && pnpm build # only with a frontend + ``` + + `tests/` must contain at least 1 test, and all tests must pass. The contract tests that `ab-plugin new` generates meet this rule. +3. Push the code to the public repository. +4. Get the full SHA: + + ```bash + git rev-parse HEAD + ``` + +5. Fork `EstrellaXD/Auto_Bangumi`, add `plugins/registry/.toml`, and open a PR to `4.0-dev`. Change only files under `plugins/registry/`. In the PR description, use the plugin template (`?template=plugin.md`) and tick each item. + +## CI rules + +The job `check` of the workflow `plugin-registry.yml` runs on the PR. It has read-only permissions and no secrets. For each changed entry, it first fetches the pinned commit. Then it checks the rules below. If one rule fails, CI rejects the entry: + +- `commit` is not a full 40-character SHA. +- The file name differs from the manifest `id`. +- The `id` clashes with a built-in plugin, or it is a reserved id. +- `ab-plugin validate` fails. +- For an update, the version is not higher than the listed version. +- For an update, `repo` changed. To transfer a plugin, refer to "Transfer a plugin" below. +- `tests/` has no tests, or a test fails. +- The plugin has a frontend but no `web-src/`. A frontend must be committed as source in `web-src/`, with `package.json` and `pnpm-lock.yaml`. CI runs `pnpm install --frozen-lockfile --ignore-scripts && pnpm build` with pnpm 9 and Node 20. It resolves `@autobangumi/plugin-ui` to the package of this repository. It discards any committed `web/`. + +When the checks pass, CI packs the plugin with `ab-plugin pack` and writes a Job Summary with: + +- The version change. +- A compare link (update) or a tree link (new plugin). +- The validate and test results. +- The extension points. +- The declared permissions. +- Whether the plugin has a frontend. +- The sha256 of the zip. + +## After the merge + +A maintainer (CODEOWNERS: @EstrellaXD) reads the code at the compare link and merges. + +After the merge, CI rebuilds the catalog incrementally. An entry whose `commit` did not change reuses its existing zip. CI then signs `catalog.json` and every zip with a dedicated plugin signing key. This key is not the update key. Last, CI uploads the files to the GitHub release `plugins`. + +A user sees the new plugin or version through `GET /api/v1/plugins/catalog`. For the catalog format and the signature, refer to [Signing and Distribution](/en/dev/plugins/signing). + +## Update a plugin + +1. Change the code in your repository and raise `version` in `plugin.toml`. +2. Push, then get the new full SHA with `git rev-parse HEAD`. +3. Open a PR to `4.0-dev`. In `plugins/registry/.toml`, change only `commit` to the new SHA. + +The version must be higher than the listed version. If not, CI rejects the PR. + +## Transfer a plugin + +You cannot change `repo` in an update. To move a plugin to another repository, use two PRs: + +1. The first PR removes `plugins/registry/.toml`. +2. After it is merged, the second PR adds the file again with the new `repo`. CI then checks it as a new plugin, so the old version does not limit it. + +## Remove a plugin + +Open a PR that removes `plugins/registry/.toml`. After the merge, the plugin leaves the catalog. Copies that users already installed are not deleted. + +## Dependencies and redistribution + +- A plugin can depend only on the standard library and on packages that AutoBangumi already ships. Put third-party pure-Python libraries in `vendor/`. +- A license is not required. But by opening the PR, the author agrees that AutoBangumi redistributes the packed plugin in its release. diff --git a/docs/en/dev/plugins/signing.md b/docs/en/dev/plugins/signing.md index 53c81db8..b80739ae 100644 --- a/docs/en/dev/plugins/signing.md +++ b/docs/en/dev/plugins/signing.md @@ -28,6 +28,12 @@ AB downloads `catalog.json` from the GitHub release `plugins`. The catalog conta "sdk": ">=0.5,<1", "min_ab_version": "4.0.0-beta.1", "description": "…", + "authors": ["…"], + "repo": "owner/ntfy-notifier", + "commit": "…", + "permissions": ["network"], + "has_web": false, + "readme": "…", "asset": "ntfy-notifier-0.1.0.zip", "sha256": "…" } @@ -35,7 +41,18 @@ AB downloads `catalog.json` from the GitHub release `plugins`. The catalog conta } ``` -`catalog.json` and each zip have a `.sig` file with the same name. It is an ed25519 signature over all bytes of the file, encoded in base64. The public key is part of the AB image. It is the same key that the online update uses. +Fields of an entry: + +| Field | Description | +| --- | --- | +| `authors` | List of authors, from `plugin.toml` | +| `repo` | The GitHub repository of the source | +| `commit` | The full 40-character SHA that was packed | +| `permissions` | Permissions that the manifest declares. For display only | +| `has_web` | Whether the plugin has a frontend | +| `readme` | README text, 16 KB at most | + +`catalog.json` and each zip have a `.sig` file with the same name. It is an ed25519 signature over all bytes of the file, encoded in base64. The public key is part of the AB image. The catalog uses a dedicated plugin signing key. It is not the key of the online update. AB 4.0.0-beta.2 and later trust the plugin key. 4.0.0-beta.1 cannot install from the new catalog. At install time AB checks, in this order: @@ -67,22 +84,9 @@ The host owns `/plugins/{id}/install` and `/plugins/catalog`. The `api_router` o ## Publish a plugin -Only a maintainer who has the signing private key can publish the catalog. The steps: +The author opens a PR to `EstrellaXD/Auto_Bangumi` that registers the repository and the commit of the plugin source. CI checks it, and a maintainer reviews and merges it. After the merge, CI rebuilds the catalog, signs it with the plugin signing key, and uploads it to the release `plugins`. The private key exists only in a CI secret. Authors and the CI of a PR cannot reach it. -1. The author runs `ab-plugin pack .` and gets a zip. -2. The author opens a GitHub issue with the zip and the source address to ask for a listing. -3. The maintainer reviews the source, then runs this command on one or more zips: - - ```bash - uv run --no-project --with cryptography python scripts/build_plugin_catalog.py \ - --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0-beta.1 \ - --out release-assets dist/*.zip - ``` - - The script writes `catalog.json`, the zips and their `.sig` files. Do not set `--min-ab` to `4.0.0`: in semver, `4.0.0-beta.N` is lower than `4.0.0`, so 4.0 beta users cannot install the plugin. -4. The maintainer uploads the whole directory to the release `plugins` (and overwrites the old files). - -A user sees the new version through `GET /api/v1/plugins/catalog`. +For the full procedure, refer to [List a Plugin](/en/dev/plugins/publish). ## Publish the SDK diff --git a/docs/ja/dev/plugins/publish.md b/docs/ja/dev/plugins/publish.md new file mode 100644 index 00000000..24dcf402 --- /dev/null +++ b/docs/ja/dev/plugins/publish.md @@ -0,0 +1,101 @@ +# プラグインを掲載する + +プラグインのソースは、作者自身の公開 GitHub リポジトリに置いたままにします。プラグインを掲載または更新するには、作者が `EstrellaXD/Auto_Bangumi`(ベースブランチ `4.0-dev`)に PR を作ります。PR では 1 つのファイル `plugins/registry/.toml` を追加または編集します。 + +CI はこのファイルが指すコードを検査します。メンテナーがコードを読んでマージします。マージ後、CI がプラグインをパックして署名し、署名付きカタログに公開します。 + +::: tip バージョン要件 +AutoBangumi 4.0.0-beta.2 以降はプラグイン用の鍵を信頼します。4.0.0-beta.1 は新しいカタログからプラグインをインストールできません。 +::: + +## 登録ファイル + +ファイル `plugins/registry/.toml` のフィールドは 3 つです。 + +```toml +repo = "owner/name" # 公開 GitHub リポジトリ +commit = "<40 文字の完全な SHA>" # タグは動かせるが、SHA は動かせない +path = "." # 任意。プラグインのサブディレクトリ。既定はリポジトリのルート +``` + +- ファイル名は `plugin.toml` の `id` と一致させます。 +- `commit` は 40 文字の完全な SHA にします。短い SHA やタグは拒否されます。 +- 名前、バージョン、`sdk` の範囲、作者、権限は、その commit の `plugin.toml` から読み取ります。登録ファイルに重複して書かないでください。 + +1 つの PR で複数のエントリを変更できます。1 PR に 1 プラグインを推奨します。 + +## 手順 + +1. リポジトリを準備します。リポジトリは公開にします。`plugin.toml`、プラグインのコード、`tests/` をコミットします。フロントエンドがある場合は、ソースを `web-src/` に置き、`package.json` と `pnpm-lock.yaml` もコミットします。 +2. ローカルで確認します。 + + ```bash + ab-plugin validate . + uv run pytest + pnpm install --frozen-lockfile && pnpm build # フロントエンドがある場合のみ + ``` + + `tests/` には少なくとも 1 つのテストが必要で、すべて合格しなければなりません。`ab-plugin new` が生成する契約テストはこの条件を満たします。 +3. コードを公開リポジトリに push します。 +4. 完全な SHA を取得します。 + + ```bash + git rev-parse HEAD + ``` + +5. `EstrellaXD/Auto_Bangumi` を fork し、`plugins/registry/.toml` を追加して、`4.0-dev` に PR を作ります。変更するのは `plugins/registry/` 以下のファイルだけです。PR の説明ではプラグイン用テンプレート(`?template=plugin.md`)を使い、各項目にチェックを入れます。 + +## CI のルール + +ワークフロー `plugin-registry.yml` のジョブ `check` が PR 上で実行されます。権限は読み取り専用で、secret はありません。変更された各エントリについて、まず固定された commit を取得します。次に下のルールを検査します。1 つでも満たさないと、CI はそのエントリを拒否します。 + +- `commit` が 40 文字の完全な SHA ではない。 +- ファイル名がマニフェストの `id` と異なる。 +- `id` が組み込みプラグインと衝突する、または予約 id である。 +- `ab-plugin validate` が失敗する。 +- 更新で、バージョンがカタログにあるバージョンより高くない。 +- 更新で、`repo` が変更されている。プラグインを移管するには、下の「プラグインを移管する」を参照してください。 +- `tests/` にテストがない、またはテストが失敗する。 +- プラグインにフロントエンドがあるのに、`web-src/` がない。フロントエンドは、ソースを `web-src/` にコミットし、`package.json` と `pnpm-lock.yaml` を付けます。CI は pnpm 9 / Node 20 で `pnpm install --frozen-lockfile --ignore-scripts && pnpm build` を実行します。`@autobangumi/plugin-ui` はこのリポジトリのパッケージに解決します。コミット済みの `web/` は破棄します。 + +検査に通ると、CI は `ab-plugin pack` でパックし、次の内容を Job Summary に書き込みます。 + +- バージョンの変化。 +- 比較リンク(更新)またはツリーリンク(新規)。 +- validate とテストの結果。 +- 拡張ポイント。 +- 宣言された権限。 +- フロントエンドの有無。 +- zip の sha256。 + +## マージ後 + +メンテナー(CODEOWNERS:@EstrellaXD)が比較リンクでコードを読み、マージします。 + +マージ後、CI はカタログを増分で再構築します。`commit` が変わっていないエントリは、既存の zip を再利用します。次に、専用のプラグイン署名鍵で `catalog.json` とすべての zip に署名します。この鍵は更新用の鍵ではありません。最後に、CI がファイルを GitHub release `plugins` にアップロードします。 + +ユーザーは `GET /api/v1/plugins/catalog` で新しいプラグインやバージョンを確認できます。カタログの形式と署名は [署名と配布](/ja/dev/plugins/signing) を参照してください。 + +## プラグインを更新する + +1. 自分のリポジトリでコードを変更し、`plugin.toml` の `version` を上げます。 +2. push して、`git rev-parse HEAD` で新しい完全な SHA を取得します。 +3. `4.0-dev` に PR を作ります。`plugins/registry/.toml` では `commit` だけを新しい SHA に変更します。 + +バージョンはカタログにあるバージョンより高くする必要があります。そうでない場合、CI は PR を拒否します。 + +## プラグインを移管する + +更新では `repo` を変更できません。プラグインを別のリポジトリに移すには、2 つの PR を使います。 + +1. 1 つ目の PR で `plugins/registry/.toml` を削除します。 +2. マージ後、2 つ目の PR で新しい `repo` を指定して、このファイルを再び追加します。このとき CI は新規プラグインとして検査するため、古いバージョンの制限は受けません。 + +## プラグインを削除する + +`plugins/registry/.toml` を削除する PR を作ります。マージ後、そのプラグインはカタログから外れます。ユーザーが既にインストールしたコピーは削除されません。 + +## 依存関係と再配布 + +- プラグインが依存できるのは、標準ライブラリと AutoBangumi に既にあるパッケージだけです。サードパーティの純 Python ライブラリは `vendor/` に入れてください。 +- ライセンスは必須ではありません。ただし、PR を作ることで、作者は AutoBangumi がパック済みのプラグインを release で再配布することに同意したものとみなされます。 diff --git a/docs/ja/dev/plugins/signing.md b/docs/ja/dev/plugins/signing.md index 481759e2..3aa73fa6 100644 --- a/docs/ja/dev/plugins/signing.md +++ b/docs/ja/dev/plugins/signing.md @@ -28,6 +28,12 @@ AB は GitHub release `plugins` から `catalog.json` をダウンロードし "sdk": ">=0.5,<1", "min_ab_version": "4.0.0-beta.1", "description": "…", + "authors": ["…"], + "repo": "owner/ntfy-notifier", + "commit": "…", + "permissions": ["network"], + "has_web": false, + "readme": "…", "asset": "ntfy-notifier-0.1.0.zip", "sha256": "…" } @@ -35,7 +41,18 @@ AB は GitHub release `plugins` から `catalog.json` をダウンロードし } ``` -`catalog.json` と各 zip には、同名の `.sig` ファイルが付きます。ファイル全体のバイト列に対する ed25519 署名を base64 で符号化したものです。公開鍵は AB イメージに含まれます。オンライン更新と同じ鍵です。 +エントリのフィールド: + +| フィールド | 説明 | +| --- | --- | +| `authors` | 作者のリスト。`plugin.toml` から取得 | +| `repo` | ソースがある GitHub リポジトリ | +| `commit` | パックに使った 40 文字の完全な SHA | +| `permissions` | マニフェストが宣言する権限。表示のみ | +| `has_web` | プラグインにフロントエンドがあるか | +| `readme` | README のテキスト。最大 16 KB | + +`catalog.json` と各 zip には、同名の `.sig` ファイルが付きます。ファイル全体のバイト列に対する ed25519 署名を base64 で符号化したものです。公開鍵は AB イメージに含まれます。カタログは専用のプラグイン署名鍵を使います。オンライン更新の鍵とは別の鍵です。4.0.0-beta.2 以降の AB はこのプラグイン鍵を信頼します。4.0.0-beta.1 は新しいカタログからインストールできません。 インストール時、AB は次の順に検査します。 @@ -67,22 +84,9 @@ API: ## プラグインを公開する -カタログを公開できるのは、署名用の秘密鍵を持つメンテナーだけです。手順は次のとおりです。 +作者は `EstrellaXD/Auto_Bangumi` に PR を作り、プラグインのソースのリポジトリと commit を登録します。CI が検査し、メンテナーが確認してマージします。マージ後、CI がカタログを再構築し、プラグイン署名鍵で署名して、release `plugins` にアップロードします。秘密鍵は CI の secret にだけ存在します。作者も PR の CI も、秘密鍵には触れられません。 -1. 作者が `ab-plugin pack .` を実行し、zip を作ります。 -2. 作者が zip とソースの場所を添えて GitHub issue を作り、掲載を依頼します。 -3. メンテナーがソースを確認し、1 つ以上の zip に対して次のコマンドを実行します。 - - ```bash - uv run --no-project --with cryptography python scripts/build_plugin_catalog.py \ - --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0-beta.1 \ - --out release-assets dist/*.zip - ``` - - スクリプトは `catalog.json`、各 zip、およびその `.sig` を出力します。`--min-ab` に `4.0.0` を指定しないでください。semver では `4.0.0-beta.N` は `4.0.0` より低いため、4.0 beta のユーザーがインストールできなくなります。 -4. メンテナーがディレクトリ全体を release `plugins` にアップロードします(古いファイルは上書きします)。 - -ユーザーは `GET /api/v1/plugins/catalog` で新しいバージョンを確認できます。 +詳しい手順は [プラグインを掲載する](/ja/dev/plugins/publish) を参照してください。 ## SDK の公開 diff --git a/plugins/registry/custom-rss-site.toml b/plugins/registry/custom-rss-site.toml new file mode 100644 index 00000000..00bdfcd6 --- /dev/null +++ b/plugins/registry/custom-rss-site.toml @@ -0,0 +1,3 @@ +repo = "EstrellaXD/Auto_Bangumi" +commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" +path = "examples/plugins/custom-rss-site" diff --git a/plugins/registry/manual-pick.toml b/plugins/registry/manual-pick.toml new file mode 100644 index 00000000..7d1dae86 --- /dev/null +++ b/plugins/registry/manual-pick.toml @@ -0,0 +1,3 @@ +repo = "EstrellaXD/Auto_Bangumi" +commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" +path = "examples/plugins/manual-pick" diff --git a/plugins/registry/nfo-writer.toml b/plugins/registry/nfo-writer.toml new file mode 100644 index 00000000..bed1676c --- /dev/null +++ b/plugins/registry/nfo-writer.toml @@ -0,0 +1,3 @@ +repo = "EstrellaXD/Auto_Bangumi" +commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" +path = "examples/plugins/nfo-writer" diff --git a/plugins/registry/ntfy-notifier.toml b/plugins/registry/ntfy-notifier.toml new file mode 100644 index 00000000..8247260b --- /dev/null +++ b/plugins/registry/ntfy-notifier.toml @@ -0,0 +1,3 @@ +repo = "EstrellaXD/Auto_Bangumi" +commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" +path = "examples/plugins/ntfy-notifier" diff --git a/plugins/registry/template-rename.toml b/plugins/registry/template-rename.toml new file mode 100644 index 00000000..9317b3aa --- /dev/null +++ b/plugins/registry/template-rename.toml @@ -0,0 +1,3 @@ +repo = "EstrellaXD/Auto_Bangumi" +commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" +path = "examples/plugins/template-rename" diff --git a/plugins/registry/webhook-on-event.toml b/plugins/registry/webhook-on-event.toml new file mode 100644 index 00000000..700ffb21 --- /dev/null +++ b/plugins/registry/webhook-on-event.toml @@ -0,0 +1,3 @@ +repo = "EstrellaXD/Auto_Bangumi" +commit = "f3359b17fde62ebfe32179071e7ae579431fd0cd" +path = "examples/plugins/webhook-on-event" diff --git a/scripts/build_plugin_catalog.py b/scripts/build_plugin_catalog.py index 97d1bdad..c7270ace 100644 --- a/scripts/build_plugin_catalog.py +++ b/scripts/build_plugin_catalog.py @@ -6,7 +6,8 @@ --key ~/.autobangumi/update-signing-key.pem \ --out release-assets dist/*.zip -输入是 ``ab-plugin pack`` 打出的 zip。输出目录里有 ``catalog.json``、每个 zip 以及 +输入是 ``ab-plugin pack`` 打出的 zip;``--sources`` 是 ``plugin_registry.py build`` +写出的 ``sources.json``(每个插件的 repo、commit 与 README)。输出目录里有 ``catalog.json``、每个 zip 以及 它们的 ``.sig``(对文件全部字节做 ed25519 签名,base64 文本),一并上传到 GitHub release ``plugins`` 即可。安装端的校验见 ``backend/src/module/plugin/installer.py``。 """ @@ -28,9 +29,10 @@ CATALOG_SCHEMA = 2 DEFAULT_MIN_AB = "4.0.0-beta.1" -def _entry(archive: Path, min_ab: str) -> dict: +def _entry(archive: Path, min_ab: str, sources: dict[str, dict[str, str]]) -> dict: with zipfile.ZipFile(archive) as zf: plugin = tomllib.loads(zf.read("plugin.toml").decode("utf-8"))["plugin"] + source = sources.get(plugin["id"], {}) return { "id": plugin["id"], "name": plugin["name"], @@ -40,6 +42,12 @@ def _entry(archive: Path, min_ab: str) -> dict: "sdk": plugin["sdk"], "min_ab_version": min_ab, "description": plugin.get("description", ""), + "authors": plugin.get("authors", []), + "permissions": plugin.get("permissions", []), + "has_web": bool(plugin.get("ui")), + "repo": source.get("repo", ""), + "commit": source.get("commit", ""), + "readme": source.get("readme", ""), "asset": archive.name, "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), } @@ -51,7 +59,11 @@ def _sign(key: Ed25519PrivateKey, path: Path) -> None: def build( - archives: list[Path], out: Path, key_path: Path, min_ab: str = DEFAULT_MIN_AB + archives: list[Path], + out: Path, + key_path: Path, + min_ab: str = DEFAULT_MIN_AB, + sources: dict[str, dict[str, str]] | None = None, ) -> Path: key = load_pem_private_key(key_path.read_bytes(), password=None) if not isinstance(key, Ed25519PrivateKey): @@ -59,7 +71,7 @@ def build( out.mkdir(parents=True, exist_ok=True) entries = [] for archive in sorted(archives): - entries.append(_entry(archive, min_ab)) + entries.append(_entry(archive, min_ab, sources or {})) shutil.copy2(archive, out / archive.name) _sign(key, out / archive.name) catalog = out / "catalog.json" @@ -77,5 +89,7 @@ if __name__ == "__main__": parser.add_argument("--key", required=True, type=Path) parser.add_argument("--out", required=True, type=Path) parser.add_argument("--min-ab", default=DEFAULT_MIN_AB) + parser.add_argument("--sources", type=Path) args = parser.parse_args() - print(build(args.archives, args.out, args.key, args.min_ab)) + sources = json.loads(args.sources.read_text("utf-8")) if args.sources else None + print(build(args.archives, args.out, args.key, args.min_ab, sources)) diff --git a/scripts/plugin_registry.py b/scripts/plugin_registry.py new file mode 100644 index 00000000..6d9db460 --- /dev/null +++ b/scripts/plugin_registry.py @@ -0,0 +1,375 @@ +"""插件市场的索引工具:``plugins/registry/.toml`` → 校验、测试、打包。 + +条目只有三个字段,代码留在作者自己的仓库,用完整 SHA 固定(tag 可以移动):: + + repo = "owner/name" + commit = "<40 位 SHA>" + path = "." # 可选,插件在仓库中的子目录 + +用法(在仓库根目录运行;backend 的虚拟环境里有 ab_sdk):: + + uv run --project backend python scripts/plugin_registry.py check --base origin/4.0-dev --out dist + uv run --project backend python scripts/plugin_registry.py build --old old/catalog.json --out dist + +- ``check``:PR 上运行(没有 secret、只读权限)。只处理本 PR 改动的条目,运行作者的 + 测试,把审查摘要(Markdown)写到 stdout,有条目不合格时以 1 退出。 +- ``build``:合并后运行(同样没有密钥)。commit 未变的条目复用 release 中的 zip(按旧 + catalog 的 sha256 校验),其余拉取并打包,再写出 ``sources.json``。签名 job 只用 + ``build_plugin_catalog.py`` 读这些 zip,不运行作者的任何代码。 +""" + +import argparse +import hashlib +import json +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +import urllib.request +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from packaging.version import Version + +from ab_sdk.cli import pack +from ab_sdk.manifest import ID_RE, MANIFEST_NAME, RESERVED_IDS, PluginManifest, check + +ROOT = Path(__file__).resolve().parent.parent +REGISTRY = ROOT / "plugins" / "registry" +BUILTIN_ROOT = ROOT / "backend" / "src" / "module" / "plugins" / "builtin" +PLUGIN_UI = ROOT / "webui" / "packages" / "plugin-ui" +RELEASE_BASE = "https://github.com/EstrellaXD/Auto_Bangumi/releases/download/plugins" +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +README_LIMIT = 16 * 1024 +_FIELDS = {"repo", "commit", "path"} + + +class Reject(Exception): + """条目不合格;消息直接写进审查摘要。""" + + +@dataclass(frozen=True) +class Entry: + id: str + repo: str + commit: str + path: str = "." + + @classmethod + def parse(cls, plugin_id: str, text: str) -> "Entry": + if not ID_RE.match(plugin_id) or plugin_id in RESERVED_IDS: + raise Reject(f"文件名 {plugin_id}.toml 不是合法的插件 id") + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as e: + raise Reject(f"TOML 格式错误:{e}") from None + if unknown := set(data) - _FIELDS: + raise Reject(f"未知字段:{', '.join(sorted(unknown))}") + if missing := {"repo", "commit"} - set(data): + raise Reject(f"缺少字段:{', '.join(sorted(missing))}") + entry = cls( + plugin_id, + str(data["repo"]), + str(data["commit"]), + str(data.get("path", ".")), + ) + if not REPO_RE.match(entry.repo): + raise Reject("repo 必须是 GitHub 的 owner/name") + if not SHA_RE.match(entry.commit): + raise Reject("commit 必须是 40 位完整 SHA") + path = PurePosixPath(entry.path) + if path.is_absolute() or ".." in path.parts: + raise Reject("path 必须是仓库内的相对路径") + return entry + + +def _run(cmd: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + + +def _tail(proc: subprocess.CompletedProcess[str], lines: int = 15) -> str: + return "\n".join((proc.stdout + proc.stderr).strip().splitlines()[-lines:]) + + +def fetch(repo: str, commit: str, path: str, workdir: Path) -> Path: + """浅拉取固定 commit,返回插件目录。不执行仓库里的任何代码。""" + workdir.mkdir(parents=True, exist_ok=True) + for cmd in ( + ["git", "init", "-q"], + [ + "git", + "fetch", + "-q", + "--depth", + "1", + f"https://github.com/{repo}.git", + commit, + ], + ["git", "checkout", "-q", "FETCH_HEAD"], + ): + proc = _run(cmd, workdir) + if proc.returncode != 0: + raise Reject(f"无法拉取 {repo}@{commit[:7]}:{_tail(proc, 3)}") + src = workdir / path + if not (src / MANIFEST_NAME).is_file(): + raise Reject(f"{repo}@{commit[:7]} 的 {path} 下没有 {MANIFEST_NAME}") + return src + + +def build_web(src: Path) -> None: + """有 web-src/ 时丢弃提交的 web/,用 pnpm 从源码重新构建。""" + web_src = src / "web-src" + if not (web_src / "package.json").is_file(): + if (src / "web").exists(): + raise Reject("前端必须以 web-src/ 源码提交,CI 负责构建 web/") + return + if not (web_src / "pnpm-lock.yaml").is_file(): + raise Reject("web-src/ 必须带 pnpm-lock.yaml") + shutil.rmtree(src / "web", ignore_errors=True) + proc = _run(["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], web_src) + if proc.returncode != 0: + raise Reject(f"pnpm install 失败:\n{_tail(proc)}") + # 作者的 link: 路径指向自己机器上的 AB 仓库;统一换成本仓库的包 + linked = web_src / "node_modules" / "@autobangumi" / "plugin-ui" + if linked.is_symlink() or linked.is_file(): + linked.unlink() + elif linked.exists(): + shutil.rmtree(linked) + linked.parent.mkdir(parents=True, exist_ok=True) + linked.symlink_to(PLUGIN_UI, target_is_directory=True) + proc = _run(["pnpm", "build"], web_src) + if proc.returncode != 0: + raise Reject(f"pnpm build 失败:\n{_tail(proc)}") + + +def verify( + entry: Entry, src: Path, old: Entry | None, old_version: str | None +) -> PluginManifest: + """清单与条目的一致性规则;不执行插件代码。""" + manifest, problems = check(src) + if manifest is None or problems: + raise Reject("ab-plugin validate 失败:" + ";".join(problems)) + if manifest.id != entry.id: + raise Reject( + f"文件名 {entry.id}.toml 与 plugin.toml 的 id {manifest.id} 不一致" + ) + if (BUILTIN_ROOT / entry.id).is_dir(): + raise Reject(f"id {entry.id} 与内置插件同名") + if old is not None and old.repo != entry.repo: + raise Reject("不能修改已上架插件的 repo:转移时先下架,再用新 repo 上架") + if old_version is not None and Version(manifest.version) <= Version(old_version): + raise Reject(f"version 必须高于已上架的 {old_version}") + return manifest + + +def run_tests(src: Path) -> str: + """运行作者的测试;至少 1 个且全部通过。返回 pytest 的结果行。""" + if not (src / "tests").is_dir(): + raise Reject("缺少 tests/:至少要有 1 个测试") + cmd = [ + sys.executable, + "-m", + "pytest", + "-q", + "-p", + "no:cacheprovider", + "--rootdir", + str(src), + ] + if (src / "pyproject.toml").is_file(): + cmd += ["-c", str(src / "pyproject.toml")] + proc = _run([*cmd, str(src / "tests")], src) + if proc.returncode == 5: + raise Reject("tests/ 中没有收集到测试:至少要有 1 个测试") + if proc.returncode != 0: + raise Reject(f"测试失败:\n{_tail(proc)}") + return proc.stdout.strip().splitlines()[-1] + + +def readme(src: Path) -> str: + for name in ("README.md", "readme.md", "README"): + if (src / name).is_file(): + data = (src / name).read_bytes()[:README_LIMIT] + return data.decode("utf-8", errors="ignore") + return "" + + +def _manifest_version(repo: str, commit: str, path: str, workdir: Path) -> str: + """已上架版本的 version:只读旧 commit 的 plugin.toml。""" + src = fetch(repo, commit, path, workdir) + return tomllib.loads((src / MANIFEST_NAME).read_text("utf-8"))["plugin"]["version"] + + +def _git(*args: str) -> subprocess.CompletedProcess[str]: + return _run(["git", *args], ROOT) + + +# --------------------------------------------------------------------- check + + +@dataclass +class Result: + id: str + ok: bool + lines: list[tuple[str, str]] + + +def check_entry(plugin_id: str, base: str, out: Path, tmp: Path) -> Result: + rel = f"plugins/registry/{plugin_id}.toml" + lines: list[tuple[str, str]] = [] + try: + entry = Entry.parse(plugin_id, (ROOT / rel).read_text("utf-8")) + old_text = _git("show", f"{base}:{rel}") + old = ( + Entry.parse(plugin_id, old_text.stdout) + if old_text.returncode == 0 + else None + ) + old_version = ( + _manifest_version(old.repo, old.commit, old.path, tmp / "old") + if old is not None + else None + ) + url = f"https://github.com/{entry.repo}" + tree = f"{url}/tree/{entry.commit}" + ( + "" if entry.path == "." else f"/{entry.path}" + ) + lines.append( + ("审查", f"{url}/compare/{old.commit}...{entry.commit}" if old else tree) + ) + src = fetch(entry.repo, entry.commit, entry.path, tmp / "new") + has_web_src = (src / "web-src" / "package.json").is_file() + build_web(src) + manifest = verify(entry, src, old, old_version) + version = ( + f"{old_version} → {manifest.version}" if old_version else manifest.version + ) + lines.insert(0, ("版本", version)) + lines.append(("validate", "✓")) + lines.append(("tests", f"✓ {run_tests(src)}")) + lines.append(("扩展点", ", ".join(manifest.extension_points) or "—")) + lines.append(("权限声明", ", ".join(manifest.permissions) or "无")) + lines.append(("前端", "web-src/(CI 构建)" if has_web_src else "无")) + archive = pack(src, out, manifest) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + lines.append(("zip", f"`{archive.name}` · sha256 `{digest}`")) + return Result(plugin_id, True, lines) + except Reject as e: + lines.append(("拒绝", str(e))) + return Result(plugin_id, False, lines) + + +def changed_ids(base: str) -> tuple[list[str], list[str]]: + """本 PR 新增 / 修改与删除的条目 id。""" + + def ids(diff_filter: str) -> list[str]: + proc = _git( + "diff", + "--name-only", + f"--diff-filter={diff_filter}", + f"{base}...HEAD", + "--", + "plugins/registry", + ) + if proc.returncode != 0: + raise SystemExit(proc.stderr) + return sorted(PurePosixPath(p).stem for p in proc.stdout.split()) + + return ids("AMR"), ids("D") + + +def summary(results: list[Result], removed: list[str]) -> str: + out = ["## 插件索引检查", ""] + for r in results: + out += [ + f"### {'✓' if r.ok else '✗'} {r.id}", + "", + "| 项目 | 结果 |", + "| --- | --- |", + ] + out += [f"| {k} | {v.replace(chr(10), '
')} |" for k, v in r.lines] + out.append("") + if removed: + out += ["### 下架", "", *[f"- {i}" for i in removed], ""] + if not results and not removed: + out.append("本 PR 没有改动 `plugins/registry/`。") + return "\n".join(out) + + +def cmd_check(base: str, out: Path) -> int: + changed, removed = changed_ids(base) + with tempfile.TemporaryDirectory() as tmp: + results = [check_entry(i, base, out, Path(tmp) / i) for i in changed] + print(summary(results, removed)) + return 0 if all(r.ok for r in results) else 1 + + +# --------------------------------------------------------------------- build + + +def _download(url: str) -> bytes: + with urllib.request.urlopen(url, timeout=60) as resp: + return resp.read() + + +def build(old_catalog: dict, out: Path, tmp: Path) -> dict[str, dict[str, str]]: + """重建全部条目的 zip,返回 sources(id → repo、commit、readme)。""" + out.mkdir(parents=True, exist_ok=True) + previous = {p["id"]: p for p in old_catalog.get("plugins", [])} + sources: dict[str, dict[str, str]] = {} + for path in sorted(REGISTRY.glob("*.toml")): + entry = Entry.parse(path.stem, path.read_text("utf-8")) + prev = previous.get(entry.id) + if prev and (prev.get("repo"), prev.get("commit")) == ( + entry.repo, + entry.commit, + ): + # commit 未变:复用已发布的 zip,作者删库或 force-push 不影响发布 + data = _download(f"{RELEASE_BASE}/{prev['asset']}") + if hashlib.sha256(data).hexdigest() != prev["sha256"]: + raise SystemExit(f"{prev['asset']} 与旧 catalog 的 sha256 不一致") + (out / prev["asset"]).write_bytes(data) + source = {"readme": prev.get("readme", "")} + print(f"✓ {entry.id} {prev['version']} 复用", file=sys.stderr) + else: + src = fetch(entry.repo, entry.commit, entry.path, tmp / entry.id) + build_web(src) + manifest = verify(entry, src, None, None) + pack(src, out, manifest) + source = {"readme": readme(src)} + print(f"✓ {entry.id} {manifest.version} 打包", file=sys.stderr) + sources[entry.id] = {"repo": entry.repo, "commit": entry.commit, **source} + return sources + + +def cmd_build(old: Path | None, out: Path) -> int: + old_catalog = json.loads(old.read_text("utf-8")) if old and old.is_file() else {} + with tempfile.TemporaryDirectory() as tmp: + sources = build(old_catalog, out, Path(tmp)) + (out / "sources.json").write_text( + json.dumps(sources, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="cmd", required=True) + p_check = sub.add_parser("check", help="检查本 PR 改动的条目") + p_check.add_argument("--base", required=True) + p_check.add_argument("--out", required=True, type=Path) + p_build = sub.add_parser("build", help="重建全部条目的 zip 与 sources.json") + p_build.add_argument("--old", type=Path, help="当前 release 的 catalog.json") + p_build.add_argument("--out", required=True, type=Path) + args = parser.parse_args() + try: + sys.exit( + cmd_check(args.base, args.out) + if args.cmd == "check" + else cmd_build(args.old, args.out) + ) + except Reject as e: + sys.exit(f"错误:{e}") diff --git a/webui/src/components/setting/__tests__/config-plugins.test.ts b/webui/src/components/setting/__tests__/config-plugins.test.ts index 05463624..113dca7e 100644 --- a/webui/src/components/setting/__tests__/config-plugins.test.ts +++ b/webui/src/components/setting/__tests__/config-plugins.test.ts @@ -68,6 +68,12 @@ function entry(overrides: Partial = {}): CatalogEntry { extension_points: ['notifier'], description: '', min_ab_version: '4.0.0', + authors: [], + permissions: [], + has_web: false, + repo: '', + commit: '', + readme: '', installed_version: null, ...overrides, }; diff --git a/webui/types/plugins.ts b/webui/types/plugins.ts index 9caabb43..8d880af7 100644 --- a/webui/types/plugins.ts +++ b/webui/types/plugins.ts @@ -64,6 +64,13 @@ export interface CatalogEntry { extension_points: string[]; description: string; min_ab_version: string; + authors: string[]; + permissions: string[]; + has_web: boolean; + /** 源码位置:作者仓库与固定的 commit */ + repo: string; + commit: string; + readme: string; installed_version: string | null; }