From 3d1c3946f6506777ae38cd1d1ed81a9eef4d1d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <62183434+zouyonghe@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:23:49 +0900 Subject: [PATCH] feat(ci): add nightly prerelease release flow and updater support (#5744) * feat: add nightly prerelease release flow and updater support * feat(ci): auto-generate nightly release notes from latest stable tag * fix(ci): correct nightly release notes heredoc YAML indentation * fix(ci): align nightly notes heredoc terminator * fix(ci): remove heredoc body indentation in nightly notes script * fix: align nightly release metadata and prerelease rules * fix: harden nightly release flow and updater release resolution * fix: improve nightly branch resolution and updater logging * fix: simplify updater target resolution and nightly release assets * fix: avoid inputs lookup on non-dispatch release events * fix: split nightly release fetch and simplify updater flow * refactor: simplify updater target resolvers and nightly error checks * fix: type release fetch errors and streamline updater resolution * refactor: simplify updater target branching and release artifacts * refactor: simplify release fetching and harden nightly git diagnostics * fix: validate release payload shape before parsing * refactor: harden prerelease handling and nightly constants * refactor: derive archive urls and enrich fetch errors * refactor: simplify update target resolution flow * refactor: linearize update target resolution * refactor: validate update target inputs and sync nightly tag source * refactor: simplify updater mode resolution and prerelease tests * refactor: simplify update target resolution flow * fix: avoid package import when resolving nightly tag * refactor: simplify updater resolution and centralize release constants * fix: harden nightly release notes generation in workflow * refactor: streamline update target resolution and errors * refactor: simplify updater target resolution and nightly handling * refactor: simplify updater errors and package release scripts * refactor: centralize release api constants and loader * fix(ci): resolve dispatch fallback tag from stable releases --- .github/workflows/release.yml | 284 +++++++++--- astrbot/core/release_constants.py | 11 + astrbot/core/updator.py | 164 ++++++- astrbot/core/zip_updator.py | 189 ++++++-- astrbot/dashboard/routes/update.py | 2 +- .../full/vertical-header/VerticalHeader.vue | 9 +- scripts/__init__.py | 1 + scripts/release/__init__.py | 1 + .../release/generate_nightly_release_notes.py | 136 ++++++ scripts/release/print_release_constant.py | 27 ++ scripts/release/release_constants_loader.py | 60 +++ tests/unit/test_prerelease_rule_sync.py | 49 ++ tests/unit/test_updator.py | 437 ++++++++++++++++++ 13 files changed, 1224 insertions(+), 146 deletions(-) create mode 100644 astrbot/core/release_constants.py create mode 100644 scripts/__init__.py create mode 100644 scripts/release/__init__.py create mode 100644 scripts/release/generate_nightly_release_notes.py create mode 100644 scripts/release/print_release_constant.py create mode 100644 scripts/release/release_constants_loader.py create mode 100644 tests/unit/test_prerelease_rule_sync.py create mode 100644 tests/unit/test_updator.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4950b7a4b..36acfafb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: - "v*" + schedule: + # Daily at 00:00 UTC + - cron: "0 0 * * *" workflow_dispatch: inputs: ref: @@ -18,15 +21,133 @@ permissions: contents: write jobs: - verify-core: - name: Verify Core Quality Gate + 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 - ref: ${{ inputs.ref || github.ref }} + + - 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 }} - name: Set up Python uses: actions/setup-python@v6 @@ -44,6 +165,8 @@ 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 }} @@ -53,24 +176,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - 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" + ref: ${{ needs.resolve-release-context.outputs.checkout_ref }} - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -89,23 +195,23 @@ jobs: run: | pnpm --dir dashboard install --frozen-lockfile pnpm --dir dashboard run build - echo "${{ steps.tag.outputs.tag }}" > dashboard/dist/assets/version + echo "${{ needs.resolve-release-context.outputs.tag }}" > dashboard/dist/assets/version cd dashboard - zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist + zip -r "AstrBot-${{ needs.resolve-release-context.outputs.tag }}-dashboard.zip" dist - name: Upload dashboard artifact uses: actions/upload-artifact@v7 with: - name: Dashboard-${{ steps.tag.outputs.tag }} + name: Dashboard-${{ needs.resolve-release-context.outputs.tag }} if-no-files-found: error - path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip + path: dashboard/AstrBot-${{ needs.resolve-release-context.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 != '' }} + 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 }} env: R2_BUCKET_NAME: "astrbot" R2_OBJECT_NAME: "astrbot-webui-latest.zip" - VERSION_TAG: ${{ steps.tag.outputs.tag }} + VERSION_TAG: ${{ needs.resolve-release-context.outputs.tag }} shell: bash run: | sudo apt-get update @@ -129,7 +235,11 @@ 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: @@ -137,29 +247,51 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.resolve-release-context.outputs.checkout_ref }} - - name: Resolve tag - id: tag + - name: Resolve release title + id: release-meta shell: bash run: | - if [ "${{ github.event_name }}" = "push" ]; then - tag="${GITHUB_REF_NAME}" - elif [ -n "${{ inputs.tag }}" ]; then - tag="${{ inputs.tag }}" + 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}" else - tag="$(git describe --tags --abbrev=0)" + base_version="$tag" + title="$tag" fi - if [ -z "$tag" ]; then - echo "Failed to resolve tag." >&2 + 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 exit 1 fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" + git tag -f "$nightly_tag" "${current_sha}" + git push --force origin "refs/tags/${nightly_tag}" - name: Download dashboard artifact uses: actions/download-artifact@v8 with: - name: Dashboard-${{ steps.tag.outputs.tag }} + name: Dashboard-${{ needs.resolve-release-context.outputs.tag }} path: release-assets @@ -167,10 +299,32 @@ jobs: id: notes shell: bash run: | - note_file="changelogs/${{ steps.tag.outputs.tag }}.md" - if [ ! -f "$note_file" ]; then + tag="${{ needs.resolve-release-context.outputs.tag }}" + nightly_tag="${{ needs.resolve-release-context.outputs.nightly_tag }}" + if [ "$tag" = "$nightly_tag" ]; then note_file="$(mktemp)" - echo "Release ${{ steps.tag.outputs.tag }}" > "$note_file" + 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 fi echo "file=$note_file" >> "$GITHUB_OUTPUT" @@ -179,9 +333,18 @@ jobs: GH_TOKEN: ${{ github.token }} shell: bash run: | - tag="${{ steps.tag.outputs.tag }}" + 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 if ! gh release view "$tag" >/dev/null 2>&1; then - gh release create "$tag" --title "$tag" --notes-file "${{ steps.notes.outputs.file }}" + 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 fi - name: Remove stale assets from release @@ -189,10 +352,10 @@ jobs: GH_TOKEN: ${{ github.token }} shell: bash run: | - tag="${{ steps.tag.outputs.tag }}" + tag="${{ needs.resolve-release-context.outputs.tag }}" while IFS= read -r asset; do case "$asset" in - *.AppImage|*.dmg|*.zip|*.exe|*.blockmap) + AstrBot-*-dashboard.zip) gh release delete-asset "$tag" "$asset" -y || true ;; esac @@ -203,49 +366,34 @@ jobs: GH_TOKEN: ${{ github.token }} shell: bash run: | - tag="${{ steps.tag.outputs.tag }}" - gh release upload "$tag" release-assets/* --clobber + tag="${{ needs.resolve-release-context.outputs.tag }}" + gh release upload "$tag" "release-assets/AstrBot-${tag}-dashboard.zip" --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: ${{ 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" + ref: ${{ needs.resolve-release-context.outputs.checkout_ref }} - name: Download dashboard artifact uses: actions/download-artifact@v8 with: - name: Dashboard-${{ steps.tag.outputs.tag }} + name: Dashboard-${{ needs.resolve-release-context.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-${{ steps.tag.outputs.tag }}-dashboard.zip" -d dashboard-artifact/unpacked + unzip -q "dashboard-artifact/AstrBot-${{ needs.resolve-release-context.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 new file mode 100644 index 000000000..2e303a238 --- /dev/null +++ b/astrbot/core/release_constants.py @@ -0,0 +1,11 @@ +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 df2cfb82c..b95e345b1 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -1,3 +1,4 @@ +import asyncio import os import sys import time @@ -6,10 +7,47 @@ 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 ReleaseInfo, RepoZipUpdator +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.""" class AstrBotUpdator(RepoZipUpdator): @@ -21,7 +59,10 @@ class AstrBotUpdator(RepoZipUpdator): def __init__(self, repo_mirror: str = "") -> None: super().__init__(repo_mirror) self.MAIN_PATH = get_astrbot_path() - self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases" + 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 def terminate_child_processes(self) -> None: """终止当前进程的所有子进程 @@ -141,35 +182,108 @@ class AstrBotUpdator(RepoZipUpdator): consider_prerelease, ) - async def get_releases(self) -> list: + async def get_releases(self) -> list[dict]: return await self.fetch_release_info(self.ASTRBOT_RELEASE_API) - 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 + 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 os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"): - raise Exception( - "Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot." - ) # 避免版本管理混乱 + 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: - latest_version = update_data[0]["tag_name"] + 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 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}") + 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: + 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.", + ) # 避免版本管理混乱 + + target_version, file_url = await self._resolve_update_target(latest, version) + + logger.info(f"准备更新至 AstrBot Core: {target_version}") if proxy: proxy = proxy.removesuffix("/") diff --git a/astrbot/core/zip_updator.py b/astrbot/core/zip_updator.py index 6cea6b38d..8c8d55290 100644 --- a/astrbot/core/zip_updator.py +++ b/astrbot/core/zip_updator.py @@ -3,15 +3,19 @@ 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 @@ -32,12 +36,112 @@ 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 - async def fetch_release_info(self, url: str, latest: bool = True) -> list: + @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: """请求版本信息。 返回一个列表,每个元素是一个字典,包含版本号、发布时间、更新内容、commit hash等信息。 """ @@ -61,46 +165,31 @@ class RepoZipUpdator: logger.error( f"请求 {url} 失败,状态码: {response.status}, 内容: {text}", ) - raise Exception(f"请求失败,状态码: {response.status}") + raise FetchReleaseError( + "请求失败", + url=url, + status_code=response.status, + detail=text[:500], + ) result = await response.json() - 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 - - 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"], - }, + except FetchReleaseError: + raise + except ( + TimeoutError, + aiohttp.ClientError, + JSONDecodeError, + ) as e: + logger.error( + "解析版本信息时发生异常。" + f"url={url}, error_type={type(e).__name__}, detail={e}", ) - return ret + raise FetchReleaseError( + "解析版本信息失败", + url=url, + detail=f"{type(e).__name__}: {e}", + ) from e + + return self._normalize_release_payload(result, url) def unzip(self) -> NoReturn: raise NotImplementedError @@ -119,25 +208,33 @@ 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 等预发布标签的版本 - if re.search( - r"[\-_.]?(alpha|beta|rc|dev)[\-_.]?\d*$", - data["tag_name"], - re.IGNORECASE, - ): + # 跳过带有 alpha、beta、nightly 等预发布标签的版本 + if PRERELEASE_TAG_REGEX.search(data["tag_name"]): continue tag_name = data["tag_name"] sel_release_data = data break - if not sel_release_data or not tag_name: + if sel_release_data is None: + if not consider_prerelease: + logger.info( + "当前仅有预发布版本,consider_prerelease=False,跳过更新检查。" + ) + else: + logger.error("未找到合适的发布版本") + return None + + if not tag_name: logger.error("未找到合适的发布版本") return None diff --git a/astrbot/dashboard/routes/update.py b/astrbot/dashboard/routes/update.py index b0520c315..90d8afeb8 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() + ret = await self.astrbot_updator.get_releases_with_nightly() 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 613771860..0c282608d 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -208,12 +208,9 @@ function handleUpdateClick() { updateStatusDialog.value = true; } -// 检测是否为预发布版本 -const isPreRelease = (version: string) => { - const preReleaseKeywords = ['alpha', 'beta', 'rc', 'pre', 'preview', 'dev']; - const lowerVersion = version.toLowerCase(); - return preReleaseKeywords.some(keyword => lowerVersion.includes(keyword)); -}; +// 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); // 账户修改 function accountEdit() { diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 000000000..29db0ff52 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +# Scripts package marker. diff --git a/scripts/release/__init__.py b/scripts/release/__init__.py new file mode 100644 index 000000000..cb3156b55 --- /dev/null +++ b/scripts/release/__init__.py @@ -0,0 +1 @@ +# Release scripts package marker. diff --git a/scripts/release/generate_nightly_release_notes.py b/scripts/release/generate_nightly_release_notes.py new file mode 100644 index 000000000..e15c9ab7c --- /dev/null +++ b/scripts/release/generate_nightly_release_notes.py @@ -0,0 +1,136 @@ +#!/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 new file mode 100644 index 000000000..fb5572127 --- /dev/null +++ b/scripts/release/print_release_constant.py @@ -0,0 +1,27 @@ +#!/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 new file mode 100644 index 000000000..bfc65ee62 --- /dev/null +++ b/scripts/release/release_constants_loader.py @@ -0,0 +1,60 @@ +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 new file mode 100644 index 000000000..481b396e3 --- /dev/null +++ b/tests/unit/test_prerelease_rule_sync.py @@ -0,0 +1,49 @@ +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 new file mode 100644 index 000000000..4779f15aa --- /dev/null +++ b/tests/unit/test_updator.py @@ -0,0 +1,437 @@ +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