+66
-214
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
# Scripts package marker.
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user