Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
45 changes: 41 additions & 4 deletions .github/scripts/release_gui_collect.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
#!/usr/bin/env python3
"""Collect and rename GUI release assets from cargo-packager outputs."""
"""Collect and rename GUI release assets from cargo-packager outputs.

This script intentionally does a small amount of normalization to avoid
platform-specific and tool-specific churn (filename casing, extension
variants, etc.).
"""

from __future__ import annotations

Expand All @@ -18,6 +23,8 @@ def fail(message: str) -> None:


def find_first(root: Path, pattern: str, *, directories: bool = False) -> Path:
"""Return the first match (sorted) for a glob pattern under root."""

if not root.exists():
fail(f"packager output directory does not exist: {root}")

Expand All @@ -31,9 +38,36 @@ def find_first(root: Path, pattern: str, *, directories: bool = False) -> Path:
if not matches:
entry_type = "directory" if directories else "file"
fail(f"failed to find {entry_type} matching '{pattern}' under {root}")

return matches[0]


def find_first_any(
root: Path,
patterns: list[str],
*,
directories: bool = False,
) -> Path:
"""Return the first match among multiple patterns (in provided order)."""

if not patterns:
fail("find_first_any requires at least one pattern")

errors: list[str] = []
for pattern in patterns:
try:
return find_first(root, pattern, directories=directories)
except SystemExit as exc:
errors.append(str(exc))

joined = "\n".join(f"- {pattern}" for pattern in patterns)
fail(
"failed to find a matching artifact for any of the expected patterns\n"
f"root: {root}\n"
f"patterns:\n{joined}"
)


def ensure_non_empty(path: Path) -> None:
if not path.is_file():
fail(f"expected file is missing: {path}")
Expand All @@ -48,7 +82,8 @@ def collect_windows(
release_dir: Path,
stage_dir: Path,
) -> list[str]:
msi_source = find_first(packager_out, "*.msi")
# Be tolerant to filename casing changes.
msi_source = find_first_any(packager_out, ["*.msi", "*.MSI"])
msi_target = release_dir / f"localpaste-{tag}-{asset_suffix}.msi"
shutil.copy2(msi_source, msi_target)

Expand All @@ -74,7 +109,9 @@ def collect_linux(
release_dir: Path,
stage_dir: Path,
) -> list[str]:
appimage_source = find_first(packager_out, "*.AppImage")
# cargo-packager historically produced ".AppImage" (capital A/I) but this has
# changed in other tooling ecosystems. Be resilient.
appimage_source = find_first_any(packager_out, ["*.AppImage", "*.appimage"])
appimage_target = release_dir / f"localpaste-{tag}-{asset_suffix}.AppImage"
shutil.copy2(appimage_source, appimage_target)

Expand All @@ -94,7 +131,7 @@ def collect_linux(


def collect_macos(tag: str, asset_suffix: str, packager_out: Path, release_dir: Path) -> list[str]:
dmg_source = find_first(packager_out, "*.dmg")
dmg_source = find_first_any(packager_out, ["*.dmg", "*.DMG"])
app_bundle = find_first(packager_out, "*.app", directories=True)

dmg_target = release_dir / f"localpaste-{tag}-{asset_suffix}.dmg"
Expand Down
108 changes: 72 additions & 36 deletions .github/scripts/release_gui_prepare.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env python3
"""Prepare release GUI packager config, staging, and Windows WiX environment."""
"""Prepare release GUI packager config, staging, and Windows WiX environment.

Design goals:
- Deterministic versioning: derive packager version from the release tag.
- Strict inputs: fail fast with actionable errors.
- Windows resilience: prefer a WiX installation that matches the expected major
version when multiple WiX installs exist on the runner.
"""

from __future__ import annotations

Expand All @@ -12,7 +19,7 @@
import subprocess
import sys
from pathlib import Path
from typing import Iterable
from typing import Iterable, Sequence


def fail(message: str) -> None:
Expand Down Expand Up @@ -48,7 +55,7 @@ def unique_paths(paths: Iterable[Path]) -> list[Path]:
return unique


def discover_wix_bin() -> Path:
def wix_candidate_dirs() -> list[Path]:
candidates: list[Path] = []

wix_root = os.environ.get("WIX")
Expand All @@ -71,55 +78,86 @@ def discover_wix_bin() -> Path:
if discovered:
candidates.append(Path(discovered).parent)

checked: list[Path] = []
for candidate in unique_paths(candidates):
checked.append(candidate)
if (candidate / "candle.exe").is_file() and (candidate / "light.exe").is_file():
return candidate

if checked:
joined = "\n".join(f"- {entry}" for entry in checked)
fail(
"failed to locate WiX bin directory containing candle.exe and light.exe\n"
f"checked candidate directories:\n{joined}"
)
return unique_paths(candidates)

fail("failed to locate WiX installation candidates")

def probe_wix_version(wix_bin: Path) -> str | None:
"""Return WiX version string if candle/light can be executed, else None."""

def validate_wix_tools(wix_bin: Path) -> str:
detected_version: str | None = None
version_pattern = re.compile(r"version\s+([0-9]+(?:\.[0-9]+){1,3})", re.IGNORECASE)

detected_version: str | None = None
for executable in ("candle.exe", "light.exe"):
command = str(wix_bin / executable)
result = subprocess.run([command, "-?"], capture_output=True, text=True, check=False)
command = wix_bin / executable
if not command.is_file():
return None
result = subprocess.run(
[str(command), "-?"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
fail(f"{command} failed self-check with exit code {result.returncode}")
return None
if detected_version is None:
output = f"{result.stdout}\n{result.stderr}"
match = version_pattern.search(output)
if match:
detected_version = match.group(1)

if detected_version is None:
fail("failed to parse WiX version from candle/light self-check output")

return detected_version


def validate_wix_major_version(version: str, expected_major: int) -> None:
def wix_major(version: str) -> int | None:
major_raw = version.split(".", 1)[0]
try:
major = int(major_raw)
except ValueError as exc:
fail(f"failed to parse WiX major version from '{version}': {exc}")
return int(major_raw)
except ValueError:
return None


def discover_wix_bin(expected_major: int) -> tuple[Path, str]:
"""Locate a WiX bin directory that matches expected_major.

if major != expected_major:
Runners can end up with multiple WiX installs (preinstalled + Chocolatey,
or WiX 3 + WiX 4). We should select the one that matches the expected major
version instead of taking the first match.
"""

candidates = wix_candidate_dirs()

valid: list[tuple[Path, str]] = []
checked: list[Path] = []

for candidate in candidates:
checked.append(candidate)
version = probe_wix_version(candidate)
if not version:
continue
valid.append((candidate, version))
major = wix_major(version)
if major == expected_major:
return candidate, version

if valid:
joined = "\n".join(
f"- {path} (version {version})" for path, version in valid
)
fail(
"found WiX installations, but none match the expected major version\n"
f"expected_major={expected_major}\n"
f"detected:\n{joined}"
)

if checked:
joined = "\n".join(f"- {entry}" for entry in checked)
fail(
f"unexpected WiX major version: detected={version} expected_major={expected_major}"
"failed to locate a usable WiX bin directory containing candle.exe and light.exe\n"
f"checked candidate directories:\n{joined}"
)

fail("failed to locate WiX installation candidates")


def resolve_icon_paths(config_dir: Path, icons: list[str]) -> list[Path]:
resolved: list[Path] = []
Expand Down Expand Up @@ -228,19 +266,17 @@ def main() -> int:

if runner_os == "macOS" and not any(icon.lower().endswith(".icns") for icon in icons):
fail("macOS packager config must include an .icns icon path")
if runner_os == "Windows" and not any(
icon.suffix.lower() == ".ico" for icon in resolved_icons
):

if runner_os == "Windows" and not any(icon.suffix.lower() == ".ico" for icon in resolved_icons):
fail("windows packager config must include at least one .ico icon path")

if runner_os == "Windows":
wix_bin = discover_wix_bin()
wix_version = validate_wix_tools(wix_bin)
validate_wix_major_version(wix_version, args.expected_wix_major)
wix_bin, wix_version = discover_wix_bin(args.expected_wix_major)
wix_root = wix_bin.parent
append_github_path(wix_bin)
append_github_env("WIX", str(wix_root))
append_github_env("WIX_VERSION_DETECTED", wix_version)
print(f"using WiX: {wix_bin} (version {wix_version})")

stage_runtime_binary(runner_os=runner_os, target=args.target, asset_suffix=args.asset_suffix)

Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/validate_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ def validate_release_job_shape(path: Path, data: dict[str, Any]) -> list[str]:
matrix = strategy.get("matrix") if isinstance(strategy, dict) else None
include = matrix.get("include") if isinstance(matrix, dict) else None
expected_matrix = {
("windows-latest", "x86_64-pc-windows-msvc"),
("windows-2022", "x86_64-pc-windows-msvc"),
("ubuntu-22.04", "x86_64-unknown-linux-gnu"),
("macos-14", "aarch64-apple-darwin"),
}
Expand Down
Loading