Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions scripts/ci/lib/windows_filenames.py
Original file line number Diff line number Diff line change
@@ -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"
)
53 changes: 50 additions & 3 deletions scripts/ci/package_windows_portable.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import argparse
from dataclasses import dataclass
from datetime import datetime
import json
import pathlib
import re
Expand All @@ -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(
Expand All @@ -23,6 +25,13 @@
WINDOWS_LEGACY_INSTALLER_RE = re.compile(
r"(?P<name>.+?)_(?P<version>.+?)_(?P<arch>x64|amd64|arm64|aarch64)-setup\.exe$"
)
LEGACY_NIGHTLY_BASE_VERSION_PATTERN = r"[0-9A-Za-z.+]+(?:-[0-9A-Za-z.+]+)*"
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
LEGACY_NIGHTLY_VERSION_RE = re.compile(
rf"^(?P<version>{LEGACY_NIGHTLY_BASE_VERSION_PATTERN})-nightly[._-](?P<date>[0-9]{{8}})[._-](?P<sha>{SHORT_SHA_PATTERN})$"
)
MALFORMED_LEGACY_NIGHTLY_VERSION_RE = re.compile(
rf"^(?P<version>{LEGACY_NIGHTLY_BASE_VERSION_PATTERN})-nightly(?:[._-].*)?$"
)

PORTABLE_README_NAME = "README-portable.txt"
PORTABLE_README_TEXT = """AstrBot Windows portable package
Expand Down Expand Up @@ -55,6 +64,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():
Expand Down Expand Up @@ -101,6 +118,24 @@ def load_project_config_from(start_path: pathlib.Path) -> ProjectConfig:
)


def normalize_legacy_nightly_version(version: str) -> tuple[str, str]:
nightly_match = LEGACY_NIGHTLY_VERSION_RE.fullmatch(version)
if nightly_match and is_valid_nightly_date(nightly_match.group("date")):
base_version = nightly_match.group("version")
nightly_suffix = f"_nightly_{nightly_match.group('sha')}"
return base_version, nightly_suffix

malformed_nightly_match = MALFORMED_LEGACY_NIGHTLY_VERSION_RE.fullmatch(version)
if malformed_nightly_match:
return malformed_nightly_match.group("version"), ""

return version, ""


def portable_executable_name(project_config: ProjectConfig) -> str:
return f"{project_config.product_name}.exe"


def load_project_config() -> ProjectConfig:
return load_project_config_from(pathlib.Path(__file__))

Expand All @@ -117,9 +152,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: "
Expand Down Expand Up @@ -179,6 +216,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


Expand All @@ -205,7 +249,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 / portable_executable_name(project_config),
)

webview_loader = release_dir / "WebView2Loader.dll"
if webview_loader.is_file():
Expand Down
203 changes: 201 additions & 2 deletions scripts/ci/test_package_windows_portable.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import re
import tempfile
import unittest
Expand Down Expand Up @@ -27,6 +28,61 @@ 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"),
]

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,
):
Comment on lines +60 to 118
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add coverage for malformed legacy nightly versions that should not be treated as nightly builds

To better exercise LEGACY_NIGHTLY_VERSION_RE, please add cases where the version segment looks nightly-like but doesn’t actually match the regex (e.g. wrong SHA length, invalid date, or missing components). These should follow non-nightly behavior and yield ..._portable.zip without the _nightly_<sha> suffix. You can add these as extra subTests here or as a sibling test asserting the suffix is omitted when the pattern doesn’t fully match.

Suggested change
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_rejects_noncanonical_nightly_suffix_length(
self,
):
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 = [".", "_", "-"]
# Cases that look "nightly-like" but should not match LEGACY_NIGHTLY_VERSION_RE
malformed_fragments = [
# Wrong SHA length (too short)
("nightly{sep}20260401{sep}deadbee", "short_sha"),
# Invalid date (13th month)
("nightly{sep}20261301{sep}deadbeef", "invalid_date"),
# Missing SHA component entirely
("nightly{sep}20260401", "missing_sha"),
]
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"
)
# Malformed legacy nightly patterns should be treated as non-nightly
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,
):
self.assertEqual(project_config.binary_name, "astrbot-desktop-tauri")
self.assertEqual(project_config.portable_marker_name, "portable.flag")

Expand Down Expand Up @@ -128,6 +184,144 @@ def test_load_project_config_from_returns_root_product_and_marker(self):
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_portable_executable_name_uses_normalized_product_name(self):
project_config = MODULE.ProjectConfig(
root=Path("/tmp/project"),
product_name="AstrBot",
binary_name="astrbot-desktop-tauri",
portable_marker_name="portable.flag",
)

self.assertEqual(MODULE.portable_executable_name(project_config), "AstrBot.exe")

def test_load_project_config_from_rejects_product_name_with_invalid_windows_chars(
self,
):
invalid_product_names = [
"AstrBot<Dev>",
"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):
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
)

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(
'[package]\nname = "astrbot-desktop-tauri"\n'
)
marker_path.parent.mkdir(parents=True, exist_ok=True)
marker_path.write_text("portable.flag\n")

with self.assertRaisesRegex(ValueError, "invalid Windows filename"):
MODULE.load_project_config_from(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):
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
)

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(
'[package]\nname = "astrbot-desktop-tauri"\n'
)
marker_path.parent.mkdir(parents=True, exist_ok=True)
marker_path.write_text("portable.flag\n")

with self.assertRaisesRegex(ValueError, "reserved device name"):
MODULE.load_project_config_from(script_path)

def test_load_project_config_from_rejects_product_name_with_trailing_dot(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

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": "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")

with self.assertRaisesRegex(ValueError, "trailing spaces or dots"):
MODULE.load_project_config_from(script_path)

def test_load_project_config_from_strips_exe_suffix_from_product_name(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

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.exe"}')
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(script_path)

self.assertEqual(project_config.product_name, "AstrBot")

def test_load_cargo_package_name_supports_inline_comments(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
Expand Down Expand Up @@ -293,13 +487,18 @@ def test_populate_portable_root_copies_release_bundle_contents(self):
"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=MODULE.load_project_config_from(script_path),
project_config=project_config,
)

self.assertTrue((destination_root / "astrbot-desktop-tauri.exe").is_file())
executable_name = f"{project_config.product_name}.exe"

self.assertTrue((destination_root / executable_name).is_file())
self.assertFalse((destination_root / "astrbot-desktop-tauri.exe").exists())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider asserting the copied executable name via project_config.product_name to guard against future renames

The current checks are correct, but to make this test resilient to future product renames, derive the expected .exe name from project_config.product_name (e.g. f"{project_config.product_name}.exe") instead of hardcoding AstrBot.exe.

Suggested implementation:

            )

            executable_name = f"{project_config.product_name}.exe"

            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(

This edit assumes that project_config is already defined in the scope of this test method (which appears likely from project_config=MODULE.load_project_config_from(script_path), above). If it is instead only passed into another function and not bound as a local variable, you will need to:

  1. Assign the result of MODULE.load_project_config_from(script_path) to a local project_config variable before this block, and
  2. Pass that project_config into any existing calls that currently construct it inline.

self.assertTrue((destination_root / "WebView2Loader.dll").is_file())
self.assertTrue(
(
Expand Down