diff --git a/scripts/ci/lib/windows_filenames.py b/scripts/ci/lib/windows_filenames.py new file mode 100644 index 00000000..7efd1243 --- /dev/null +++ b/scripts/ci/lib/windows_filenames.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import re + +WINDOWS_FILENAME_INVALID_CHARS_RE = re.compile(r'[<>:"/\\|?*]') +WINDOWS_FILENAME_INVALID_TRAILING_RE = re.compile(r"[ .]+$") +WINDOWS_RESERVED_DEVICE_NAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *{f"COM{i}" for i in range(1, 10)}, + *{f"LPT{i}" for i in range(1, 10)}, +} + + +def validate_windows_filename(name: str) -> None: + if not name or name in {".", ".."}: + raise ValueError(f"invalid Windows filename: {name!r}") + + if WINDOWS_FILENAME_INVALID_CHARS_RE.search(name): + raise ValueError( + f"invalid Windows filename {name!r}: contains characters invalid in Windows filenames" + ) + + if WINDOWS_FILENAME_INVALID_TRAILING_RE.search(name): + raise ValueError( + f"invalid Windows filename {name!r}: trailing spaces or dots are not allowed" + ) + + stem = name.split(".", 1)[0].upper() + if stem in WINDOWS_RESERVED_DEVICE_NAMES: + raise ValueError( + f"invalid Windows filename {name!r}: {stem!r} is a reserved device name" + ) diff --git a/scripts/ci/package_windows_portable.py b/scripts/ci/package_windows_portable.py index 2dd0cb0b..5f4d0c06 100644 --- a/scripts/ci/package_windows_portable.py +++ b/scripts/ci/package_windows_portable.py @@ -4,6 +4,7 @@ import argparse from dataclasses import dataclass +from datetime import datetime import json import pathlib import re @@ -14,6 +15,7 @@ from scripts.ci.lib.artifact_arch import normalize_arch_alias from scripts.ci.lib.release_artifacts import SHORT_SHA_PATTERN +from scripts.ci.lib.windows_filenames import validate_windows_filename WINDOWS_CANONICAL_INSTALLER_RE = re.compile( @@ -23,6 +25,7 @@ WINDOWS_LEGACY_INSTALLER_RE = re.compile( r"(?P.+?)_(?P.+?)_(?Px64|amd64|arm64|aarch64)-setup\.exe$" ) +LEGACY_NIGHTLY_BASE_VERSION_RE = re.compile(r"^[0-9A-Za-z.+]+(?:-[0-9A-Za-z.+]+)*$") PORTABLE_README_NAME = "README-portable.txt" PORTABLE_README_TEXT = """AstrBot Windows portable package @@ -55,6 +58,14 @@ def normalize_arch(arch: str) -> str: return normalize_arch_alias(arch) or arch +def is_valid_nightly_date(date_value: str) -> bool: + try: + datetime.strptime(date_value, "%Y%m%d") + except ValueError: + return False + return True + + def resolve_project_root_from(start_path: pathlib.Path) -> pathlib.Path: candidate = start_path.resolve() if candidate.is_file(): @@ -101,6 +112,31 @@ def load_project_config_from(start_path: pathlib.Path) -> ProjectConfig: ) +def normalize_legacy_nightly_version(version: str) -> tuple[str, str]: + if "-nightly" not in version: + return version, "" + + base_version, separator, nightly_part = version.partition("-nightly") + if not separator or not LEGACY_NIGHTLY_BASE_VERSION_RE.fullmatch(base_version): + return version, "" + + nightly_part = nightly_part.lstrip("._-") + if not nightly_part: + return base_version, "" + + parts = re.split(r"[._-]", nightly_part, maxsplit=2) + if len(parts) != 2: + return base_version, "" + + date_value, sha = parts[0], parts[1] + if not is_valid_nightly_date(date_value): + return base_version, "" + if not re.fullmatch(SHORT_SHA_PATTERN, sha): + return base_version, "" + + return base_version, f"_nightly_{sha}" + + def load_project_config() -> ProjectConfig: return load_project_config_from(pathlib.Path(__file__)) @@ -117,9 +153,11 @@ def installer_to_portable_name(installer_name: str) -> str: legacy_match = WINDOWS_LEGACY_INSTALLER_RE.fullmatch(installer_name) if legacy_match: name = legacy_match.group("name") - version = legacy_match.group("version") + version, nightly_suffix = normalize_legacy_nightly_version( + legacy_match.group("version") + ) arch = normalize_arch(legacy_match.group("arch")) - return f"{name}_{version}_windows_{arch}_portable.zip" + return f"{name}_{version}_windows_{arch}_portable{nightly_suffix}.zip" raise ValueError( "Unexpected Windows installer name: " @@ -179,6 +217,13 @@ def resolve_product_name(project_root: pathlib.Path) -> str: product_name = str(config.get("productName", "")).strip() if not product_name: raise ValueError(f"Missing productName in {TAURI_CONFIG_RELATIVE_PATH}") + if product_name.lower().endswith(".exe"): + product_name = product_name[:-4].rstrip() + if not product_name: + raise ValueError( + f"productName resolves to an empty executable name in {TAURI_CONFIG_RELATIVE_PATH}" + ) + validate_windows_filename(product_name) return product_name @@ -205,7 +250,10 @@ def populate_portable_root( main_executable_path = resolve_main_executable_path(bundle_dir, project_config) destination_root.mkdir(parents=True, exist_ok=True) - shutil.copy2(main_executable_path, destination_root / main_executable_path.name) + shutil.copy2( + main_executable_path, + destination_root / f"{project_config.product_name}.exe", + ) webview_loader = release_dir / "WebView2Loader.dll" if webview_loader.is_file(): diff --git a/scripts/ci/test_package_windows_portable.py b/scripts/ci/test_package_windows_portable.py index 4d8bf81c..74ec1873 100644 --- a/scripts/ci/test_package_windows_portable.py +++ b/scripts/ci/test_package_windows_portable.py @@ -1,3 +1,4 @@ +import json import re import tempfile import unittest @@ -7,6 +8,35 @@ class PackageWindowsPortableTests(unittest.TestCase): + def make_project_layout( + self, + *, + product_name: str = "AstrBot", + cargo_toml: str = '[package]\nname = "astrbot-desktop-tauri"\n', + marker_name: str = "portable.flag\n", + ) -> dict[str, Path]: + project_root = Path(self.enterContext(tempfile.TemporaryDirectory())) + script_path = project_root / "scripts" / "ci" / "package_windows_portable.py" + tauri_config_path = project_root / "src-tauri" / "tauri.conf.json" + cargo_toml_path = project_root / "src-tauri" / "Cargo.toml" + marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH + + script_path.parent.mkdir(parents=True) + script_path.write_text("# placeholder") + tauri_config_path.parent.mkdir(parents=True) + tauri_config_path.write_text(json.dumps({"productName": product_name})) + cargo_toml_path.write_text(cargo_toml) + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.write_text(marker_name) + + return { + "project_root": project_root, + "script_path": script_path, + "tauri_config_path": tauri_config_path, + "cargo_toml_path": cargo_toml_path, + "marker_path": marker_path, + } + def test_installer_to_portable_name_accepts_canonical_windows_name(self): self.assertEqual( MODULE.installer_to_portable_name("AstrBot_4.29.0_windows_amd64_setup.exe"), @@ -27,6 +57,62 @@ def test_installer_to_portable_name_accepts_canonical_nightly_windows_name(self) "AstrBot_4.29.0_windows_amd64_portable_nightly_deadbeef.zip", ) + def test_installer_to_portable_name_normalizes_legacy_nightly_windows_name(self): + arch_cases = [ + ("x64", "amd64"), + ("amd64", "amd64"), + ("arm64", "arm64"), + ("aarch64", "arm64"), + ] + separators = [".", "_", "-"] + + for arch_input, arch_output in arch_cases: + for separator in separators: + with self.subTest(arch=arch_input, separator=separator): + installer_name = f"AstrBot_4.29.0-nightly{separator}20260401{separator}deadbeef_{arch_input}-setup.exe" + expected_name = f"AstrBot_4.29.0_windows_{arch_output}_portable_nightly_deadbeef.zip" + self.assertEqual( + MODULE.installer_to_portable_name(installer_name), + expected_name, + ) + + def test_installer_to_portable_name_treats_malformed_legacy_nightly_as_non_nightly( + self, + ): + arch_cases = [ + ("x64", "amd64"), + ("amd64", "amd64"), + ("arm64", "arm64"), + ("aarch64", "arm64"), + ] + separators = [".", "_", "-"] + malformed_fragments = [ + ("nightly{sep}20260401{sep}deadbee", "short_sha"), + ("nightly{sep}20261301{sep}deadbeef", "invalid_date"), + ("nightly{sep}20260401", "missing_sha"), + ("nightly{sep}20260401{sep}deadbeef{sep}extra", "extra_component"), + ] + + for arch_input, arch_output in arch_cases: + for separator in separators: + for fragment_template, case_name in malformed_fragments: + fragment = fragment_template.format(sep=separator) + with self.subTest( + arch=arch_input, + separator=separator, + malformed_case=case_name, + ): + installer_name = ( + f"AstrBot_4.29.0-{fragment}_{arch_input}-setup.exe" + ) + expected_name = ( + f"AstrBot_4.29.0_windows_{arch_output}_portable.zip" + ) + self.assertEqual( + MODULE.installer_to_portable_name(installer_name), + expected_name, + ) + def test_installer_to_portable_name_rejects_noncanonical_nightly_suffix_length( self, ): @@ -104,29 +190,72 @@ def test_load_portable_runtime_marker_reads_shared_marker_file(self): ) def test_load_project_config_from_returns_root_product_and_marker(self): - with tempfile.TemporaryDirectory() as tmpdir: - project_root = Path(tmpdir) - script_path = ( - project_root / "scripts" / "ci" / "package_windows_portable.py" - ) - tauri_config_path = project_root / "src-tauri" / "tauri.conf.json" - cargo_toml_path = project_root / "src-tauri" / "Cargo.toml" - marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH + layout = self.make_project_layout() - script_path.parent.mkdir(parents=True) - script_path.write_text("# placeholder") - tauri_config_path.parent.mkdir(parents=True) - tauri_config_path.write_text('{"productName":"AstrBot"}') - cargo_toml_path.write_text('[package]\nname = "astrbot-desktop-tauri"\n') - marker_path.parent.mkdir(parents=True, exist_ok=True) - marker_path.write_text("portable.flag\n") + project_config = MODULE.load_project_config_from(layout["script_path"]) - project_config = MODULE.load_project_config_from(script_path) + self.assertEqual(project_config.root, layout["project_root"].resolve()) + self.assertEqual(project_config.product_name, "AstrBot") + self.assertEqual(project_config.binary_name, "astrbot-desktop-tauri") + self.assertEqual(project_config.portable_marker_name, "portable.flag") - self.assertEqual(project_config.root, project_root.resolve()) - self.assertEqual(project_config.product_name, "AstrBot") - self.assertEqual(project_config.binary_name, "astrbot-desktop-tauri") - self.assertEqual(project_config.portable_marker_name, "portable.flag") + def test_normalize_legacy_nightly_version_returns_base_version_and_suffix(self): + self.assertEqual( + MODULE.normalize_legacy_nightly_version("4.29.0-nightly.20260401.deadbeef"), + ("4.29.0", "_nightly_deadbeef"), + ) + + def test_normalize_legacy_nightly_version_strips_malformed_nightly_suffix(self): + self.assertEqual( + MODULE.normalize_legacy_nightly_version("4.29.0-nightly-20260401"), + ("4.29.0", ""), + ) + + def test_load_project_config_from_rejects_product_name_with_invalid_windows_chars( + self, + ): + invalid_product_names = [ + "AstrBot", + "AstrBot>Dev", + 'AstrBot:"Test"', + "AstrBot/Dev", + r"AstrBot\Dev", + "AstrBot|Beta", + "AstrBot?", + "AstrBot*", + ] + + for product_name in invalid_product_names: + with self.subTest(product_name=product_name): + layout = self.make_project_layout(product_name=product_name) + + with self.assertRaisesRegex(ValueError, "invalid Windows filename"): + MODULE.load_project_config_from(layout["script_path"]) + + def test_load_project_config_from_rejects_reserved_windows_device_names(self): + reserved_product_names = ["CON", "NUL", "PRN", "COM1", "LPT9"] + + for product_name in reserved_product_names: + with self.subTest(product_name=product_name): + layout = self.make_project_layout(product_name=product_name) + + with self.assertRaisesRegex(ValueError, "reserved device name"): + MODULE.load_project_config_from(layout["script_path"]) + + def test_load_project_config_from_rejects_product_name_with_trailing_dot(self): + layout = self.make_project_layout(product_name="AstrBot.") + + with self.assertRaisesRegex(ValueError, "trailing spaces or dots"): + MODULE.load_project_config_from(layout["script_path"]) + + def test_load_project_config_from_strips_exe_suffix_from_product_name(self): + for raw_name in ("AstrBot.exe", "AstrBot.EXE", "AstrBot.ExE", "AstrBot.exe "): + with self.subTest(product_name=raw_name): + layout = self.make_project_layout(product_name=raw_name) + + project_config = MODULE.load_project_config_from(layout["script_path"]) + + self.assertEqual(project_config.product_name, "AstrBot") def test_load_cargo_package_name_supports_inline_comments(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -256,99 +385,79 @@ def test_iter_installer_paths_only_returns_installer_style_executables(self): ) def test_populate_portable_root_copies_release_bundle_contents(self): - with tempfile.TemporaryDirectory() as tmpdir: - project_root = Path(tmpdir) - script_path = ( - project_root / "scripts" / "ci" / "package_windows_portable.py" - ) - bundle_dir = ( - project_root / "src-tauri" / "target" / "release" / "bundle" / "nsis" - ) - release_dir = project_root / "src-tauri" / "target" / "release" - destination_root = project_root / "portable" - backend_dir = project_root / "resources" / "backend" - webui_dir = project_root / "resources" / "webui" - windows_dir = project_root / "src-tauri" / "windows" - tauri_config_path = project_root / "src-tauri" / "tauri.conf.json" - cargo_toml_path = project_root / "src-tauri" / "Cargo.toml" - marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH + layout = self.make_project_layout() + project_root = layout["project_root"] + script_path = layout["script_path"] + bundle_dir = ( + project_root / "src-tauri" / "target" / "release" / "bundle" / "nsis" + ) + release_dir = project_root / "src-tauri" / "target" / "release" + destination_root = project_root / "portable" + backend_dir = project_root / "resources" / "backend" + webui_dir = project_root / "resources" / "webui" + windows_dir = project_root / "src-tauri" / "windows" + + bundle_dir.mkdir(parents=True) + release_dir.mkdir(parents=True, exist_ok=True) + backend_dir.mkdir(parents=True) + webui_dir.mkdir(parents=True) + windows_dir.mkdir(parents=True, exist_ok=True) + (release_dir / "astrbot-desktop-tauri.exe").write_text("exe") + (release_dir / "WebView2Loader.dll").write_text("dll") + (backend_dir / "runtime-manifest.json").write_text("{}") + (backend_dir / "launch_backend.py").write_text("print('ok')") + (webui_dir / "index.html").write_text("") + (windows_dir / "kill-backend-processes.ps1").write_text("Write-Host cleanup") + + project_config = MODULE.load_project_config_from(script_path) + + MODULE.populate_portable_root( + bundle_dir=bundle_dir, + destination_root=destination_root, + project_config=project_config, + ) - script_path.parent.mkdir(parents=True) - script_path.write_text("# placeholder") - bundle_dir.mkdir(parents=True) - release_dir.mkdir(parents=True, exist_ok=True) - backend_dir.mkdir(parents=True) - webui_dir.mkdir(parents=True) - windows_dir.mkdir(parents=True) + executable_name = f"{project_config.product_name}.exe" - tauri_config_path.write_text('{"productName":"AstrBot"}') - cargo_toml_path.write_text('[package]\nname = "astrbot-desktop-tauri"\n') - marker_path.write_text("portable.flag\n") - (release_dir / "astrbot-desktop-tauri.exe").write_text("exe") - (release_dir / "WebView2Loader.dll").write_text("dll") - (backend_dir / "runtime-manifest.json").write_text("{}") - (backend_dir / "launch_backend.py").write_text("print('ok')") - (webui_dir / "index.html").write_text("") - (windows_dir / "kill-backend-processes.ps1").write_text( - "Write-Host cleanup" - ) + self.assertTrue((destination_root / executable_name).is_file()) + self.assertFalse((destination_root / "astrbot-desktop-tauri.exe").exists()) + self.assertTrue((destination_root / "WebView2Loader.dll").is_file()) + self.assertTrue( + ( + destination_root / "resources" / "backend" / "runtime-manifest.json" + ).is_file() + ) + self.assertTrue( + (destination_root / "resources" / "webui" / "index.html").is_file() + ) + self.assertTrue((destination_root / "kill-backend-processes.ps1").is_file()) + self.assertTrue((destination_root / "portable.flag").is_file()) + self.assertTrue((destination_root / MODULE.PORTABLE_README_NAME).is_file()) + def test_populate_portable_root_rejects_missing_main_executable(self): + layout = self.make_project_layout() + project_root = layout["project_root"] + script_path = layout["script_path"] + bundle_dir = ( + project_root / "src-tauri" / "target" / "release" / "bundle" / "nsis" + ) + destination_root = project_root / "portable" + backend_dir = project_root / "resources" / "backend" + webui_dir = project_root / "resources" / "webui" + + bundle_dir.mkdir(parents=True) + backend_dir.mkdir(parents=True) + webui_dir.mkdir(parents=True) + (backend_dir / "runtime-manifest.json").write_text("{}") + (webui_dir / "index.html").write_text("") + + with self.assertRaisesRegex(FileNotFoundError, "Main executable not found"): MODULE.populate_portable_root( bundle_dir=bundle_dir, destination_root=destination_root, project_config=MODULE.load_project_config_from(script_path), ) - self.assertTrue((destination_root / "astrbot-desktop-tauri.exe").is_file()) - self.assertTrue((destination_root / "WebView2Loader.dll").is_file()) - self.assertTrue( - ( - destination_root / "resources" / "backend" / "runtime-manifest.json" - ).is_file() - ) - self.assertTrue( - (destination_root / "resources" / "webui" / "index.html").is_file() - ) - self.assertTrue((destination_root / "kill-backend-processes.ps1").is_file()) - self.assertTrue((destination_root / "portable.flag").is_file()) - self.assertTrue((destination_root / MODULE.PORTABLE_README_NAME).is_file()) - - def test_populate_portable_root_rejects_missing_main_executable(self): - with tempfile.TemporaryDirectory() as tmpdir: - project_root = Path(tmpdir) - script_path = ( - project_root / "scripts" / "ci" / "package_windows_portable.py" - ) - bundle_dir = ( - project_root / "src-tauri" / "target" / "release" / "bundle" / "nsis" - ) - destination_root = project_root / "portable" - backend_dir = project_root / "resources" / "backend" - webui_dir = project_root / "resources" / "webui" - tauri_config_path = project_root / "src-tauri" / "tauri.conf.json" - cargo_toml_path = project_root / "src-tauri" / "Cargo.toml" - marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH - - script_path.parent.mkdir(parents=True) - script_path.write_text("# placeholder") - bundle_dir.mkdir(parents=True) - backend_dir.mkdir(parents=True) - webui_dir.mkdir(parents=True) - tauri_config_path.parent.mkdir(parents=True, exist_ok=True) - tauri_config_path.write_text('{"productName":"AstrBot"}') - cargo_toml_path.write_text('[package]\nname = "astrbot-desktop-tauri"\n') - marker_path.parent.mkdir(parents=True, exist_ok=True) - marker_path.write_text("portable.flag\n") - (backend_dir / "runtime-manifest.json").write_text("{}") - (webui_dir / "index.html").write_text("") - - with self.assertRaisesRegex(FileNotFoundError, "Main executable not found"): - MODULE.populate_portable_root( - bundle_dir=bundle_dir, - destination_root=destination_root, - project_config=MODULE.load_project_config_from(script_path), - ) - def test_add_portable_runtime_files_writes_marker_and_readme(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir)