Ctrl K
ComunidadGuías…

scripts/build_release.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Build a clean DiceFrame release zip.

The package contains source code plus a prebuilt Vue frontend. Runtime data,
logs, local settings, tests, caches, and secrets are intentionally excluded.
"""

from __future__ import annotations

import argparse
import hashlib
import os
import re
import shutil
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from src.template_catalog import is_user_template_file

try:
    from . import build_assistant_knowledge
except ImportError:  # Direct execution: python scripts/build_release.py
    import build_assistant_knowledge

DIST_DIR = ROOT / "dist"
BUILD_ROOT = DIST_DIR / "_release_build"
# v3 头像:6 规则 × 8 张 jpg(4 realistic + 4 anime)。旧 WebP 图集(12 张)已弃用。
EXPECTED_BUILTIN_AVATAR_ATLASES = 48
MAX_BUILTIN_AVATAR_COMPRESSED_BYTES = 8 * 1024 * 1024
BUILTIN_BACKGROUND_FILES = {
    "dark-fantasy-atmosphere.jpg",
    "campaign-mountain-city.jpg",
    "campaign-moonlit-ruins.jpg",
}

ROOT_FILES = [
    ".env.example",
    "Dockerfile",
    "LICENSE",
    "README.md",
    "README_EN.md",
    "README_zh-CN.md",
    "THIRD_PARTY_NOTICES.md",
    "docker-compose.yml",
    "requirements.txt",
    "requirements-portable.lock",
    "web_server.py",
    "web_ui.bat",
]

ROOT_DIRS = [
    "legal",
    "plugins",
    "prompts",
    "scripts",
    "src",
    "templates",
]

FRONTEND_FILES = [
    "index.html",
    "package-lock.json",
    "package.json",
    "tsconfig.app.json",
    "tsconfig.json",
    "vite.config.ts",
    "vitest.config.ts",
]

FRONTEND_DIRS = [
    "public",
    "src",
]

EXCLUDED_DIR_NAMES = {
    ".git",
    ".github",
    ".mypy_cache",
    ".pytest_cache",
    ".ruff_cache",
    ".venv",
    "__pycache__",
    "data",
    "data-test",
    "dist",
    "env",
    "node_modules",
    "playwright-report",
    "test-results",
    "tests",
    "venv",
}

EXCLUDED_SUFFIXES = {
    ".log",
    ".pyc",
    ".pyo",
    ".sqlite",
    ".db",
    ".tsbuildinfo",
}

# web_server.py imports this module during startup. Keep it explicit so a
# packaging exclusion cannot silently produce an unusable release archive.
REQUIRED_RUNTIME_SOURCE_FILES = ("src/ai_providers.py",)

FORBIDDEN_ZIP_PATTERNS = [
    re.compile(r"(^|/)data/"),
    re.compile(r"(^|/)\.env$"),
    re.compile(r"(^|/)\.env\.(?!example$)"),
    re.compile(r"(^|/)\.git/"),
    re.compile(r"(^|/)\.codex/"),
    re.compile(r"(^|/)\.claude/"),
    re.compile(r"(^|/)node_modules/"),
    re.compile(r"(^|/)tests/"),
    re.compile(r"(^|/)frontend-v2/tests/"),
    re.compile(r"(^|/)frontend-v2/e2e/"),
    re.compile(r"(^|/)test-results/"),
    re.compile(r"(^|/)playwright-report/"),
    re.compile(r"\.(?:log|pyc|pyo|sqlite|db)$"),
]


def run(cmd: list[str], cwd: Path) -> None:
    print("> " + " ".join(cmd), flush=True)
    subprocess.run(cmd, cwd=str(cwd), check=True)


def git_dirty() -> bool:
    result = subprocess.run(
        ["git", "status", "--porcelain"],
        cwd=str(ROOT),
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        check=False,
    )
    return bool(result.stdout.strip())


def app_version() -> str:
    text = (ROOT / "src" / "version.py").read_text(encoding="utf-8")
    match = re.search(r'__version__\s*=\s*"([^"]+)"', text)
    if not match:
        raise RuntimeError("Cannot find __version__ in src/version.py")
    return match.group(1)


def is_excluded(path: Path) -> bool:
    parts = set(path.parts)
    if parts & EXCLUDED_DIR_NAMES:
        return True
    if path.name.startswith(".env"):
        return True
    if "_copy_" in path.name:
        return True
    return path.suffix in EXCLUDED_SUFFIXES


def copy_file(src: Path, dst: Path) -> None:
    if not src.exists():
        return
    dst.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(src, dst)


def copy_tree(src: Path, dst: Path) -> None:
    if not src.exists():
        return
    template_kind = "rules" if src == ROOT / "templates" / "rules" else "worlds" if src == ROOT / "templates" / "worlds" else ""
    for path in src.rglob("*"):
        rel = path.relative_to(src)
        if is_excluded(rel):
            continue
        if path.is_file() and template_kind and is_user_template_file(path, template_kind):
            continue
        target = dst / rel
        if path.is_dir():
            target.mkdir(parents=True, exist_ok=True)
        else:
            copy_file(path, target)


CLOUDFLARED_VERSION = "2026.7.3"
CLOUDFLARED_SHA256 = {
    "cloudflared-windows-amd64.exe": "8635da433b6df8194746e88ed9d2589566c20e38bfc2a80e431a348b7c765841",
    "cloudflared-linux-amd64": "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17",
}


def _sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def download_cloudflared(package_dir: Path) -> None:
    """下载 cloudflared 二进制打进包内,让外网接入插件离线可用。

    当前 release.yml 仅 Windows 平台构建(ubuntu 跑 windows-zip、windows 跑
    portable),均不内置——source/portable 包里外网接入插件运行时自行下载。
    函数保留,供未来 Linux/Docker 构建复用(按构建平台选资产:
    windows-amd64 / linux-amd64)。失败不阻断打包(插件会运行时下载)。
    """
    target_dir = package_dir / "cloudflared"
    target_dir.mkdir(parents=True, exist_ok=True)
    if os.name == "nt":
        asset = "cloudflared-windows-amd64.exe"
        target = target_dir / "cloudflared.exe"
    else:
        asset = "cloudflared-linux-amd64"
        target = target_dir / "cloudflared"
    url = f"https://github.com/cloudflare/cloudflared/releases/download/{CLOUDFLARED_VERSION}/{asset}"
    temporary = target.with_suffix(target.suffix + ".download")
    try:
        print(f"Downloading cloudflared {CLOUDFLARED_VERSION} ...")
        urllib.request.urlretrieve(url, temporary)
        expected = CLOUDFLARED_SHA256[asset]
        actual = _sha256_file(temporary)
        if actual.lower() != expected.lower():
            raise RuntimeError(f"cloudflared sha256 mismatch: expected {expected}, got {actual}")
        temporary.replace(target)
        if os.name != "nt":
            target.chmod(0o755)
        print(f"cloudflared saved to {target}")
    except Exception as exc:  # noqa: BLE001
        temporary.unlink(missing_ok=True)
        target.unlink(missing_ok=True)
        print(f"Warning: cloudflared download failed, plugin will fetch at runtime: {exc}")


def prepare_package_tree(package_dir: Path, *, include_cloudflared: bool = False) -> None:
    """组装发布包目录树。

    include_cloudflared 默认 False:source 与 portable 包都不内置 cloudflared
    二进制,外网接入插件运行时自行下载(插件 v0.2.0 起带 sha256 校验)。
    参数保留,供未来 Linux/Docker 构建复用(内置 linux-amd64)。
    """
    if package_dir.exists():
        shutil.rmtree(package_dir)
    package_dir.mkdir(parents=True)

    for rel in ROOT_FILES:
        copy_file(ROOT / rel, package_dir / rel)
    for rel in ROOT_DIRS:
        copy_tree(ROOT / rel, package_dir / rel)

    frontend_dir = package_dir / "frontend-v2"
    frontend_dir.mkdir(parents=True, exist_ok=True)
    for rel in FRONTEND_FILES:
        copy_file(ROOT / "frontend-v2" / rel, frontend_dir / rel)
    for rel in FRONTEND_DIRS:
        copy_tree(ROOT / "frontend-v2" / rel, frontend_dir / rel)

    build_assistant_knowledge.build(package_dir / "src" / "webui" / "assistant_knowledge_index.json")

    if include_cloudflared:
        download_cloudflared(package_dir)


def prepare_runtime_app_tree(app_dir: Path) -> None:
    """Assemble runtime files plus temporary frontend sources for the build."""

    if app_dir.exists():
        shutil.rmtree(app_dir)
    app_dir.mkdir(parents=True)
    copy_file(ROOT / "web_server.py", app_dir / "web_server.py")
    for rel in ("legal", "plugins", "prompts", "src", "templates"):
        copy_tree(ROOT / rel, app_dir / rel)
    frontend_dir = app_dir / "frontend-v2"
    frontend_dir.mkdir(parents=True, exist_ok=True)
    for rel in FRONTEND_FILES:
        copy_file(ROOT / "frontend-v2" / rel, frontend_dir / rel)
    for rel in FRONTEND_DIRS:
        copy_tree(ROOT / "frontend-v2" / rel, frontend_dir / rel)
    build_assistant_knowledge.build(
        app_dir / "src" / "webui" / "assistant_knowledge_index.json"
    )


def build_frontend(package_dir: Path) -> None:
    frontend_dir = package_dir / "frontend-v2"
    npm = shutil.which("npm.cmd") or shutil.which("npm")
    if not npm:
        raise RuntimeError("npm not found. Install Node.js 20.19+ or 22.12+ before building a release.")
    run([npm, "ci"], frontend_dir)
    run([npm, "run", "build"], frontend_dir)
    shutil.rmtree(frontend_dir / "node_modules", ignore_errors=True)
    for tsbuild in frontend_dir.glob("*.tsbuildinfo"):
        tsbuild.unlink(missing_ok=True)


def make_zip(package_dir: Path, output_zip: Path) -> None:
    output_zip.parent.mkdir(parents=True, exist_ok=True)
    if output_zip.exists():
        try:
            output_zip.unlink()
        except PermissionError:
            output_zip = available_zip_name(output_zip)
    shutil.make_archive(str(output_zip.with_suffix("")), "zip", root_dir=package_dir.parent, base_dir=package_dir.name)
    validate_zip(output_zip)


def available_zip_name(path: Path) -> Path:
    stem = path.stem
    suffix = path.suffix
    for idx in range(1, 100):
        candidate = path.parent / f"{stem}-{idx}{suffix}"
        if not candidate.exists():
            return candidate
    raise RuntimeError(f"Cannot find an available output name near {path}")


def validate_zip(output_zip: Path) -> None:
    with zipfile.ZipFile(output_zip) as zf:
        names = zf.namelist()
        infos = zf.infolist()
    bad = [name for name in names if any(pattern.search(name.replace("\\", "/")) for pattern in FORBIDDEN_ZIP_PATTERNS)]
    if bad:
        preview = "\n".join(bad[:20])
        raise RuntimeError(f"Release zip contains forbidden paths:\n{preview}")
    if not any(name.endswith("/static-v2/index.html") for name in names):
        raise RuntimeError("Release zip is missing static-v2/index.html")
    if not any("/static-v2/assets/" in name and name.endswith(".js") for name in names):
        raise RuntimeError("Release zip is missing built frontend assets")
    for relative in REQUIRED_RUNTIME_SOURCE_FILES:
        if not any(name.endswith("/" + relative) for name in names):
            raise RuntimeError(f"Release zip is missing {relative}")
    validate_avatar_payload(infos, require_source=True)
    validate_background_payload(infos, require_source=True)


def validate_avatar_payload(infos: list[zipfile.ZipInfo], *, require_source: bool) -> None:
    normalized = [(info, info.filename.replace("\\", "/")) for info in infos]
    built = [info for info, name in normalized if "/static-v2/avatars/v3/" in name and name.endswith(".jpg")]
    source = [info for info, name in normalized if "/frontend-v2/public/avatars/v3/" in name and name.endswith(".jpg")]
    legacy_png = [name for _, name in normalized if "/avatars/" in name and name.lower().endswith(".png")]
    if legacy_png:
        raise RuntimeError("Release zip contains legacy PNG portrait atlases")
    if len(built) != EXPECTED_BUILTIN_AVATAR_ATLASES:
        raise RuntimeError(
            f"Release zip must contain {EXPECTED_BUILTIN_AVATAR_ATLASES} built v3 portrait images, got {len(built)}"
        )
    if require_source and len(source) != EXPECTED_BUILTIN_AVATAR_ATLASES:
        raise RuntimeError(
            f"Source release must contain {EXPECTED_BUILTIN_AVATAR_ATLASES} source v3 portrait images, got {len(source)}"
        )
    compressed_size = sum(info.compress_size for info in built + source)
    if compressed_size > MAX_BUILTIN_AVATAR_COMPRESSED_BYTES:
        raise RuntimeError(
            f"Built-in portrait payload is too large: {compressed_size} bytes; optimize assets before publishing"
        )


def validate_background_payload(infos: list[zipfile.ZipInfo], *, require_source: bool) -> None:
    normalized = [info.filename.replace("\\", "/") for info in infos]
    built = {
        name.rsplit("/", 1)[-1]
        for name in normalized
        if "/static-v2/ui/" in name
    }
    missing_built = BUILTIN_BACKGROUND_FILES - built
    if missing_built:
        raise RuntimeError(
            "Release zip is missing built-in UI backgrounds: " + ", ".join(sorted(missing_built))
        )
    if require_source:
        source = {
            name.rsplit("/", 1)[-1]
            for name in normalized
            if "/frontend-v2/public/ui/" in name
        }
        missing_source = BUILTIN_BACKGROUND_FILES - source
        if missing_source:
            raise RuntimeError(
                "Source release is missing built-in UI backgrounds: " + ", ".join(sorted(missing_source))
            )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Build a clean DiceFrame release zip.")
    parser.add_argument("--version", default=app_version(), help="Release version, default: src/version.py")
    parser.add_argument("--output-dir", type=Path, default=DIST_DIR, help="Directory for the generated zip")
    parser.add_argument("--allow-dirty", action="store_true", help="Allow packaging with uncommitted git changes")
    parser.add_argument("--skip-build", action="store_true", help="Copy files without running npm build")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    if git_dirty() and not args.allow_dirty:
        print(
            "Working tree has uncommitted changes. Commit first, or rerun with --allow-dirty for a local test package.",
            file=sys.stderr,
        )
        return 2

    version = args.version.lstrip("v")
    package_name = f"DiceFrame-v{version}-windows"
    package_dir = BUILD_ROOT / package_name
    output_zip = args.output_dir.resolve() / f"{package_name}.zip"

    if BUILD_ROOT.exists():
        shutil.rmtree(BUILD_ROOT)
    prepare_package_tree(package_dir)
    if not args.skip_build:
        build_frontend(package_dir)
    make_zip(package_dir, output_zip)
    print(f"\nRelease package created: {output_zip}")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except subprocess.CalledProcessError as exc:
        raise SystemExit(exc.returncode)