fixed # 修复部分工作流BUG

This commit is contained in:
2026-10-27 18:43:42 +08:00
parent d05b2e5765
commit c6b504924e
16 changed files with 181 additions and 33 deletions
+3 -3
View File
@@ -6,7 +6,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
publish: publish:
description: Sync main branch in NoneBot repository description: Sync NoneBot repository and trigger PyPI version release
default: false default: false
type: boolean type: boolean
permissions: permissions:
@@ -102,9 +102,9 @@ jobs:
with: with:
name: nonebot-preview name: nonebot-preview
path: incoming path: incoming
- name: Sync validated NoneBot repository - name: Sync NoneBot repository and trigger version release
env: env:
FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }} FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }}
run: >- run: >-
python scripts/publish_nonebot.py python scripts/publish_nonebot.py
incoming/nonebot_plugin_farm-*.zip --publish incoming/nonebot_plugin_farm-*.zip --publish --release-tag
+1
View File
@@ -6,6 +6,7 @@
| 文档 | 用途 | | 文档 | 用途 |
|---|---| |---|---|
| [nonebot-release.md](nonebot-release.md) | NoneBot 同步、自动版本标签、PyPI发布及失败重跑 |
| [catalogue-updates.md](catalogue-updates.md) | Service 作物维护、发布、启动更新、缓存与兼容回滚 | | [catalogue-updates.md](catalogue-updates.md) | Service 作物维护、发布、启动更新、缓存与兼容回滚 |
| [23-seasonal-events-and-rust-service.md](23-seasonal-events-and-rust-service.md) | 节日活动交互备选、Rust 发布中心、同步、资产事务、过期清理与上线规划(草案) | | [23-seasonal-events-and-rust-service.md](23-seasonal-events-and-rust-service.md) | 节日活动交互备选、Rust 发布中心、同步、资产事务、过期清理与上线规划(草案) |
| [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 | | [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 |
+34
View File
@@ -0,0 +1,34 @@
# NoneBot 发布与版本更新
主仓库 `version.txt` 是版本来源。NoneBot preview 工作流通过测试并生成插件后,
在主仓库 `main` 推送或手动勾选 `publish` 时,执行以下操作:
1. 校验生成包及来源工作区状态。
2. 同步 `Shu-Ying/nonebot_plugin_farm` 的 `main` 分支。
3. 在本次同步提交上创建 `v<version.txt>` 标签。
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。
+1 -2
View File
@@ -3,7 +3,6 @@
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
from ._version import __version__
from .runtime import COMMANDS, HELP from .runtime import COMMANDS, HELP
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
@@ -12,7 +11,7 @@ __plugin_meta__ = PluginMetadata(
homepage="https://github.com/Shu-Ying/zhenxun_plugin_farm", homepage="https://github.com/Shu-Ying/zhenxun_plugin_farm",
supported_adapters={"~onebot.v11", "~qq"}, supported_adapters={"~onebot.v11", "~qq"},
extra=PluginExtraData( 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], commands=[Command(command=name) for name in COMMANDS],
configs=[RegisterConfig( configs=[RegisterConfig(
key="农场图片质量", value=75, default_value=75, type=int, key="农场图片质量", value=75, default_value=75, type=int,
+2 -2
View File
@@ -49,9 +49,9 @@
--- ---
## 如何安装 ## 如何安装
在Astrbot的插件商店下载即可
方法一(推荐):在小真寻后台的插件商店下载即可<br> `在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像`
方法二:下载源码放在小真寻`plugin`目录下
--- ---
+3 -16
View File
@@ -50,24 +50,11 @@
## 如何安装 ## 如何安装
使用真寻分发仓库的完整生成内容或Actions中的真寻预览包,不能直接 方法一(推荐):在小真寻后台的插件商店下载即可
复制主仓库的integrations目录。先停止机器人,备份代码与数据,停用
会注册同名命令的旧插件,再在宿主环境安装本插件requirements.txt。
Pillow要求>=10.4,<11,不强制升级宿主到12。安装后手工启动并检查日志。
默认数据目录为真寻DATA_PATH下的farm_db,新库为farm.sqlite3,日志为 方法二:下载源码放在小真寻`plugin`目录下
farm-preview.log;旧farm.db不得直接改名覆盖。资源源默认Github,可在
网页配置选择Gitea/farm_service;服务地址独立保留,修改资源源后重载。
图片质量默认75、整数1~95,仅“我的农场 高清”支持高清且质量至少85。
资源或字体下载失败时文字业务仍可用,详细原因见日志。
升级前停机备份完整代码与数据目录;安装器拒绝覆盖,先移走旧目录。 `在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像`
回退需匹配代码和数据库备份,不让旧代码强行打开新schema;恢复旧数据
会丢失备份后进度。卸载时停止加载并移走插件代码,保留数据供恢复。
开发者构建、包验证、安全安装、配置、备份卸载与Secrets详见
[当前维护说明](https://github.com/Shu-Ying/main_plugin_farm/blob/main/docs/zhenxun-preview-current.md)。
本地离线验证不代表真实真寻宿主、公开资源或远端发布已验证。
--- ---
+3 -3
View File
@@ -31,12 +31,12 @@ jobs:
raise SystemExit('Tag differs from version.txt') raise SystemExit('Tag differs from version.txt')
PY PY
- run: python -m pip install build twine - run: python -m pip install build twine
- run: python -m build --sdist --wheel --outdir ../packages - run: python -m build --sdist --wheel --outdir dist
- run: python -m twine check ../packages/* - run: python -m twine check dist/*
- uses: actions/upload-artifact@v7 - uses: actions/upload-artifact@v7
with: with:
name: pypi-distributions name: pypi-distributions
path: ../packages/* path: dist/*
if-no-files-found: error if-no-files-found: error
publish: publish:
needs: build needs: build
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
python-version: '3.10' python-version: '3.10'
- run: python verify_package.py --directory . - run: python verify_package.py --directory .
- run: python -m pip install build - 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 - uses: actions/upload-artifact@v7
with: with:
name: nonebot-distributions name: nonebot-distributions
path: ../packages/* path: dist/*
if-no-files-found: error if-no-files-found: error
+7
View File
@@ -54,6 +54,13 @@ def build(root, output, *, help_image=None):
"requirements.txt", "config_migration.py", "requirements.txt", "config_migration.py",
): ):
files[name] = (integration / name).read_bytes() 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"] = ( files["runtime.py"] = (
root / "integrations/shared/runtime.py" root / "integrations/shared/runtime.py"
).read_bytes() ).read_bytes()
+17
View File
@@ -97,6 +97,8 @@ def main():
token = sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"] token = sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"]
if token != "${{ secrets.FARM_RELEASE_TOKEN }}": if token != "${{ secrets.FARM_RELEASE_TOKEN }}":
raise ValueError("NoneBot must reuse 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(( generated = yaml.load((
root / "release/nonebot/verify.yml" root / "release/nonebot/verify.yml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) ).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
@@ -113,6 +115,21 @@ def main():
"github.repository == 'Shu-Ying/nonebot_plugin_farm'" "github.repository == 'Shu-Ying/nonebot_plugin_farm'"
)): )):
raise ValueError("NoneBot PyPI boundary differs") 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"] steps = publishing["jobs"]["publish"]["steps"]
if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}": if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}":
raise ValueError("Keep the existing PyPI secret") raise ValueError("Keep the existing PyPI secret")
+34 -4
View File
@@ -4,6 +4,7 @@ import argparse
import base64 import base64
import json import json
import os import os
import re
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -37,13 +38,19 @@ def github(method, path, payload, token, allow_missing=False):
) from None ) from None
def publish(package, confirmed=False, request=None): def publish(package, confirmed=False, request=None, *, release_tag=False):
files = read_package(package) files = read_package(package)
manifest = verify_files(files) 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) message = commit_message(manifest)
result = {"repository": REPOSITORY, "branch": BRANCH, result = {"repository": REPOSITORY, "branch": BRANCH,
"version": manifest["version"], "message": message, "version": manifest["version"], "message": message,
"published": False} "published": False}
tag = 'v' + manifest['version']
if release_tag:
result.update(tag=tag, tag_created=False)
if not confirmed: if not confirmed:
return result return result
if manifest["source"]["dirty"]: 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") raise ValueError("Configure FARM_RELEASE_TOKEN in a protected job")
call = request or (lambda method, path, data: github( call = request or (lambda method, path, data: github(
method, path, data, token, 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): if not call("GET", "", None):
raise ValueError(f"Target repository is not accessible: {REPOSITORY}") 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) ref = call("GET", f"git/ref/heads/{BRANCH}", None)
parent = ref["object"]["sha"] if ref else None parent = ref["object"]["sha"] if ref else None
if parent: if parent:
@@ -92,15 +112,25 @@ def publish(package, confirmed=False, request=None):
call("POST", "git/refs", { call("POST", "git/refs", {
"ref": f"refs/heads/{BRANCH}", "sha": commit["sha"], "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(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("package") parser.add_argument("package")
parser.add_argument("--publish", action="store_true") parser.add_argument("--publish", action="store_true")
parser.add_argument('--release-tag', action='store_true',
help='同步后创建v版本标签,触发目标仓库PyPI发布')
args = parser.parse_args() 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__": if __name__ == "__main__":
+11
View File
@@ -1,6 +1,7 @@
"""独立验证生成的真寻包;仅标准库,不导入或启动插件。""" """独立验证生成的真寻包;仅标准库,不导入或启动插件。"""
import argparse import argparse
import ast
import hashlib import hashlib
import json import json
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
@@ -45,6 +46,16 @@ def verify_files(files):
raise ValueError('Resource ZIP must not be in plugin repository') raise ValueError('Resource ZIP must not be in plugin repository')
if not required <= set(files): if not required <= set(files):
raise ValueError("Incomplete plugin") 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 return manifest
+1
View File
@@ -154,6 +154,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(plugin.runtime.data_dir, self.assertEqual(plugin.runtime.data_dir,
(self.root / 'existing-custom-data').resolve()) (self.root / 'existing-custom-data').resolve())
self.assertIsNone(plugin.runtime.font_path) self.assertIsNone(plugin.runtime.font_path)
self.assertEqual(plugin.runtime.resource_source, 'Github')
# 使用真实开发素材预置缓存,模拟宿主测试不访问网络。 # 使用真实开发素材预置缓存,模拟宿主测试不访问网络。
manifest = json.loads((ROOT / manifest = json.loads((ROOT /
'src/farm_render/data/asset-manifest.json').read_bytes()) 'src/farm_render/data/asset-manifest.json').read_bytes())
+56
View File
@@ -165,3 +165,59 @@ class NoneBotPackageTests(unittest.TestCase):
with self.assertRaises((ValueError, RuntimeError)): with self.assertRaises((ValueError, RuntimeError)):
publish(bundle, True, request) publish(bundle, True, request)
self.assertFalse(any(method == "PATCH" for method, _, _ in calls)) 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'])
+5
View File
@@ -157,6 +157,11 @@ class ZhenxunPackageTests(unittest.TestCase):
self.assertEqual(archive.read( self.assertEqual(archive.read(
"zhenxun_plugin_farm_preview/_version.py", "zhenxun_plugin_farm_preview/_version.py",
), b'__version__ = "1.2.3-preview.4"\n') ), 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): def clean_package(self):
from verify_zhenxun import read_package, MANIFEST, ROOT_NAME from verify_zhenxun import read_package, MANIFEST, ROOT_NAME
+1 -1
View File
@@ -1 +1 @@
2.0.4 2.0.5