From c6b504924e57bc287f746dc0c37db5af0f11c9bd Mon Sep 17 00:00:00 2001 From: Art_Sakura <1754798088@qq.com> Date: Tue, 27 Oct 2026 18:43:42 +0800 Subject: [PATCH] =?UTF-8?q?fixed=20#=20=E4=BF=AE=E5=A4=8D=E9=83=A8?= =?UTF-8?q?=E5=88=86=E5=B7=A5=E4=BD=9C=E6=B5=81BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/nonebot-preview.yml | 6 +-- docs/README.md | 1 + docs/nonebot-release.md | 34 ++++++++++++++++ integrations/zhenxun/__init__.py | 3 +- readme/astrbot.md | 4 +- readme/zhenxun.md | 19 ++------- release/nonebot/publish.yaml | 6 +-- release/nonebot/verify.yml | 4 +- scripts/build_zhenxun.py | 7 ++++ scripts/check_workflows.py | 17 ++++++++ scripts/publish_nonebot.py | 38 ++++++++++++++++-- scripts/verify_zhenxun.py | 11 ++++++ tests/test_astrbot_package.py | 1 + tests/test_nonebot_package.py | 56 +++++++++++++++++++++++++++ tests/test_zhenxun_package.py | 5 +++ version.txt | 2 +- 16 files changed, 181 insertions(+), 33 deletions(-) create mode 100644 docs/nonebot-release.md diff --git a/.github/workflows/nonebot-preview.yml b/.github/workflows/nonebot-preview.yml index 49bf727..9efd563 100644 --- a/.github/workflows/nonebot-preview.yml +++ b/.github/workflows/nonebot-preview.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: publish: - description: Sync main branch in NoneBot repository + description: Sync NoneBot repository and trigger PyPI version release default: false type: boolean permissions: @@ -102,9 +102,9 @@ jobs: with: name: nonebot-preview path: incoming - - name: Sync validated NoneBot repository + - name: Sync NoneBot repository and trigger version release env: FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }} run: >- python scripts/publish_nonebot.py - incoming/nonebot_plugin_farm-*.zip --publish + incoming/nonebot_plugin_farm-*.zip --publish --release-tag diff --git a/docs/README.md b/docs/README.md index 916b934..c87018d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ | 文档 | 用途 | |---|---| +| [nonebot-release.md](nonebot-release.md) | NoneBot 同步、自动版本标签、PyPI发布及失败重跑 | | [catalogue-updates.md](catalogue-updates.md) | Service 作物维护、发布、启动更新、缓存与兼容回滚 | | [23-seasonal-events-and-rust-service.md](23-seasonal-events-and-rust-service.md) | 节日活动交互备选、Rust 发布中心、同步、资产事务、过期清理与上线规划(草案) | | [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 | diff --git a/docs/nonebot-release.md b/docs/nonebot-release.md new file mode 100644 index 0000000..165b078 --- /dev/null +++ b/docs/nonebot-release.md @@ -0,0 +1,34 @@ +# NoneBot 发布与版本更新 + +主仓库 `version.txt` 是版本来源。NoneBot preview 工作流通过测试并生成插件后, +在主仓库 `main` 推送或手动勾选 `publish` 时,执行以下操作: + +1. 校验生成包及来源工作区状态。 +2. 同步 `Shu-Ying/nonebot_plugin_farm` 的 `main` 分支。 +3. 在本次同步提交上创建 `v` 标签。 +4. 标签触发独立仓库的“🚀 发布到 PyPI”工作流,构建、校验并上传发行包。 + +PR及未勾选publish的手动运行只构建测试,不同步、不打标签。脚本未传 +`--publish` 时仍是离线预演;`--release-tag` 明确开启自动标签步骤。 +标签创建成功只表示触发条件已满足,不表示下游PyPI发布或商店检测已成功。 + +## 凭据与发布前提 + +- 主仓库受保护环境中继续使用 `FARM_RELEASE_TOKEN`,必须是可写目标仓库 + 内容及工作流的PAT或GitHub App token;不能替换为普通 `GITHUB_TOKEN`。 +- NoneBot独立仓库需允许Actions运行,并已配置有效的 `PYPI_TOKEN`。 +- 目标仓库标签规则也必须允许该发布身份创建 `v*` 标签。 +- 发布新内容前递增主仓库 `version.txt`;不要在发布仓库手工改生成的版本文件。 + +## 重跑和失败处理 + +已有同版本标签时,比较标签中生成清单的版本、包身份和全部文件摘要;内容 +一致则跳过同步和重复触发。只有来源提交信息变化而插件文件相同也会跳过。 +若内容不同则拒绝,并提示升级 `version.txt`;脚本不删除或移动旧标签。 + +同步main成功但创建标签失败时,工作流会报错,保留已同步代码;修复权限等 +原因后重跑。若标签已创建而下游PyPI上传失败,应在独立仓库重新运行失败的 +发布任务;上游重跑不会再次创建同名标签。PyPI已占用的版本不能靠移动标签 +覆盖,应使用新版本。 + +本地测试使用模拟GitHub请求,不会创建远端标签或上传PyPI。 diff --git a/integrations/zhenxun/__init__.py b/integrations/zhenxun/__init__.py index 40bd377..4abbbcf 100644 --- a/integrations/zhenxun/__init__.py +++ b/integrations/zhenxun/__init__.py @@ -3,7 +3,6 @@ from nonebot.plugin import PluginMetadata from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig -from ._version import __version__ from .runtime import COMMANDS, HELP __plugin_meta__ = PluginMetadata( @@ -12,7 +11,7 @@ __plugin_meta__ = PluginMetadata( homepage="https://github.com/Shu-Ying/zhenxun_plugin_farm", supported_adapters={"~onebot.v11", "~qq"}, extra=PluginExtraData( - author="Shu-Ying", version=__version__, menu_type="群内小游戏", + author="Shu-Ying", version="__FARM_VERSION__", menu_type="群内小游戏", commands=[Command(command=name) for name in COMMANDS], configs=[RegisterConfig( key="农场图片质量", value=75, default_value=75, type=int, diff --git a/readme/astrbot.md b/readme/astrbot.md index 659a3bc..9e8bf5d 100644 --- a/readme/astrbot.md +++ b/readme/astrbot.md @@ -49,9 +49,9 @@ --- ## 如何安装 +在Astrbot的插件商店下载即可 -方法一(推荐):在小真寻后台的插件商店下载即可
-方法二:下载源码放在小真寻`plugin`目录下 +`在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像` --- diff --git a/readme/zhenxun.md b/readme/zhenxun.md index 7ecf99f..2a1aed9 100644 --- a/readme/zhenxun.md +++ b/readme/zhenxun.md @@ -50,24 +50,11 @@ ## 如何安装 -使用真寻分发仓库的完整生成内容或Actions中的真寻预览包,不能直接 -复制主仓库的integrations目录。先停止机器人,备份代码与数据,停用 -会注册同名命令的旧插件,再在宿主环境安装本插件requirements.txt。 -Pillow要求>=10.4,<11,不强制升级宿主到12。安装后手工启动并检查日志。 +方法一(推荐):在小真寻后台的插件商店下载即可 -默认数据目录为真寻DATA_PATH下的farm_db,新库为farm.sqlite3,日志为 -farm-preview.log;旧farm.db不得直接改名覆盖。资源源默认Github,可在 -网页配置选择Gitea/farm_service;服务地址独立保留,修改资源源后重载。 -图片质量默认75、整数1~95,仅“我的农场 高清”支持高清且质量至少85。 -资源或字体下载失败时文字业务仍可用,详细原因见日志。 +方法二:下载源码放在小真寻`plugin`目录下 -升级前停机备份完整代码与数据目录;安装器拒绝覆盖,先移走旧目录。 -回退需匹配代码和数据库备份,不让旧代码强行打开新schema;恢复旧数据 -会丢失备份后进度。卸载时停止加载并移走插件代码,保留数据供恢复。 - -开发者构建、包验证、安全安装、配置、备份卸载与Secrets详见 -[当前维护说明](https://github.com/Shu-Ying/main_plugin_farm/blob/main/docs/zhenxun-preview-current.md)。 -本地离线验证不代表真实真寻宿主、公开资源或远端发布已验证。 +`在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像` --- diff --git a/release/nonebot/publish.yaml b/release/nonebot/publish.yaml index 14b84b7..4619b84 100644 --- a/release/nonebot/publish.yaml +++ b/release/nonebot/publish.yaml @@ -31,12 +31,12 @@ jobs: raise SystemExit('Tag differs from version.txt') PY - run: python -m pip install build twine - - run: python -m build --sdist --wheel --outdir ../packages - - run: python -m twine check ../packages/* + - run: python -m build --sdist --wheel --outdir dist + - run: python -m twine check dist/* - uses: actions/upload-artifact@v7 with: name: pypi-distributions - path: ../packages/* + path: dist/* if-no-files-found: error publish: needs: build diff --git a/release/nonebot/verify.yml b/release/nonebot/verify.yml index a106074..9e05472 100644 --- a/release/nonebot/verify.yml +++ b/release/nonebot/verify.yml @@ -18,9 +18,9 @@ jobs: python-version: '3.10' - run: python verify_package.py --directory . - run: python -m pip install build - - run: python -m build --sdist --wheel --outdir ../packages + - run: python -m build --sdist --wheel --outdir dist - uses: actions/upload-artifact@v7 with: name: nonebot-distributions - path: ../packages/* + path: dist/* if-no-files-found: error diff --git a/scripts/build_zhenxun.py b/scripts/build_zhenxun.py index 0931511..f47f0f5 100644 --- a/scripts/build_zhenxun.py +++ b/scripts/build_zhenxun.py @@ -54,6 +54,13 @@ def build(root, output, *, help_image=None): "requirements.txt", "config_migration.py", ): files[name] = (integration / name).read_bytes() + # 真寻市场静态读取版本字段,生成时写入字面量而非运行时变量。 + marker = b'"__FARM_VERSION__"' + if files['__init__.py'].count(marker) != 1: + raise ValueError('Zhenxun entry requires one version placeholder') + files['__init__.py'] = files['__init__.py'].replace( + marker, json.dumps(version).encode('ascii'), + ) files["runtime.py"] = ( root / "integrations/shared/runtime.py" ).read_bytes() diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py index 62f7dc2..d96e9c1 100644 --- a/scripts/check_workflows.py +++ b/scripts/check_workflows.py @@ -97,6 +97,8 @@ def main(): token = sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"] if token != "${{ secrets.FARM_RELEASE_TOKEN }}": raise ValueError("NoneBot must reuse FARM_RELEASE_TOKEN") + if '--release-tag' not in sync['steps'][-1]['run'].split(): + raise ValueError('NoneBot sync must create its version release tag') generated = yaml.load(( root / "release/nonebot/verify.yml" ).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) @@ -113,6 +115,21 @@ def main(): "github.repository == 'Shu-Ying/nonebot_plugin_farm'" )): raise ValueError("NoneBot PyPI boundary differs") + for workflow, job in ((generated, 'verify'), (publishing, 'build')): + steps = workflow['jobs'][job]['steps'] + if not any(step.get('run') == ( + 'python -m build --sdist --wheel --outdir dist' + ) for step in steps): + raise ValueError('NoneBot distributions must build inside dist') + upload = next(step for step in steps + if step.get('uses', '').startswith( + 'actions/upload-artifact@')) + if (upload['with']['path'] != 'dist/*' + or upload['with'].get('if-no-files-found') != 'error'): + raise ValueError('NoneBot upload path must be dist/*') + if not any(step.get('run') == 'python -m twine check dist/*' + for step in publishing['jobs']['build']['steps']): + raise ValueError('NoneBot distribution check must use dist/*') steps = publishing["jobs"]["publish"]["steps"] if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}": raise ValueError("Keep the existing PyPI secret") diff --git a/scripts/publish_nonebot.py b/scripts/publish_nonebot.py index 8a40be0..2708ce1 100644 --- a/scripts/publish_nonebot.py +++ b/scripts/publish_nonebot.py @@ -4,6 +4,7 @@ import argparse import base64 import json import os +import re from urllib.error import HTTPError from urllib.request import Request, urlopen @@ -37,13 +38,19 @@ def github(method, path, payload, token, allow_missing=False): ) from None -def publish(package, confirmed=False, request=None): +def publish(package, confirmed=False, request=None, *, release_tag=False): files = read_package(package) manifest = verify_files(files) + if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z0-9.-]+)?', + manifest['version']): + raise ValueError('Invalid release version in version.txt') message = commit_message(manifest) result = {"repository": REPOSITORY, "branch": BRANCH, "version": manifest["version"], "message": message, "published": False} + tag = 'v' + manifest['version'] + if release_tag: + result.update(tag=tag, tag_created=False) if not confirmed: return result if manifest["source"]["dirty"]: @@ -53,10 +60,23 @@ def publish(package, confirmed=False, request=None): 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/")), + allow_missing=path.startswith(( + "git/ref/heads/", "git/ref/tags/", "contents/", + )), )) if not call("GET", "", None): raise ValueError(f"Target repository is not accessible: {REPOSITORY}") + if release_tag and call('GET', f'git/ref/tags/{tag}', None): + owned = call('GET', f'contents/FARM-MANIFEST.json?ref={tag}', None) + if owned is None or owned.get('encoding') != 'base64': + raise ValueError('Existing release is not managed; bump version.txt') + previous = json.loads(base64.b64decode(owned['content'])) + if (previous.get('target') != 'nonebot' + or previous.get('plugin') != manifest['plugin'] + or previous.get('version') != manifest['version'] + or previous.get('files') != manifest['files']): + raise ValueError('Release tag has different files; bump version.txt') + return {**result, 'tag_reused': True} ref = call("GET", f"git/ref/heads/{BRANCH}", None) parent = ref["object"]["sha"] if ref else None if parent: @@ -92,15 +112,25 @@ def publish(package, confirmed=False, request=None): call("POST", "git/refs", { "ref": f"refs/heads/{BRANCH}", "sha": commit["sha"], }) - return {**result, "published": True, "commit": commit["sha"]} + result.update(published=True, commit=commit['sha']) + if release_tag: + # 仅创建不可变标签;失败保留已同步main,重跑时重新检查,不移动旧标签。 + call('POST', 'git/refs', { + 'ref': f'refs/tags/{tag}', 'sha': commit['sha'], + }) + result['tag_created'] = True + return result def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("package") parser.add_argument("--publish", action="store_true") + parser.add_argument('--release-tag', action='store_true', + help='同步后创建v版本标签,触发目标仓库PyPI发布') args = parser.parse_args() - print(json.dumps(publish(args.package, args.publish), ensure_ascii=False)) + print(json.dumps(publish(args.package, args.publish, + release_tag=args.release_tag), ensure_ascii=False)) if __name__ == "__main__": diff --git a/scripts/verify_zhenxun.py b/scripts/verify_zhenxun.py index 7e00884..6846fc3 100644 --- a/scripts/verify_zhenxun.py +++ b/scripts/verify_zhenxun.py @@ -1,6 +1,7 @@ """独立验证生成的真寻包;仅标准库,不导入或启动插件。""" import argparse +import ast import hashlib import json from pathlib import Path, PurePosixPath @@ -45,6 +46,16 @@ def verify_files(files): raise ValueError('Resource ZIP must not be in plugin repository') if not required <= set(files): raise ValueError("Incomplete plugin") + tree = ast.parse(files['__init__.py']) + versions = [ + keyword.value for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == 'PluginExtraData' + for keyword in node.keywords if keyword.arg == 'version' + ] + if (len(versions) != 1 or not isinstance(versions[0], ast.Constant) + or versions[0].value != manifest['version']): + raise ValueError('PluginExtraData requires matching literal version') return manifest diff --git a/tests/test_astrbot_package.py b/tests/test_astrbot_package.py index 683309f..868d8eb 100644 --- a/tests/test_astrbot_package.py +++ b/tests/test_astrbot_package.py @@ -154,6 +154,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(plugin.runtime.data_dir, (self.root / 'existing-custom-data').resolve()) self.assertIsNone(plugin.runtime.font_path) + self.assertEqual(plugin.runtime.resource_source, 'Github') # 使用真实开发素材预置缓存,模拟宿主测试不访问网络。 manifest = json.loads((ROOT / 'src/farm_render/data/asset-manifest.json').read_bytes()) diff --git a/tests/test_nonebot_package.py b/tests/test_nonebot_package.py index b8f5eea..b6456e9 100644 --- a/tests/test_nonebot_package.py +++ b/tests/test_nonebot_package.py @@ -165,3 +165,59 @@ class NoneBotPackageTests(unittest.TestCase): with self.assertRaises((ValueError, RuntimeError)): publish(bundle, True, request) self.assertFalse(any(method == "PATCH" for method, _, _ in calls)) + + def test_release_tag_is_created_after_main_sync(self): + bundle, manifest = self.clean_package() + calls = [] + def request(method, path, data): + calls.append((method, path, data)) + if path == '': + return {'default_branch': 'main'} + if path.startswith('git/ref/tags/'): + return None + if path.startswith('git/ref/heads/'): + return {'object': {'sha': 'existing'}} + if path.startswith('contents/'): + return None + return {'sha': 'generated'} + with patch.dict('os.environ', {'FARM_RELEASE_TOKEN': 'test'}): + result = publish(bundle, True, request, release_tag=True) + self.assertTrue(result['tag_created']) + self.assertEqual(calls[-1], ('POST', 'git/refs', { + 'ref': 'refs/tags/v' + manifest['version'], 'sha': 'generated', + })) + self.assertLess(next(i for i, call in enumerate(calls) + if call[0] == 'PATCH'), len(calls) - 1) + + def test_existing_release_tag_is_reused_or_conflict_blocks_all_writes(self): + bundle, manifest = self.clean_package() + for matching in (True, False): + calls = [] + previous = json.loads(json.dumps(manifest)) + if not matching: + previous['files']['README.md'] = '0' * 64 + def request(method, path, data): + calls.append((method, path, data)) + if path == '': + return {'default_branch': 'main'} + if path.startswith('git/ref/tags/'): + return {'object': {'sha': 'released'}} + if path.startswith('contents/'): + return {'encoding': 'base64', 'content': base64.b64encode( + json.dumps(previous).encode(), + ).decode()} + self.fail('Unexpected request: ' + path) + with patch.dict('os.environ', {'FARM_RELEASE_TOKEN': 'test'}): + if matching: + result = publish(bundle, True, request, release_tag=True) + self.assertFalse(result['tag_created']) + else: + with self.assertRaisesRegex(ValueError, 'version.txt'): + publish(bundle, True, request, release_tag=True) + self.assertTrue(all(method == 'GET' for method, _, _ in calls)) + + def test_release_tag_dry_run_does_not_access_remote(self): + result = publish(self.bundle, release_tag=True, + request=lambda *args: self.fail('Network in dry run')) + self.assertEqual(result['tag'], 'v' + result['version']) + self.assertFalse(result['tag_created']) diff --git a/tests/test_zhenxun_package.py b/tests/test_zhenxun_package.py index ca6c499..2488bb2 100644 --- a/tests/test_zhenxun_package.py +++ b/tests/test_zhenxun_package.py @@ -157,6 +157,11 @@ class ZhenxunPackageTests(unittest.TestCase): self.assertEqual(archive.read( "zhenxun_plugin_farm_preview/_version.py", ), b'__version__ = "1.2.3-preview.4"\n') + entry = archive.read( + 'zhenxun_plugin_farm_preview/__init__.py', + ).decode() + self.assertIn('version="1.2.3-preview.4"', entry) + self.assertNotIn('__version__', entry) def clean_package(self): from verify_zhenxun import read_package, MANIFEST, ROOT_NAME diff --git a/version.txt b/version.txt index 26e3379..b9d2bdf 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.4 \ No newline at end of file +2.0.5 \ No newline at end of file