diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ba744d46..44359da05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,6 +71,15 @@ jobs: echo "${{ steps.tag.outputs.tag }}" > dist/assets/version zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist + - name: Build core package + shell: bash + run: | + git archive \ + --format=zip \ + --prefix="AstrBot-${{ steps.tag.outputs.tag }}/" \ + --output="AstrBot-${{ steps.tag.outputs.tag }}-core.zip" \ + HEAD + - name: Upload dashboard artifact uses: actions/upload-artifact@v7 with: @@ -78,11 +87,12 @@ jobs: if-no-files-found: error path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip - - name: Upload dashboard package to Cloudflare R2 + - name: Upload release packages to Cloudflare R2 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" + DASHBOARD_LATEST_OBJECT_NAME: "astrbot-webui-latest.zip" + CORE_LATEST_OBJECT_NAME: "astrbot-core-latest.zip" VERSION_TAG: ${{ steps.tag.outputs.tag }} shell: bash run: | @@ -98,11 +108,18 @@ jobs: endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com EOF - cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}" - rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress + cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" + rclone copy "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip" rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress + cp "AstrBot-${VERSION_TAG}-core.zip" "${CORE_LATEST_OBJECT_NAME}" + rclone copy "${CORE_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress + cp "AstrBot-${VERSION_TAG}-core.zip" "astrbot-core-${VERSION_TAG}.zip" + rclone copy "astrbot-core-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress + rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/astrbot-core/${VERSION_TAG}/source.zip" --progress + rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/download/astrbot-core/${VERSION_TAG}/source.zip" --progress + publish-release: name: Publish GitHub Release if: github.repository == 'AstrBotDevs/AstrBot' diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index ecd8d839d..304b35769 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -1,6 +1,7 @@ import os import sys import time +import zipfile from pathlib import Path import psutil @@ -23,6 +24,30 @@ class AstrBotUpdator(RepoZipUpdator): super().__init__(repo_mirror, verify=verify) self.MAIN_PATH = get_astrbot_path() self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases" + self.CORE_PACKAGE_BASE_URL = ( + "https://astrbot-registry.soulter.top/download/astrbot-core" + ) + + def _build_core_package_url(self, version: str | None) -> str | None: + """Build the hosted core package URL for a release tag. + + Args: + version: Release tag, such as ``v4.26.0``. + + Returns: + Public package URL, or None when hosted package download is disabled. + """ + + if not version or not str(version).startswith("v"): + return None + + base_url = os.environ.get( + "ASTRBOT_CORE_PACKAGE_BASE_URL", + self.CORE_PACKAGE_BASE_URL, + ).strip() + if not base_url: + return None + return f"{base_url.rstrip('/')}/{version}/source.zip" def terminate_child_processes(self) -> None: """终止当前进程的所有子进程 @@ -196,15 +221,18 @@ class AstrBotUpdator(RepoZipUpdator): "Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot." ) # 避免版本管理混乱 + target_version = None if latest: latest_version = update_data[0]["tag_name"] if self.compare_version(VERSION, latest_version) >= 0: raise Exception("当前已经是最新版本。") + target_version = latest_version file_url = update_data[0]["zipball_url"] elif str(version).startswith("v"): # 更新到指定版本 for data in update_data: if data["tag_name"] == version: + target_version = data["tag_name"] file_url = data["zipball_url"] if not file_url: raise Exception(f"未找到版本号为 {version} 的更新文件。") @@ -220,6 +248,28 @@ class AstrBotUpdator(RepoZipUpdator): zip_path = Path(path) ensure_dir(zip_path.parent) + hosted_package_url = self._build_core_package_url(target_version) + if hosted_package_url: + try: + logger.info( + f"优先从托管存储下载 AstrBot Core 更新包: {hosted_package_url}" + ) + await self._download_file( + hosted_package_url, + str(zip_path), + progress_callback=progress_callback, + ) + if not zipfile.is_zipfile(zip_path): + raise RuntimeError( + "Downloaded hosted package is not a valid ZIP file" + ) + return zip_path + except Exception as exc: + logger.warning( + f"从托管存储下载 AstrBot Core 更新包失败: {exc}," + "将回退到当前更新源。" + ) + await self._download_file( file_url, str(zip_path), diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index e0a0f544f..d03435448 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -461,6 +461,10 @@ async def download_dashboard( show_progress=True, progress_callback=progress_callback, ) + if not zipfile.is_zipfile(zip_path): + raise RuntimeError( + "Downloaded dashboard package is not a valid ZIP file" + ) except BaseException as _: if latest: # Resolve latest release tag from GitHub API to construct correct asset URL @@ -488,6 +492,10 @@ async def download_dashboard( show_progress=True, progress_callback=progress_callback, ) + if not zipfile.is_zipfile(zip_path): + raise RuntimeError( + "Downloaded dashboard package is not a valid ZIP file" + ) else: url = f"https://github.com/AstrBotDevs/astrbot-release-harbour/releases/download/release-{version}/dist.zip" logger.info(f"Downloading AstrBot WebUI from {url}") @@ -499,6 +507,8 @@ async def download_dashboard( show_progress=True, progress_callback=progress_callback, ) + if not zipfile.is_zipfile(zip_path): + raise RuntimeError("Downloaded dashboard package is not a valid ZIP file") if extract: extract_dashboard(zip_path, extract_path) diff --git a/tests/test_updator_socks.py b/tests/test_updator_socks.py index dc4c6e250..fad33a64b 100644 --- a/tests/test_updator_socks.py +++ b/tests/test_updator_socks.py @@ -1,14 +1,18 @@ import ntpath import posixpath +import zipfile from dataclasses import dataclass, field from pathlib import Path from types import SimpleNamespace +from urllib.parse import urlparse import certifi import httpx import pytest from astrbot.core.star.updator import PluginUpdator +from astrbot.core.updator import AstrBotUpdator +from astrbot.core.utils import io as io_utils from astrbot.core.zip_updator import RepoZipUpdator @@ -286,6 +290,185 @@ async def test_plugin_updator_install_prefers_download_url( assert calls["unzip"] == (str(expected_path) + ".zip", str(expected_path)) +@pytest.mark.asyncio +async def test_astrbot_updator_prefers_hosted_core_package( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("ASTRBOT_CLI", raising=False) + monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False) + monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core") + + updator = AstrBotUpdator() + calls: list[str] = [] + + async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001 + return [ + { + "version": "AstrBot v99.0.0", + "published_at": "2026-06-19T00:00:00Z", + "body": "hosted core package", + "tag_name": "v99.0.0", + "zipball_url": "https://github.example/archive.zip", + } + ] + + async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001 + calls.append(url) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("AstrBot-v99.0.0/README.md", "hosted-core") + + monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info) + monkeypatch.setattr(updator, "_download_file", fake_download_file) + + zip_path = await updator.download_update_package( + latest=False, + version="v99.0.0", + path=tmp_path / "core.zip", + ) + + assert zip_path == tmp_path / "core.zip" + assert zipfile.is_zipfile(zip_path) + assert calls == ["https://cdn.example/core/v99.0.0/source.zip"] + + +@pytest.mark.asyncio +async def test_astrbot_updator_falls_back_when_hosted_core_package_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("ASTRBOT_CLI", raising=False) + monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False) + monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core") + + updator = AstrBotUpdator() + calls: list[str] = [] + + async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001 + return [ + { + "version": "AstrBot v99.0.0", + "published_at": "2026-06-19T00:00:00Z", + "body": "hosted core package", + "tag_name": "v99.0.0", + "zipball_url": "https://github.example/archive.zip", + } + ] + + async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001 + calls.append(url) + parsed = urlparse(url) + if parsed.scheme == "https" and parsed.hostname == "cdn.example": + raise RuntimeError("404") + Path(path).write_bytes(b"github-core") + + monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info) + monkeypatch.setattr(updator, "_download_file", fake_download_file) + + zip_path = await updator.download_update_package( + latest=False, + version="v99.0.0", + path=tmp_path / "core.zip", + ) + + assert zip_path == tmp_path / "core.zip" + assert zip_path.read_bytes() == b"github-core" + assert calls == [ + "https://cdn.example/core/v99.0.0/source.zip", + "https://github.example/archive.zip", + ] + + +@pytest.mark.asyncio +async def test_astrbot_updator_falls_back_when_hosted_core_package_is_not_zip( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("ASTRBOT_CLI", raising=False) + monkeypatch.delenv("ASTRBOT_LAUNCHER", raising=False) + monkeypatch.setenv("ASTRBOT_CORE_PACKAGE_BASE_URL", "https://cdn.example/core") + + updator = AstrBotUpdator() + calls: list[str] = [] + + async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001 + return [ + { + "version": "AstrBot v99.0.0", + "published_at": "2026-06-19T00:00:00Z", + "body": "hosted core package", + "tag_name": "v99.0.0", + "zipball_url": "https://github.example/archive.zip", + } + ] + + async def fake_download_file(url: str, path: str, progress_callback=None): # noqa: ARG001 + calls.append(url) + parsed = urlparse(url) + if parsed.scheme == "https" and parsed.hostname == "cdn.example": + Path(path).write_bytes(b"not a zip") + return + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("AstrBot-v99.0.0/README.md", "github-core") + + monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info) + monkeypatch.setattr(updator, "_download_file", fake_download_file) + + zip_path = await updator.download_update_package( + latest=False, + version="v99.0.0", + path=tmp_path / "core.zip", + ) + + assert zip_path == tmp_path / "core.zip" + assert zipfile.is_zipfile(zip_path) + assert calls == [ + "https://cdn.example/core/v99.0.0/source.zip", + "https://github.example/archive.zip", + ] + + +@pytest.mark.asyncio +async def test_download_dashboard_falls_back_when_hosted_package_is_not_zip( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + calls: list[str] = [] + + async def fake_download_file( + url: str, + path: str, + show_progress: bool = False, # noqa: ARG001 + progress_callback=None, # noqa: ARG001 + ) -> None: + calls.append(url) + parsed = urlparse(url) + if ( + parsed.scheme == "https" + and parsed.hostname == "astrbot-registry.soulter.top" + ): + Path(path).write_bytes(b"not a zip") + return + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("dist/index.html", "dashboard") + + monkeypatch.setattr(io_utils, "download_file", fake_download_file) + + zip_path = tmp_path / "dashboard.zip" + await io_utils.download_dashboard( + path=str(zip_path), + latest=False, + version="v99.0.0", + extract=False, + ) + + assert zipfile.is_zipfile(zip_path) + assert calls == [ + "https://astrbot-registry.soulter.top/download/astrbot-dashboard/v99.0.0/dist.zip", + "https://github.com/AstrBotDevs/AstrBot/releases/download/v99.0.0/AstrBot-v99.0.0-dashboard.zip", + ] + + @pytest.mark.asyncio async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support( monkeypatch: pytest.MonkeyPatch,