This reverts commit 3d1c3946f6.
This commit is contained in:
Soulter
2026-03-05 01:29:36 +08:00
parent 3d1c3946f6
commit 2d27bfb6d0
13 changed files with 144 additions and 1222 deletions
+66 -214
View File
@@ -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
-11
View File
@@ -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,
)
+25 -139
View File
@@ -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("/")
+46 -143
View File
@@ -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
+1 -1
View File
@@ -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()}")
@@ -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() {
-1
View File
@@ -1 +0,0 @@
# Scripts package marker.
-1
View File
@@ -1 +0,0 @@
# Release scripts package marker.
@@ -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()
-27
View File
@@ -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()
@@ -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]
-49
View File
@@ -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
-437
View File
@@ -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