diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36acfafb5..4950b7a4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,6 @@ on: push: tags: - "v*" - schedule: - # Daily at 00:00 UTC - - cron: "0 0 * * *" workflow_dispatch: inputs: ref: @@ -21,133 +18,15 @@ permissions: contents: write jobs: - resolve-release-context: - name: Resolve Release Context - runs-on: ubuntu-24.04 - outputs: - checkout_ref: ${{ steps.checkout-ref.outputs.ref }} - tag: ${{ steps.tag.outputs.tag }} - nightly_tag: ${{ steps.nightly-tag.outputs.nightly_tag }} - repo_slug: ${{ steps.repo-slug.outputs.repo_slug }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Resolve nightly tag - id: nightly-tag - shell: bash - run: | - nightly_tag="$(python3 -m scripts.release.print_release_constant NIGHTLY_TAG)" - if [ -z "$nightly_tag" ]; then - echo "Failed to resolve NIGHTLY_TAG from astrbot/core/release_constants.py." >&2 - exit 1 - fi - echo "nightly_tag=$nightly_tag" >> "$GITHUB_OUTPUT" - - - name: Resolve repository slug - id: repo-slug - shell: bash - run: | - repo_slug="$(python3 -m scripts.release.print_release_constant GITHUB_REPO_SLUG)" - if [ -z "$repo_slug" ]; then - echo "Failed to resolve GITHUB_REPO_SLUG from astrbot/core/release_constants.py." >&2 - exit 1 - fi - echo "repo_slug=$repo_slug" >> "$GITHUB_OUTPUT" - - - name: Resolve checkout ref - id: checkout-ref - shell: bash - run: | - event_name="${{ github.event_name }}" - nightly_tag="${{ steps.nightly-tag.outputs.nightly_tag }}" - input_tag="" - input_ref="" - if [ "$event_name" = "workflow_dispatch" ]; then - input_tag="$(jq -r '.inputs.tag // ""' "$GITHUB_EVENT_PATH")" - input_ref="$(jq -r '.inputs.ref // ""' "$GITHUB_EVENT_PATH")" - fi - default_branch="${{ github.event.repository.default_branch }}" - if [ -z "$default_branch" ]; then - default_branch="master" - fi - - if [ "$event_name" = "schedule" ]; then - ref="refs/heads/${default_branch}" - elif [ "$event_name" = "workflow_dispatch" ] && [ -n "$input_ref" ]; then - ref="$input_ref" - elif [ "$event_name" = "workflow_dispatch" ] && [ "$input_tag" = "$nightly_tag" ]; then - ref="refs/heads/${default_branch}" - elif [ -n "$input_ref" ]; then - ref="$input_ref" - else - ref="${{ github.ref }}" - fi - - echo "ref=$ref" >> "$GITHUB_OUTPUT" - - - name: Switch repository to release ref - shell: bash - run: | - target_ref="${{ steps.checkout-ref.outputs.ref }}" - if [ -z "$target_ref" ]; then - echo "Resolved checkout ref is empty." >&2 - exit 1 - fi - - if ! git checkout --force "$target_ref" >/tmp/git_checkout_ref.log 2>&1; then - if ! git fetch --force origin "$target_ref" >/tmp/git_fetch_ref.log 2>&1; then - echo "Failed to fetch checkout ref: $target_ref" >&2 - cat /tmp/git_fetch_ref.log >&2 || true - exit 1 - fi - git checkout --force FETCH_HEAD - fi - - - name: Resolve tag - id: tag - shell: bash - run: | - event_name="${{ github.event_name }}" - nightly_tag="${{ steps.nightly-tag.outputs.nightly_tag }}" - dispatch_tag="" - if [ "$event_name" = "workflow_dispatch" ]; then - dispatch_tag="$(jq -r '.inputs.tag // ""' "$GITHUB_EVENT_PATH")" - fi - - if [ "$event_name" = "schedule" ]; then - tag="$nightly_tag" - elif [ "$event_name" = "push" ]; then - tag="${GITHUB_REF_NAME}" - elif [ -n "$dispatch_tag" ]; then - tag="$dispatch_tag" - else - # workflow_dispatch without explicit tag should default to latest stable v-tag. - tag="$(git tag --list 'v*' --sort=-version:refname | grep -E '^v[0-9]+(\.[0-9]+)*$' | head -n1)" - if [ -z "$tag" ]; then - echo "Failed to resolve latest stable v-tag." >&2 - exit 1 - fi - fi - if [ -z "$tag" ]; then - echo "Failed to resolve tag." >&2 - exit 1 - fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" - verify-core: name: Verify Core Quality Gate runs-on: ubuntu-24.04 - needs: - - resolve-release-context steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ needs.resolve-release-context.outputs.checkout_ref }} + ref: ${{ inputs.ref || github.ref }} - name: Set up Python uses: actions/setup-python@v6 @@ -165,8 +44,6 @@ jobs: build-dashboard: name: Build Dashboard runs-on: ubuntu-24.04 - needs: - - resolve-release-context env: R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} @@ -176,7 +53,24 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ needs.resolve-release-context.outputs.checkout_ref }} + ref: ${{ inputs.ref || github.ref }} + + - name: Resolve tag + id: tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + tag="${GITHUB_REF_NAME}" + elif [ -n "${{ inputs.tag }}" ]; then + tag="${{ inputs.tag }}" + else + tag="$(git describe --tags --abbrev=0)" + fi + if [ -z "$tag" ]; then + echo "Failed to resolve tag." >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -195,23 +89,23 @@ jobs: run: | pnpm --dir dashboard install --frozen-lockfile pnpm --dir dashboard run build - echo "${{ needs.resolve-release-context.outputs.tag }}" > dashboard/dist/assets/version + echo "${{ steps.tag.outputs.tag }}" > dashboard/dist/assets/version cd dashboard - zip -r "AstrBot-${{ needs.resolve-release-context.outputs.tag }}-dashboard.zip" dist + zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist - name: Upload dashboard artifact uses: actions/upload-artifact@v7 with: - name: Dashboard-${{ needs.resolve-release-context.outputs.tag }} + name: Dashboard-${{ steps.tag.outputs.tag }} if-no-files-found: error - path: dashboard/AstrBot-${{ needs.resolve-release-context.outputs.tag }}-dashboard.zip + path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip - name: Upload dashboard package to Cloudflare R2 - if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' && needs.resolve-release-context.outputs.tag != needs.resolve-release-context.outputs.nightly_tag }} + if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }} env: R2_BUCKET_NAME: "astrbot" R2_OBJECT_NAME: "astrbot-webui-latest.zip" - VERSION_TAG: ${{ needs.resolve-release-context.outputs.tag }} + VERSION_TAG: ${{ steps.tag.outputs.tag }} shell: bash run: | sudo apt-get update @@ -235,11 +129,7 @@ jobs: publish-release: name: Publish GitHub Release runs-on: ubuntu-24.04 - concurrency: - group: ${{ needs.resolve-release-context.outputs.tag == needs.resolve-release-context.outputs.nightly_tag && 'nightly-release' || format('release-{0}', needs.resolve-release-context.outputs.tag) }} - cancel-in-progress: ${{ needs.resolve-release-context.outputs.tag == needs.resolve-release-context.outputs.nightly_tag }} needs: - - resolve-release-context - verify-core - build-dashboard steps: @@ -247,51 +137,29 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ needs.resolve-release-context.outputs.checkout_ref }} + ref: ${{ inputs.ref || github.ref }} - - name: Resolve release title - id: release-meta + - name: Resolve tag + id: tag shell: bash run: | - tag="${{ needs.resolve-release-context.outputs.tag }}" - nightly_tag="${{ needs.resolve-release-context.outputs.nightly_tag }}" - if [ "$tag" = "$nightly_tag" ]; then - if ! short_sha="$(git rev-parse --short=8 HEAD 2>/tmp/git_rev_parse_error.log)"; then - echo "Failed to resolve HEAD short SHA for nightly title." >&2 - cat /tmp/git_rev_parse_error.log >&2 || true - exit 1 - fi - base_version="$(git tag --list 'v*' --sort=-version:refname | grep -E '^v[0-9]+(\.[0-9]+)*$' | head -n1)" - if [ -z "$base_version" ]; then - base_version="v0.0.0" - fi - title="${base_version}-${nightly_tag}-${short_sha}" + if [ "${{ github.event_name }}" = "push" ]; then + tag="${GITHUB_REF_NAME}" + elif [ -n "${{ inputs.tag }}" ]; then + tag="${{ inputs.tag }}" else - base_version="$tag" - title="$tag" + tag="$(git describe --tags --abbrev=0)" fi - echo "title=$title" >> "$GITHUB_OUTPUT" - echo "base_version=$base_version" >> "$GITHUB_OUTPUT" - - - name: Force-update nightly tag - if: ${{ needs.resolve-release-context.outputs.tag == needs.resolve-release-context.outputs.nightly_tag }} - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - nightly_tag="${{ needs.resolve-release-context.outputs.nightly_tag }}" - if ! current_sha="$(git rev-parse HEAD 2>/tmp/git_rev_parse_error.log)"; then - echo "Failed to resolve HEAD SHA before updating nightly tag." >&2 - cat /tmp/git_rev_parse_error.log >&2 || true + if [ -z "$tag" ]; then + echo "Failed to resolve tag." >&2 exit 1 fi - git tag -f "$nightly_tag" "${current_sha}" - git push --force origin "refs/tags/${nightly_tag}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Download dashboard artifact uses: actions/download-artifact@v8 with: - name: Dashboard-${{ needs.resolve-release-context.outputs.tag }} + name: Dashboard-${{ steps.tag.outputs.tag }} path: release-assets @@ -299,32 +167,10 @@ jobs: id: notes shell: bash run: | - tag="${{ needs.resolve-release-context.outputs.tag }}" - nightly_tag="${{ needs.resolve-release-context.outputs.nightly_tag }}" - if [ "$tag" = "$nightly_tag" ]; then + note_file="changelogs/${{ steps.tag.outputs.tag }}.md" + if [ ! -f "$note_file" ]; then note_file="$(mktemp)" - if [ -f "scripts/release/generate_nightly_release_notes.py" ]; then - python3 -m scripts.release.generate_nightly_release_notes \ - --base-tag "${{ steps.release-meta.outputs.base_version }}" \ - --repo "${{ needs.resolve-release-context.outputs.repo_slug }}" \ - --output "$note_file" - else - short_sha="$(git rev-parse --short=8 HEAD)" - echo "Nightly notes script is missing at this ref; using fallback notes." - { - echo "## What's Changed" - echo "" - echo "- Baseline tag: \`${{ steps.release-meta.outputs.base_version }}\`" - echo "- Nightly commit: \`$short_sha\`" - echo "- No changes summary generator available in this ref." - } > "$note_file" - fi - else - note_file="changelogs/${tag}.md" - if [ ! -f "$note_file" ]; then - note_file="$(mktemp)" - echo "Release ${tag}" > "$note_file" - fi + echo "Release ${{ steps.tag.outputs.tag }}" > "$note_file" fi echo "file=$note_file" >> "$GITHUB_OUTPUT" @@ -333,18 +179,9 @@ jobs: GH_TOKEN: ${{ github.token }} shell: bash run: | - tag="${{ needs.resolve-release-context.outputs.tag }}" - title="${{ steps.release-meta.outputs.title }}" - nightly_tag="${{ needs.resolve-release-context.outputs.nightly_tag }}" - if [ "$tag" = "$nightly_tag" ]; then - pre_flag="--prerelease" - else - pre_flag="" - fi + tag="${{ steps.tag.outputs.tag }}" if ! gh release view "$tag" >/dev/null 2>&1; then - gh release create "$tag" --title "$title" --notes-file "${{ steps.notes.outputs.file }}" $pre_flag - elif [ "$tag" = "$nightly_tag" ]; then - gh release edit "$tag" --title "$title" --notes-file "${{ steps.notes.outputs.file }}" --prerelease + gh release create "$tag" --title "$tag" --notes-file "${{ steps.notes.outputs.file }}" fi - name: Remove stale assets from release @@ -352,10 +189,10 @@ jobs: GH_TOKEN: ${{ github.token }} shell: bash run: | - tag="${{ needs.resolve-release-context.outputs.tag }}" + tag="${{ steps.tag.outputs.tag }}" while IFS= read -r asset; do case "$asset" in - AstrBot-*-dashboard.zip) + *.AppImage|*.dmg|*.zip|*.exe|*.blockmap) gh release delete-asset "$tag" "$asset" -y || true ;; esac @@ -366,34 +203,49 @@ jobs: GH_TOKEN: ${{ github.token }} shell: bash run: | - tag="${{ needs.resolve-release-context.outputs.tag }}" - gh release upload "$tag" "release-assets/AstrBot-${tag}-dashboard.zip" --clobber + tag="${{ steps.tag.outputs.tag }}" + gh release upload "$tag" release-assets/* --clobber publish-pypi: name: Publish PyPI - if: ${{ needs.resolve-release-context.outputs.tag != needs.resolve-release-context.outputs.nightly_tag }} runs-on: ubuntu-24.04 needs: - - resolve-release-context - publish-release steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ needs.resolve-release-context.outputs.checkout_ref }} + ref: ${{ inputs.ref || github.ref }} + + - name: Resolve tag + id: tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + tag="${GITHUB_REF_NAME}" + elif [ -n "${{ inputs.tag }}" ]; then + tag="${{ inputs.tag }}" + else + tag="$(git describe --tags --abbrev=0)" + fi + if [ -z "$tag" ]; then + echo "Failed to resolve tag." >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Download dashboard artifact uses: actions/download-artifact@v8 with: - name: Dashboard-${{ needs.resolve-release-context.outputs.tag }} + name: Dashboard-${{ steps.tag.outputs.tag }} path: dashboard-artifact - name: Unpack dashboard dist into package tree shell: bash run: | mkdir -p astrbot/dashboard/dist - unzip -q "dashboard-artifact/AstrBot-${{ needs.resolve-release-context.outputs.tag }}-dashboard.zip" -d dashboard-artifact/unpacked + unzip -q "dashboard-artifact/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" -d dashboard-artifact/unpacked cp -r dashboard-artifact/unpacked/dist/. astrbot/dashboard/dist/ - name: Set up Python diff --git a/astrbot/core/release_constants.py b/astrbot/core/release_constants.py deleted file mode 100644 index 2e303a238..000000000 --- a/astrbot/core/release_constants.py +++ /dev/null @@ -1,11 +0,0 @@ -import re - -NIGHTLY_TAG = "nightly" -GITHUB_REPO_SLUG = "AstrBotDevs/AstrBot" -ASTRBOT_RELEASE_API = "https://api.soulter.top/releases" -GITHUB_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPO_SLUG}/releases" -GITHUB_ARCHIVE_BASE = f"https://github.com/{GITHUB_REPO_SLUG}/archive" -PRERELEASE_TAG_REGEX = re.compile( - rf"[\-_.]?(alpha|beta|rc|dev|{re.escape(NIGHTLY_TAG)}|pre|preview)[\-_.]?\d*$", - re.IGNORECASE, -) diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index b95e345b1..df2cfb82c 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -1,4 +1,3 @@ -import asyncio import os import sys import time @@ -7,47 +6,10 @@ import psutil from astrbot.core import logger from astrbot.core.config.default import VERSION -from astrbot.core.release_constants import ( - ASTRBOT_RELEASE_API, - GITHUB_ARCHIVE_BASE, - GITHUB_RELEASE_API, - NIGHTLY_TAG, - PRERELEASE_TAG_REGEX, -) from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.utils.io import download_file -from .zip_updator import ( - FetchReleaseError, - ReleaseInfo, - RepoZipUpdator, -) - -ASYNC_TIMEOUT_ERROR = asyncio.TimeoutError - - -class AstrBotUpdateError(RuntimeError): - """Domain error for update-related failures.""" - - -class InvalidTargetError(AstrBotUpdateError): - """Raised when update target parameters are invalid.""" - - -class UpToDateError(AstrBotUpdateError): - """Raised when current version is already up to date.""" - - -class NoReleaseError(AstrBotUpdateError): - """Raised when no eligible release is available.""" - - -class UpdateFileNotFoundError(AstrBotUpdateError): - """Raised when update file for a requested version is not found.""" - - -class InvalidEnvironmentError(AstrBotUpdateError): - """Raised when update is called in unsupported runtime mode.""" +from .zip_updator import ReleaseInfo, RepoZipUpdator class AstrBotUpdator(RepoZipUpdator): @@ -59,10 +21,7 @@ class AstrBotUpdator(RepoZipUpdator): def __init__(self, repo_mirror: str = "") -> None: super().__init__(repo_mirror) self.MAIN_PATH = get_astrbot_path() - self.ASTRBOT_RELEASE_API = ASTRBOT_RELEASE_API - self.GITHUB_RELEASE_API = GITHUB_RELEASE_API - self.GITHUB_ARCHIVE_BASE = GITHUB_ARCHIVE_BASE - self.NIGHTLY_TAG = NIGHTLY_TAG + self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases" def terminate_child_processes(self) -> None: """终止当前进程的所有子进程 @@ -182,108 +141,35 @@ class AstrBotUpdator(RepoZipUpdator): consider_prerelease, ) - async def get_releases(self) -> list[dict]: + async def get_releases(self) -> list: return await self.fetch_release_info(self.ASTRBOT_RELEASE_API) - async def _fetch_nightly_release(self) -> dict | None: - nightly_release_url = f"{self.GITHUB_RELEASE_API}/tags/{self.NIGHTLY_TAG}" - try: - nightly_releases = await self.fetch_release_info(nightly_release_url) - except ( - FetchReleaseError, - TimeoutError, - ASYNC_TIMEOUT_ERROR, - OSError, - ) as e: - logger.warning( - "获取 nightly 发布信息失败,跳过 nightly。" - f"url={nightly_release_url}, error_type={type(e).__name__}, detail={e}", - ) - return None - - if not nightly_releases: - return None - return nightly_releases[0] - - async def get_releases_with_nightly(self) -> list[dict]: - releases = await self.get_releases() - nightly_release = await self._fetch_nightly_release() - if nightly_release is None: - return releases - - if all(item.get("tag_name") != self.NIGHTLY_TAG for item in releases): - releases.insert(0, nightly_release) - return releases - - async def _resolve_nightly_target(self) -> tuple[str, str]: - fallback = ( - self.NIGHTLY_TAG, - f"{self.GITHUB_ARCHIVE_BASE}/refs/tags/{self.NIGHTLY_TAG}.zip", - ) - nightly_release = await self._fetch_nightly_release() - if nightly_release is None: - logger.warning("nightly 发布信息不可用,使用归档地址。") - return fallback - - zip_url = nightly_release.get("zipball_url", fallback[1]) - return self.NIGHTLY_TAG, zip_url - - async def _resolve_update_target( - self, - latest: bool, - version: str | None, - ) -> tuple[str, str]: - version_str = str(version).strip() if version is not None else "" - - if latest and version_str: - raise InvalidTargetError( - "latest=True 时不能同时指定 version,请将 latest 设为 False。", - ) - - if latest: - releases = await self.get_releases() - latest_release = next( - ( - item - for item in releases - if (tag := item.get("tag_name", "")) - and not PRERELEASE_TAG_REGEX.search(tag) - ), - None, - ) - if latest_release is None: - raise NoReleaseError("未找到可用的发布版本。") - latest_version = latest_release["tag_name"] - if self.compare_version(VERSION, latest_version) >= 0: - raise UpToDateError("当前已经是最新版本。") - return latest_version, latest_release["zipball_url"] - - if not version_str: - raise InvalidTargetError("未指定有效的更新目标。") - - if version_str.lower() == self.NIGHTLY_TAG: - return await self._resolve_nightly_target() - - if version_str.startswith("v"): - releases = await self.get_releases() - for data in releases: - if data.get("tag_name") == version_str: - return version_str, data["zipball_url"] - raise UpdateFileNotFoundError(f"未找到版本号为 {version_str} 的更新文件。") - - if len(version_str) != 40: - raise InvalidTargetError("commit hash 长度不正确,应为 40") - return version_str, f"{self.GITHUB_ARCHIVE_BASE}/{version_str}.zip" - async def update(self, reboot=False, latest=True, version=None, proxy="") -> None: + update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest) + file_url = None + if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"): - raise InvalidEnvironmentError( - "Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot.", + raise Exception( + "Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot." ) # 避免版本管理混乱 - target_version, file_url = await self._resolve_update_target(latest, version) - - logger.info(f"准备更新至 AstrBot Core: {target_version}") + if latest: + latest_version = update_data[0]["tag_name"] + if self.compare_version(VERSION, latest_version) >= 0: + raise Exception("当前已经是最新版本。") + file_url = update_data[0]["zipball_url"] + elif str(version).startswith("v"): + # 更新到指定版本 + for data in update_data: + if data["tag_name"] == version: + file_url = data["zipball_url"] + if not file_url: + raise Exception(f"未找到版本号为 {version} 的更新文件。") + else: + if len(str(version)) != 40: + raise Exception("commit hash 长度不正确,应为 40") + file_url = f"https://github.com/AstrBotDevs/AstrBot/archive/{version}.zip" + logger.info(f"准备更新至指定版本的 AstrBot Core: {version}") if proxy: proxy = proxy.removesuffix("/") diff --git a/astrbot/core/zip_updator.py b/astrbot/core/zip_updator.py index 8c8d55290..6cea6b38d 100644 --- a/astrbot/core/zip_updator.py +++ b/astrbot/core/zip_updator.py @@ -3,19 +3,15 @@ import re import shutil import ssl import zipfile -from json import JSONDecodeError from typing import NoReturn import aiohttp import certifi from astrbot.core import logger -from astrbot.core.release_constants import PRERELEASE_TAG_REGEX from astrbot.core.utils.io import download_file, on_error from astrbot.core.utils.version_comparator import VersionComparator -# Keep this rule aligned with dashboard/src/layouts/full/vertical-header/VerticalHeader.vue. - class ReleaseInfo: version: str @@ -36,112 +32,12 @@ class ReleaseInfo: return f"\n{self.body}\n\n版本: {self.version} | 发布于: {self.published_at}" -class FetchReleaseError(Exception): - """Expected errors while fetching release metadata from remote services.""" - - def __init__( - self, - message: str, - *, - url: str | None = None, - status_code: int | None = None, - detail: str | None = None, - ) -> None: - super().__init__(message) - self.message = message - self.url = url - self.status_code = status_code - self.detail = detail - - def __str__(self) -> str: - context_parts = [] - if self.url: - context_parts.append(f"url={self.url}") - if self.status_code is not None: - context_parts.append(f"status_code={self.status_code}") - if self.detail: - context_parts.append(f"detail={self.detail}") - if not context_parts: - return self.message - return f"{self.message} ({', '.join(context_parts)})" - - class RepoZipUpdator: def __init__(self, repo_mirror: str = "") -> None: self.repo_mirror = repo_mirror self.rm_on_error = on_error - @staticmethod - def _normalize_release_payload(result: object, url: str) -> list: - if isinstance(result, dict): - releases = [result] - elif isinstance(result, list): - releases = result - else: - logger.error( - f"版本信息格式异常,期望列表或字典,实际为: {type(result).__name__}, url: {url}", - ) - raise FetchReleaseError( - "版本信息格式异常", - url=url, - detail=f"top_level_type={type(result).__name__}", - ) - - if not releases: - return [] - - required_fields = ( - "name", - "published_at", - "body", - "tag_name", - "zipball_url", - ) - normalized = [] - invalid_entry_count = 0 - for idx, release in enumerate(releases): - if not isinstance(release, dict): - logger.warning( - f"版本信息第 {idx} 项格式异常,期望字典,实际为: {type(release).__name__}, url: {url}", - ) - invalid_entry_count += 1 - continue - - missing_fields = [ - field for field in required_fields if field not in release - ] - if missing_fields: - logger.warning( - f"版本信息第 {idx} 项缺少字段: {missing_fields}, url: {url}", - ) - invalid_entry_count += 1 - continue - - normalized.append( - { - "version": release["name"] or release["tag_name"], - "published_at": release["published_at"], - "body": release["body"], - "tag_name": release["tag_name"], - "zipball_url": release["zipball_url"], - }, - ) - - if invalid_entry_count: - logger.warning( - f"版本信息存在 {invalid_entry_count} 条无效数据,已跳过,url: {url}", - ) - - if not normalized: - raise FetchReleaseError( - "版本信息全部无效", - url=url, - detail=f"invalid_entries={invalid_entry_count}, total_entries={len(releases)}", - ) - - return normalized - - async def fetch_release_info(self, url: str) -> list: + async def fetch_release_info(self, url: str, latest: bool = True) -> list: """请求版本信息。 返回一个列表,每个元素是一个字典,包含版本号、发布时间、更新内容、commit hash等信息。 """ @@ -165,31 +61,46 @@ class RepoZipUpdator: logger.error( f"请求 {url} 失败,状态码: {response.status}, 内容: {text}", ) - raise FetchReleaseError( - "请求失败", - url=url, - status_code=response.status, - detail=text[:500], - ) + raise Exception(f"请求失败,状态码: {response.status}") result = await response.json() - except FetchReleaseError: - raise - except ( - TimeoutError, - aiohttp.ClientError, - JSONDecodeError, - ) as e: - logger.error( - "解析版本信息时发生异常。" - f"url={url}, error_type={type(e).__name__}, detail={e}", - ) - raise FetchReleaseError( - "解析版本信息失败", - url=url, - detail=f"{type(e).__name__}: {e}", - ) from e + if not result: + return [] + # if latest: + # ret = self.github_api_release_parser([result[0]]) + # else: + # ret = self.github_api_release_parser(result) + ret = [] + for release in result: + ret.append( + { + "version": release["name"], + "published_at": release["published_at"], + "body": release["body"], + "tag_name": release["tag_name"], + "zipball_url": release["zipball_url"], + }, + ) + except Exception as e: + logger.error(f"解析版本信息时发生异常: {e}") + raise Exception("解析版本信息失败") + return ret - return self._normalize_release_payload(result, url) + def github_api_release_parser(self, releases: list) -> list: + """解析 GitHub API 返回的 releases 信息。 + 返回一个列表,每个元素是一个字典,包含版本号、发布时间、更新内容、commit hash等信息。 + """ + ret = [] + for release in releases: + ret.append( + { + "version": release["name"], + "published_at": release["published_at"], + "body": release["body"], + "tag_name": release["tag_name"], + "zipball_url": release["zipball_url"], + }, + ) + return ret def unzip(self) -> NoReturn: raise NotImplementedError @@ -208,33 +119,25 @@ class RepoZipUpdator: consider_prerelease: bool = True, ) -> ReleaseInfo | None: update_data = await self.fetch_release_info(url) - if not update_data: - return None - tag_name = "" sel_release_data = None if consider_prerelease: tag_name = update_data[0]["tag_name"] sel_release_data = update_data[0] else: for data in update_data: - # 跳过带有 alpha、beta、nightly 等预发布标签的版本 - if PRERELEASE_TAG_REGEX.search(data["tag_name"]): + # 跳过带有 alpha、beta 等预发布标签的版本 + if re.search( + r"[\-_.]?(alpha|beta|rc|dev)[\-_.]?\d*$", + data["tag_name"], + re.IGNORECASE, + ): continue tag_name = data["tag_name"] sel_release_data = data break - if sel_release_data is None: - if not consider_prerelease: - logger.info( - "当前仅有预发布版本,consider_prerelease=False,跳过更新检查。" - ) - else: - logger.error("未找到合适的发布版本") - return None - - if not tag_name: + if not sel_release_data or not tag_name: logger.error("未找到合适的发布版本") return None diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index 90d8afeb8..b0520c315 100644 --- a/astrbot/dashboard/routes/update.py +++ b/astrbot/dashboard/routes/update.py @@ -79,7 +79,7 @@ class UpdateRoute(Route): async def get_releases(self): try: - ret = await self.astrbot_updator.get_releases_with_nightly() + ret = await self.astrbot_updator.get_releases() return Response().ok(ret).__dict__ except Exception as e: logger.error(f"/api/update/releases: {traceback.format_exc()}") diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index 0c282608d..613771860 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -208,9 +208,12 @@ function handleUpdateClick() { updateStatusDialog.value = true; } -// Keep this rule aligned with astrbot/core/zip_updator.py. -const PRE_RELEASE_TAG_REGEX = /[\-_.]?(alpha|beta|rc|dev|nightly|pre|preview)[\-_.]?\d*$/i; -const isPreRelease = (version: string) => PRE_RELEASE_TAG_REGEX.test(version); +// 检测是否为预发布版本 +const isPreRelease = (version: string) => { + const preReleaseKeywords = ['alpha', 'beta', 'rc', 'pre', 'preview', 'dev']; + const lowerVersion = version.toLowerCase(); + return preReleaseKeywords.some(keyword => lowerVersion.includes(keyword)); +}; // 账户修改 function accountEdit() { diff --git a/scripts/__init__.py b/scripts/__init__.py deleted file mode 100644 index 29db0ff52..000000000 --- a/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Scripts package marker. diff --git a/scripts/release/__init__.py b/scripts/release/__init__.py deleted file mode 100644 index cb3156b55..000000000 --- a/scripts/release/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Release scripts package marker. diff --git a/scripts/release/generate_nightly_release_notes.py b/scripts/release/generate_nightly_release_notes.py deleted file mode 100644 index e15c9ab7c..000000000 --- a/scripts/release/generate_nightly_release_notes.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import argparse -import subprocess -from collections import defaultdict -from pathlib import Path - -if __package__: - from .release_constants_loader import load_release_constants -else: - import sys - - sys.path.insert(0, str(Path(__file__).resolve().parents[2])) - from scripts.release.release_constants_loader import load_release_constants - -_constants = load_release_constants("NIGHTLY_TAG", "GITHUB_REPO_SLUG") -NIGHTLY_TAG = _constants["NIGHTLY_TAG"] -DEFAULT_REPO_SLUG = _constants["GITHUB_REPO_SLUG"] - - -def _run_git(*args: str) -> str: - try: - result = subprocess.run( - ["git", *args], - capture_output=True, - text=True, - check=True, - ) - except subprocess.CalledProcessError as e: - stderr = (e.stderr or "").strip() - stdout = (e.stdout or "").strip() - detail = stderr or stdout or "no output" - raise RuntimeError(f"git {' '.join(args)} failed: {detail}") from e - return result.stdout.strip() - - -def _is_valid_ref(ref: str) -> bool: - if not ref: - return False - result = subprocess.run( - ["git", "rev-parse", "--verify", "--quiet", ref], - capture_output=True, - text=True, - check=False, - ) - return result.returncode == 0 - - -def _classify(subject: str) -> str: - lowered = subject.lower().strip() - if lowered.startswith("feat") or "新增" in subject: - return "新增" - if lowered.startswith("fix") or "修复" in subject: - return "修复" - if ( - lowered.startswith("perf") - or lowered.startswith("refactor") - or "优化" in subject - ): - return "优化" - return "其他" - - -def _write_fallback(output_path: Path) -> None: - short_sha = _run_git("rev-parse", "--short=8", "HEAD") - output_path.write_text( - f"## What's Changed\n\n- {NIGHTLY_TAG.capitalize()} build from `{short_sha}`\n", - encoding="utf-8", - ) - - -def generate_notes(base_tag: str, repo: str, output_path: Path) -> None: - output_path.parent.mkdir(parents=True, exist_ok=True) - if not _is_valid_ref(base_tag): - _write_fallback(output_path) - return - - log_output = _run_git( - "log", - "--no-merges", - "--pretty=format:%h%x1f%s", - f"{base_tag}..HEAD", - ) - sections: dict[str, list[str]] = defaultdict(list) - for line in log_output.splitlines(): - if not line.strip() or "\x1f" not in line: - continue - short_sha, subject = line.split("\x1f", 1) - commit_link = f"https://github.com/{repo}/commit/{short_sha}" - sections[_classify(subject)].append( - f"- {subject} ([`{short_sha}`]({commit_link}))" - ) - - nightly_commit = _run_git("rev-parse", "--short=8", "HEAD") - with output_path.open("w", encoding="utf-8") as file: - file.write("## What's Changed\n\n") - file.write(f"- Baseline tag: `{base_tag}`\n") - file.write(f"- {NIGHTLY_TAG.capitalize()} commit: `{nightly_commit}`\n") - - if not any(sections.values()): - file.write(f"- No changes since `{base_tag}`\n\n") - return - - file.write("\n") - for title in ("新增", "修复", "优化", "其他"): - items = sections.get(title, []) - if not items: - continue - file.write(f"### {title}\n") - file.write("\n".join(items)) - file.write("\n\n") - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Generate release notes for nightly release.", - ) - parser.add_argument("--base-tag", default="", help="Baseline stable tag.") - parser.add_argument( - "--repo", - default=DEFAULT_REPO_SLUG, - help="GitHub repository slug.", - ) - parser.add_argument("--output", required=True, help="Output markdown path.") - args = parser.parse_args() - - try: - generate_notes(args.base_tag.strip(), args.repo.strip(), Path(args.output)) - except Exception as e: - raise SystemExit(f"Failed to generate nightly release notes: {e}") from e - - -if __name__ == "__main__": - main() diff --git a/scripts/release/print_release_constant.py b/scripts/release/print_release_constant.py deleted file mode 100644 index fb5572127..000000000 --- a/scripts/release/print_release_constant.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import argparse - -if __package__: - from .release_constants_loader import load_release_constant -else: - import sys - from pathlib import Path - - sys.path.insert(0, str(Path(__file__).resolve().parents[2])) - from scripts.release.release_constants_loader import load_release_constant - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Print a release constant from astrbot/core/release_constants.py.", - ) - parser.add_argument("name", help="Constant name, e.g. NIGHTLY_TAG.") - args = parser.parse_args() - print(load_release_constant(args.name)) - - -if __name__ == "__main__": - main() diff --git a/scripts/release/release_constants_loader.py b/scripts/release/release_constants_loader.py deleted file mode 100644 index bfc65ee62..000000000 --- a/scripts/release/release_constants_loader.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import importlib.machinery -import importlib.util -from functools import lru_cache -from pathlib import Path -from types import ModuleType - - -def _constants_file() -> Path: - return ( - Path(__file__).resolve().parents[2] - / "astrbot" - / "core" - / "release_constants.py" - ) - - -@lru_cache(maxsize=1) -def _release_constants_module() -> ModuleType: - constants_path = _constants_file() - module_name = "astrbot_core_release_constants_loader" - loader = importlib.machinery.SourceFileLoader(module_name, str(constants_path)) - spec = importlib.util.spec_from_loader(module_name, loader) - if spec is None: - raise RuntimeError(f"Failed to load spec for {constants_path}") - - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def load_release_constants(*names: str) -> dict[str, str]: - module = _release_constants_module() - - values: dict[str, str] = {} - missing: list[str] = [] - - for name in names: - value = getattr(module, name, None) - if not isinstance(value, str): - missing.append(name) - continue - value = value.strip() - if not value: - missing.append(name) - continue - values[name] = value - - if missing: - missing_str = ", ".join(missing) - raise RuntimeError( - f"Failed to parse {missing_str} from astrbot/core/release_constants.py", - ) - - return values - - -def load_release_constant(name: str) -> str: - return load_release_constants(name)[name] diff --git a/tests/unit/test_prerelease_rule_sync.py b/tests/unit/test_prerelease_rule_sync.py deleted file mode 100644 index 481b396e3..000000000 --- a/tests/unit/test_prerelease_rule_sync.py +++ /dev/null @@ -1,49 +0,0 @@ -import re -from pathlib import Path - -from astrbot.core.release_constants import PRERELEASE_TAG_REGEX - - -def test_prerelease_rule_is_synced_with_dashboard(): - repo_root = Path(__file__).resolve().parents[2] - vue_path = ( - repo_root / "dashboard/src/layouts/full/vertical-header/VerticalHeader.vue" - ) - content = vue_path.read_text(encoding="utf-8") - - match = re.search( - r"const\s+PRE_RELEASE_TAG_REGEX\s*=\s*/(.+?)/([a-z]*)\s*;?", - content, - ) - assert match is not None - vue_pattern, vue_flags = match.groups() - assert vue_pattern == PRERELEASE_TAG_REGEX.pattern - assert ("i" in vue_flags) == bool(PRERELEASE_TAG_REGEX.flags & re.IGNORECASE) - - -def test_prerelease_rule_matches_expected_examples(): - prerelease_tags = ( - "v1.2.3-alpha.1", - "v1.2.3-beta", - "v1.2.3-rc1", - "v1.2.3-dev", - "v1.2.3-nightly", - "v1.2.3-pre", - "v1.2.3-preview", - "v1.2.3-ALPHA", - "v1.2.3-Beta.1", - "v1.2.3-NIGHTLY", - ) - stable_tags = ( - "v1.2.3", - "v1.2.3+build.1", - "v1.2.3-release", - "v1.2.3-alphaish", - "v1.2.3-previewed", - ) - - for tag in prerelease_tags: - assert PRERELEASE_TAG_REGEX.search(tag) is not None - - for tag in stable_tags: - assert PRERELEASE_TAG_REGEX.search(tag) is None diff --git a/tests/unit/test_updator.py b/tests/unit/test_updator.py deleted file mode 100644 index 4779f15aa..000000000 --- a/tests/unit/test_updator.py +++ /dev/null @@ -1,437 +0,0 @@ -import pytest - -from astrbot.core.updator import AstrBotUpdateError, AstrBotUpdator -from astrbot.core.zip_updator import FetchReleaseError, RepoZipUpdator - - -def test_normalize_release_payload_skips_invalid_entry_when_other_entries_are_valid(): - updator = RepoZipUpdator() - payload = [ - { - "name": "v1.0.0", - "published_at": "2026-03-01T00:00:00Z", - "tag_name": "v1.0.0", - "zipball_url": "https://example.com/v1.0.0.zip", - }, - { - "name": "v1.0.1", - "published_at": "2026-03-02T00:00:00Z", - "body": "release body", - "tag_name": "v1.0.1", - "zipball_url": "https://example.com/v1.0.1.zip", - }, - ] - - releases = updator._normalize_release_payload( - payload, - "https://example.invalid/releases", - ) - - assert len(releases) == 1 - assert releases[0]["tag_name"] == "v1.0.1" - - -def test_normalize_release_payload_raises_on_invalid_item_type(): - updator = RepoZipUpdator() - - with pytest.raises(FetchReleaseError, match="版本信息全部无效"): - updator._normalize_release_payload( - ["invalid-release-item"], - "https://example.invalid/releases", - ) - - -def test_normalize_release_payload_accepts_valid_payload(): - updator = RepoZipUpdator() - payload = { - "name": "v1.0.0", - "published_at": "2026-03-01T00:00:00Z", - "body": "release body", - "tag_name": "v1.0.0", - "zipball_url": "https://example.com/v1.0.0.zip", - } - - releases = updator._normalize_release_payload( - payload, - "https://example.invalid/releases", - ) - - assert len(releases) == 1 - assert releases[0]["tag_name"] == "v1.0.0" - assert releases[0]["version"] == "v1.0.0" - - -def test_normalize_release_payload_raises_when_all_entries_invalid(): - updator = RepoZipUpdator() - malformed_payload = [ - { - "name": "v1.0.0", - "published_at": "2026-03-01T00:00:00Z", - "tag_name": "v1.0.0", - "zipball_url": "https://example.com/v1.0.0.zip", - } - ] - - with pytest.raises(FetchReleaseError, match="版本信息全部无效"): - updator._normalize_release_payload( - malformed_payload, - "https://example.invalid/releases", - ) - - -def test_normalize_release_payload_error_contains_context(): - updator = RepoZipUpdator() - malformed_payload = ["invalid-release-item"] - - with pytest.raises(FetchReleaseError) as exc_info: - updator._normalize_release_payload( - malformed_payload, - "https://example.invalid/releases", - ) - - error = exc_info.value - assert error.url == "https://example.invalid/releases" - assert error.detail is not None - - -@pytest.mark.asyncio -async def test_update_supports_nightly_tag(monkeypatch, tmp_path): - updator = AstrBotUpdator() - captured: dict[str, str] = {} - - async def mock_download_file(url: str, path: str, *args, **kwargs): - captured["url"] = url - captured["path"] = path - - async def mock_fetch_release_info(url: str): - if url == updator.ASTRBOT_RELEASE_API: - return [] - if url == f"{updator.GITHUB_RELEASE_API}/tags/{updator.NIGHTLY_TAG}": - return [ - { - "version": "nightly", - "published_at": "2026-03-02T00:00:00Z", - "body": "nightly", - "tag_name": "nightly", - "zipball_url": "https://example.com/nightly.zip", - } - ] - raise AssertionError(f"unexpected URL: {url}") - - def mock_unzip_file(zip_path: str, target_dir: str): - captured["zip_path"] = zip_path - captured["target_dir"] = target_dir - - monkeypatch.delenv("ASTRBOT_CLI", raising=False) - monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False) - monkeypatch.setattr("astrbot.core.updator.download_file", mock_download_file) - monkeypatch.setattr(updator, "fetch_release_info", mock_fetch_release_info) - monkeypatch.setattr(updator, "unzip_file", mock_unzip_file) - monkeypatch.setattr(updator, "MAIN_PATH", str(tmp_path)) - - await updator.update(latest=False, version="nightly") - - assert captured["url"] == "https://example.com/nightly.zip" - assert captured["path"] == "temp.zip" - assert captured["zip_path"] == "temp.zip" - assert captured["target_dir"] == str(tmp_path) - - -@pytest.mark.asyncio -async def test_resolve_update_target_nightly_uses_archive_fallback(monkeypatch): - updator = AstrBotUpdator() - updator.GITHUB_ARCHIVE_BASE = "https://github.com/example-org/example-repo/archive" - - async def mock_fetch_release_info(url: str): - if url == f"{updator.GITHUB_RELEASE_API}/tags/{updator.NIGHTLY_TAG}": - raise FetchReleaseError("请求失败") - raise AssertionError(f"unexpected URL: {url}") - - monkeypatch.setattr(updator, "fetch_release_info", mock_fetch_release_info) - - target_version, file_url = await updator._resolve_update_target( - latest=False, - version="nightly", - ) - assert target_version == "nightly" - assert ( - file_url - == "https://github.com/example-org/example-repo/archive/refs/tags/nightly.zip" - ) - - -@pytest.mark.asyncio -async def test_resolve_update_target_commit_uses_archive_base(): - updator = AstrBotUpdator() - updator.GITHUB_ARCHIVE_BASE = "https://github.com/example-org/example-repo/archive" - version_str = "1234567890123456789012345678901234567890" - - target_version, file_url = await updator._resolve_update_target( - latest=False, - version=version_str, - ) - assert target_version == version_str - assert ( - file_url - == "https://github.com/example-org/example-repo/archive/1234567890123456789012345678901234567890.zip" - ) - - -@pytest.mark.asyncio -async def test_get_releases_includes_nightly_tag(monkeypatch): - updator = AstrBotUpdator() - - stable_release = { - "version": "v9.9.9", - "published_at": "2026-03-01T00:00:00Z", - "body": "stable", - "tag_name": "v9.9.9", - "zipball_url": "https://example.com/stable.zip", - } - nightly_release = { - "version": "nightly", - "published_at": "2026-03-02T00:00:00Z", - "body": "nightly", - "tag_name": "nightly", - "zipball_url": "https://example.com/nightly.zip", - } - - async def mock_fetch_release_info(url: str): - if url == updator.ASTRBOT_RELEASE_API: - return [stable_release] - if url == f"{updator.GITHUB_RELEASE_API}/tags/{updator.NIGHTLY_TAG}": - return [nightly_release] - raise AssertionError(f"unexpected URL: {url}") - - monkeypatch.setattr(updator, "fetch_release_info", mock_fetch_release_info) - - releases = await updator.get_releases_with_nightly() - - assert releases[0]["tag_name"] == "nightly" - assert releases[1]["tag_name"] == "v9.9.9" - - -@pytest.mark.asyncio -async def test_get_releases_deduplicates_nightly_when_already_in_stable(monkeypatch): - updator = AstrBotUpdator() - - stable_nightly_release = { - "version": "nightly", - "published_at": "2026-03-01T00:00:00Z", - "body": "stable nightly", - "tag_name": "nightly", - "zipball_url": "https://example.com/stable-nightly.zip", - } - github_nightly_release = { - "version": "nightly", - "published_at": "2026-03-02T00:00:00Z", - "body": "github nightly", - "tag_name": "nightly", - "zipball_url": "https://example.com/github-nightly.zip", - } - - async def mock_fetch_release_info(url: str): - if url == updator.ASTRBOT_RELEASE_API: - return [stable_nightly_release] - if url == f"{updator.GITHUB_RELEASE_API}/tags/{updator.NIGHTLY_TAG}": - return [github_nightly_release] - raise AssertionError(f"unexpected URL: {url}") - - monkeypatch.setattr(updator, "fetch_release_info", mock_fetch_release_info) - - releases = await updator.get_releases_with_nightly() - - nightly_releases = [item for item in releases if item["tag_name"] == "nightly"] - assert len(nightly_releases) == 1 - assert releases[0]["zipball_url"] == "https://example.com/stable-nightly.zip" - - -@pytest.mark.asyncio -async def test_get_releases_returns_stable_only(monkeypatch): - updator = AstrBotUpdator() - stable_release = { - "version": "v9.9.9", - "published_at": "2026-03-01T00:00:00Z", - "body": "stable", - "tag_name": "v9.9.9", - "zipball_url": "https://example.com/stable.zip", - } - - async def mock_fetch_release_info(url: str): - if url == updator.ASTRBOT_RELEASE_API: - return [stable_release] - raise AssertionError(f"unexpected URL: {url}") - - monkeypatch.setattr(updator, "fetch_release_info", mock_fetch_release_info) - - releases = await updator.get_releases() - assert len(releases) == 1 - assert releases[0]["tag_name"] == "v9.9.9" - - -@pytest.mark.asyncio -async def test_resolve_update_target_skips_prerelease_tags_for_latest(monkeypatch): - updator = AstrBotUpdator() - releases = [ - { - "version": "v9.9.9-rc1", - "published_at": "2026-03-02T00:00:00Z", - "body": "rc", - "tag_name": "v9.9.9-rc1", - "zipball_url": "https://example.com/rc.zip", - }, - { - "version": "v9.9.8", - "published_at": "2026-03-01T00:00:00Z", - "body": "stable", - "tag_name": "v9.9.8", - "zipball_url": "https://example.com/stable.zip", - }, - ] - - async def mock_get_releases(): - return releases - - monkeypatch.setattr(updator, "get_releases", mock_get_releases) - monkeypatch.setattr(updator, "compare_version", lambda _current, _target: -1) - - target_version, file_url = await updator._resolve_update_target( - latest=True, - version=None, - ) - assert target_version == "v9.9.8" - assert file_url == "https://example.com/stable.zip" - - -@pytest.mark.asyncio -async def test_resolve_update_target_rejects_version_when_latest_true(): - updator = AstrBotUpdator() - - with pytest.raises( - AstrBotUpdateError, - match="latest=True 时不能同时指定 version,请将 latest 设为 False。", - ): - await updator._resolve_update_target( - latest=True, - version="nightly", - ) - - -@pytest.mark.asyncio -async def test_get_releases_with_nightly_skips_expected_nightly_fetch_error(monkeypatch): - updator = AstrBotUpdator() - stable_release = { - "version": "v9.9.9", - "published_at": "2026-03-01T00:00:00Z", - "body": "stable", - "tag_name": "v9.9.9", - "zipball_url": "https://example.com/stable.zip", - } - - async def mock_fetch_release_info(url: str): - if url == updator.ASTRBOT_RELEASE_API: - return [stable_release] - if url == f"{updator.GITHUB_RELEASE_API}/tags/{updator.NIGHTLY_TAG}": - raise FetchReleaseError("请求失败,状态码: 404") - raise AssertionError(f"unexpected URL: {url}") - - monkeypatch.setattr(updator, "fetch_release_info", mock_fetch_release_info) - - releases = await updator.get_releases_with_nightly() - assert len(releases) == 1 - assert releases[0]["tag_name"] == "v9.9.9" - - -@pytest.mark.asyncio -async def test_get_releases_with_nightly_raises_for_unexpected_nightly_error( - monkeypatch, -): - updator = AstrBotUpdator() - stable_release = { - "version": "v9.9.9", - "published_at": "2026-03-01T00:00:00Z", - "body": "stable", - "tag_name": "v9.9.9", - "zipball_url": "https://example.com/stable.zip", - } - - async def mock_fetch_release_info(url: str): - if url == updator.ASTRBOT_RELEASE_API: - return [stable_release] - if url == f"{updator.GITHUB_RELEASE_API}/tags/{updator.NIGHTLY_TAG}": - raise KeyError("unexpected") - raise AssertionError(f"unexpected URL: {url}") - - monkeypatch.setattr(updator, "fetch_release_info", mock_fetch_release_info) - - with pytest.raises(KeyError): - await updator.get_releases_with_nightly() - - -@pytest.mark.asyncio -async def test_check_update_skips_nightly_when_prerelease_disabled(monkeypatch): - updator = RepoZipUpdator() - - async def mock_fetch_release_info(url: str): - _ = url - return [ - { - "version": "nightly", - "published_at": "2026-03-02T00:00:00Z", - "body": "nightly build", - "tag_name": "nightly", - "zipball_url": "https://example.com/nightly.zip", - }, - { - "version": "v1.2.3", - "published_at": "2026-03-01T00:00:00Z", - "body": "stable release", - "tag_name": "v1.2.3", - "zipball_url": "https://example.com/stable.zip", - }, - ] - - monkeypatch.setattr(updator, "fetch_release_info", mock_fetch_release_info) - - release = await updator.check_update( - "https://example.invalid/releases", - "v1.0.0", - consider_prerelease=False, - ) - - assert release is not None - assert release.version == "v1.2.3" - - -@pytest.mark.asyncio -async def test_check_update_returns_none_when_only_prerelease_and_disabled(monkeypatch): - updator = RepoZipUpdator() - - async def mock_fetch_release_info(url: str): - _ = url - return [ - { - "version": "nightly", - "published_at": "2026-03-02T00:00:00Z", - "body": "nightly build", - "tag_name": "nightly", - "zipball_url": "https://example.com/nightly.zip", - }, - { - "version": "v1.2.3-beta.1", - "published_at": "2026-03-01T00:00:00Z", - "body": "beta build", - "tag_name": "v1.2.3-beta.1", - "zipball_url": "https://example.com/beta.zip", - }, - ] - - monkeypatch.setattr(updator, "fetch_release_info", mock_fetch_release_info) - - release = await updator.check_update( - "https://example.invalid/releases", - "v1.0.0", - consider_prerelease=False, - ) - - assert release is None