feat(ci): add nightly prerelease release flow and updater support (#5744)

* feat: add nightly prerelease release flow and updater support

* feat(ci): auto-generate nightly release notes from latest stable tag

* fix(ci): correct nightly release notes heredoc YAML indentation

* fix(ci): align nightly notes heredoc terminator

* fix(ci): remove heredoc body indentation in nightly notes script

* fix: align nightly release metadata and prerelease rules

* fix: harden nightly release flow and updater release resolution

* fix: improve nightly branch resolution and updater logging

* fix: simplify updater target resolution and nightly release assets

* fix: avoid inputs lookup on non-dispatch release events

* fix: split nightly release fetch and simplify updater flow

* refactor: simplify updater target resolvers and nightly error checks

* fix: type release fetch errors and streamline updater resolution

* refactor: simplify updater target branching and release artifacts

* refactor: simplify release fetching and harden nightly git diagnostics

* fix: validate release payload shape before parsing

* refactor: harden prerelease handling and nightly constants

* refactor: derive archive urls and enrich fetch errors

* refactor: simplify update target resolution flow

* refactor: linearize update target resolution

* refactor: validate update target inputs and sync nightly tag source

* refactor: simplify updater mode resolution and prerelease tests

* refactor: simplify update target resolution flow

* fix: avoid package import when resolving nightly tag

* refactor: simplify updater resolution and centralize release constants

* fix: harden nightly release notes generation in workflow

* refactor: streamline update target resolution and errors

* refactor: simplify updater target resolution and nightly handling

* refactor: simplify updater errors and package release scripts

* refactor: centralize release api constants and loader

* fix(ci): resolve dispatch fallback tag from stable releases
This commit is contained in:
エイカク
2026-03-05 01:23:49 +09:00
committed by GitHub
parent cd434c5fed
commit 3d1c3946f6
13 changed files with 1224 additions and 146 deletions

1
scripts/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Scripts package marker.

View File

@@ -0,0 +1 @@
# Release scripts package marker.

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import subprocess
from collections import defaultdict
from pathlib import Path
if __package__:
from .release_constants_loader import load_release_constants
else:
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from scripts.release.release_constants_loader import load_release_constants
_constants = load_release_constants("NIGHTLY_TAG", "GITHUB_REPO_SLUG")
NIGHTLY_TAG = _constants["NIGHTLY_TAG"]
DEFAULT_REPO_SLUG = _constants["GITHUB_REPO_SLUG"]
def _run_git(*args: str) -> str:
try:
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").strip()
stdout = (e.stdout or "").strip()
detail = stderr or stdout or "no output"
raise RuntimeError(f"git {' '.join(args)} failed: {detail}") from e
return result.stdout.strip()
def _is_valid_ref(ref: str) -> bool:
if not ref:
return False
result = subprocess.run(
["git", "rev-parse", "--verify", "--quiet", ref],
capture_output=True,
text=True,
check=False,
)
return result.returncode == 0
def _classify(subject: str) -> str:
lowered = subject.lower().strip()
if lowered.startswith("feat") or "新增" in subject:
return "新增"
if lowered.startswith("fix") or "修复" in subject:
return "修复"
if (
lowered.startswith("perf")
or lowered.startswith("refactor")
or "优化" in subject
):
return "优化"
return "其他"
def _write_fallback(output_path: Path) -> None:
short_sha = _run_git("rev-parse", "--short=8", "HEAD")
output_path.write_text(
f"## What's Changed\n\n- {NIGHTLY_TAG.capitalize()} build from `{short_sha}`\n",
encoding="utf-8",
)
def generate_notes(base_tag: str, repo: str, output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
if not _is_valid_ref(base_tag):
_write_fallback(output_path)
return
log_output = _run_git(
"log",
"--no-merges",
"--pretty=format:%h%x1f%s",
f"{base_tag}..HEAD",
)
sections: dict[str, list[str]] = defaultdict(list)
for line in log_output.splitlines():
if not line.strip() or "\x1f" not in line:
continue
short_sha, subject = line.split("\x1f", 1)
commit_link = f"https://github.com/{repo}/commit/{short_sha}"
sections[_classify(subject)].append(
f"- {subject} ([`{short_sha}`]({commit_link}))"
)
nightly_commit = _run_git("rev-parse", "--short=8", "HEAD")
with output_path.open("w", encoding="utf-8") as file:
file.write("## What's Changed\n\n")
file.write(f"- Baseline tag: `{base_tag}`\n")
file.write(f"- {NIGHTLY_TAG.capitalize()} commit: `{nightly_commit}`\n")
if not any(sections.values()):
file.write(f"- No changes since `{base_tag}`\n\n")
return
file.write("\n")
for title in ("新增", "修复", "优化", "其他"):
items = sections.get(title, [])
if not items:
continue
file.write(f"### {title}\n")
file.write("\n".join(items))
file.write("\n\n")
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate release notes for nightly release.",
)
parser.add_argument("--base-tag", default="", help="Baseline stable tag.")
parser.add_argument(
"--repo",
default=DEFAULT_REPO_SLUG,
help="GitHub repository slug.",
)
parser.add_argument("--output", required=True, help="Output markdown path.")
args = parser.parse_args()
try:
generate_notes(args.base_tag.strip(), args.repo.strip(), Path(args.output))
except Exception as e:
raise SystemExit(f"Failed to generate nightly release notes: {e}") from e
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
if __package__:
from .release_constants_loader import load_release_constant
else:
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from scripts.release.release_constants_loader import load_release_constant
def main() -> None:
parser = argparse.ArgumentParser(
description="Print a release constant from astrbot/core/release_constants.py.",
)
parser.add_argument("name", help="Constant name, e.g. NIGHTLY_TAG.")
args = parser.parse_args()
print(load_release_constant(args.name))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import importlib.machinery
import importlib.util
from functools import lru_cache
from pathlib import Path
from types import ModuleType
def _constants_file() -> Path:
return (
Path(__file__).resolve().parents[2]
/ "astrbot"
/ "core"
/ "release_constants.py"
)
@lru_cache(maxsize=1)
def _release_constants_module() -> ModuleType:
constants_path = _constants_file()
module_name = "astrbot_core_release_constants_loader"
loader = importlib.machinery.SourceFileLoader(module_name, str(constants_path))
spec = importlib.util.spec_from_loader(module_name, loader)
if spec is None:
raise RuntimeError(f"Failed to load spec for {constants_path}")
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def load_release_constants(*names: str) -> dict[str, str]:
module = _release_constants_module()
values: dict[str, str] = {}
missing: list[str] = []
for name in names:
value = getattr(module, name, None)
if not isinstance(value, str):
missing.append(name)
continue
value = value.strip()
if not value:
missing.append(name)
continue
values[name] = value
if missing:
missing_str = ", ".join(missing)
raise RuntimeError(
f"Failed to parse {missing_str} from astrbot/core/release_constants.py",
)
return values
def load_release_constant(name: str) -> str:
return load_release_constants(name)[name]