3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
217 lines
8.0 KiB
Python
217 lines
8.0 KiB
Python
# SPDX-License-Identifier: MIT
|
|
"""Regression test: the Thin AppImage recipe must not re-introduce the
|
|
podman stack (0.6.0 item A, #357 / #363 root-cause fix).
|
|
|
|
Bundling rootless podman + helpers (conmon / crun / netavark /
|
|
aardvark-dns / pasta / passt / slirp4netns / fuse-overlayfs) is what
|
|
caused #357 (Ubuntu 26.04 -- bundled podman-compose shadowed the host's
|
|
working stack) and #363 (Fedora Bluefin -- bundled libcrypto poisoned
|
|
host systemd-run / aardvark-dns via LD_LIBRARY_PATH). The Thin redesign
|
|
drops the entire container stack from the AppImage; this test guards
|
|
against a future PR silently re-adding it.
|
|
|
|
The AppImage build is driven by:
|
|
|
|
* ``.github/workflows/appimage-publish.yml`` -- the CI workflow whose
|
|
``dnf install -y ...`` line is the package include list.
|
|
* ``packaging/appimage/bundle-system-bins.sh`` -- the BINARIES array
|
|
that names the binaries copied out of the Fedora image into the
|
|
AppDir.
|
|
|
|
Both files are scanned. If a forbidden token appears in either, fail
|
|
with a pointer back to this docstring so the author understands the
|
|
constraint before adding an opt-out.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "appimage-publish.yml"
|
|
BUNDLE_SCRIPT = REPO_ROOT / "packaging" / "appimage" / "bundle-system-bins.sh"
|
|
|
|
# Packages / binaries that must NOT be bundled into the Thin AppImage.
|
|
# Order: stack core, then helpers. Each is matched as a whole word so
|
|
# substrings (e.g. ``podman-compose`` containing ``podman``) don't
|
|
# false-positive each other.
|
|
FORBIDDEN_PACKAGES = (
|
|
"podman",
|
|
"podman-compose",
|
|
"conmon",
|
|
"crun",
|
|
"netavark",
|
|
"aardvark-dns",
|
|
"passt",
|
|
"pasta",
|
|
"slirp4netns",
|
|
"fuse-overlayfs",
|
|
)
|
|
|
|
|
|
def _read(path: Path) -> str:
|
|
assert path.is_file(), f"expected file at {path}"
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def _scan_dnf_install_block(workflow_text: str) -> str:
|
|
"""Return the ``dnf install -y ...`` argument block from the workflow.
|
|
|
|
The workflow embeds the install line as part of a shell ``bash -c '...'``
|
|
payload, with each package on its own line. We slice out everything
|
|
between ``dnf install -y`` and the next ``echo`` line so a future
|
|
rewording of the surrounding shell doesn't break the scan.
|
|
"""
|
|
match = re.search(
|
|
r"dnf\s+install\s+-y(?P<args>.*?)(?:^\s*echo\b|^\s*rpm\b|^\s*bash\b)",
|
|
workflow_text,
|
|
re.DOTALL | re.MULTILINE,
|
|
)
|
|
if match is None:
|
|
# Workflow doesn't dnf install anything (CI re-architected) --
|
|
# nothing to scan, the test passes vacuously on this file.
|
|
return ""
|
|
return match.group("args")
|
|
|
|
|
|
def _scan_bundle_binaries_array(script_text: str) -> str:
|
|
"""Return the BINARIES=( ... ) block contents from bundle-system-bins.sh."""
|
|
match = re.search(
|
|
r"BINARIES=\((?P<body>.*?)\)",
|
|
script_text,
|
|
re.DOTALL,
|
|
)
|
|
if match is None:
|
|
return ""
|
|
return match.group("body")
|
|
|
|
|
|
def _tokens(block: str) -> set[str]:
|
|
"""Split a whitespace-separated argument block into bare tokens.
|
|
|
|
Ignores shell line-continuations, comments, and empty lines.
|
|
"""
|
|
out: set[str] = set()
|
|
for raw in block.splitlines():
|
|
# Strip in-line comments after ``#``
|
|
line = raw.split("#", 1)[0]
|
|
# Drop the line-continuation backslash
|
|
line = line.replace("\\", " ")
|
|
for tok in line.split():
|
|
out.add(tok)
|
|
return out
|
|
|
|
|
|
def test_workflow_dnf_install_has_no_podman_stack_packages():
|
|
text = _read(WORKFLOW)
|
|
block = _scan_dnf_install_block(text)
|
|
tokens = _tokens(block)
|
|
found = sorted(tokens.intersection(FORBIDDEN_PACKAGES))
|
|
assert not found, (
|
|
"Thin AppImage regression: "
|
|
f".github/workflows/appimage-publish.yml `dnf install -y` block reintroduced {found!r}. "
|
|
"See tests/test_appimage_recipe.py docstring -- bundling rootless podman "
|
|
"broke #357 and #363; 0.6.0 item A removed the stack."
|
|
)
|
|
|
|
|
|
def test_bundle_script_binaries_array_has_no_podman_stack():
|
|
text = _read(BUNDLE_SCRIPT)
|
|
block = _scan_bundle_binaries_array(text)
|
|
tokens = _tokens(block)
|
|
found = sorted(tokens.intersection(FORBIDDEN_PACKAGES))
|
|
assert not found, (
|
|
"Thin AppImage regression: "
|
|
f"packaging/appimage/bundle-system-bins.sh BINARIES=() reintroduced {found!r}. "
|
|
"See tests/test_appimage_recipe.py docstring -- bundling rootless podman "
|
|
"broke #357 and #363; 0.6.0 item A removed the stack."
|
|
)
|
|
|
|
|
|
def test_workflow_does_not_pip_install_podman_compose():
|
|
"""podman-compose was pip-installed into the bundled interpreter
|
|
(#322 workaround for ldd-bundling a pure-Python script). Thin drops
|
|
that too -- the host's podman-compose is used directly."""
|
|
text = _read(WORKFLOW)
|
|
assert "pip install --no-cache-dir podman-compose" not in text, (
|
|
"Thin AppImage regression: appimage-publish.yml re-introduced a "
|
|
"`pip install podman-compose` into the bundled interpreter. The Thin "
|
|
"AppImage requires host podman-compose; see tests/test_appimage_recipe.py."
|
|
)
|
|
assert "-m podman_compose" not in text, (
|
|
"Thin AppImage regression: appimage-publish.yml re-introduced a "
|
|
"podman-compose wrapper that runs ``python3 -m podman_compose``."
|
|
)
|
|
|
|
|
|
def test_forbidden_tokens_are_known():
|
|
"""Lock the forbidden list so a future expansion is deliberate."""
|
|
assert set(FORBIDDEN_PACKAGES) == {
|
|
"podman",
|
|
"podman-compose",
|
|
"conmon",
|
|
"crun",
|
|
"netavark",
|
|
"aardvark-dns",
|
|
"passt",
|
|
"pasta",
|
|
"slirp4netns",
|
|
"fuse-overlayfs",
|
|
}
|
|
|
|
|
|
# --- Qt6 slimming guard -------------------------------------------------
|
|
# packaging/appimage/slim-pyside6.sh strips the Qt6 modules winpodx never
|
|
# imports from the bundled PySide6 (QtWebEngine etc. -- the bulk of the
|
|
# AppImage). If it ever drops a module the GUI actually uses, the AppImage
|
|
# build would produce a binary that crashes on launch. Cross-check the
|
|
# script's DROP_MODULES against the modules src/winpodx actually imports.
|
|
|
|
SLIM_SCRIPT = REPO_ROOT / "packaging" / "appimage" / "slim-pyside6.sh"
|
|
SRC_DIR = REPO_ROOT / "src" / "winpodx"
|
|
|
|
|
|
def _used_qt_modules() -> set[str]:
|
|
"""Qt module suffixes imported anywhere in src (e.g. {'Core','Widgets'})."""
|
|
used: set[str] = set()
|
|
pat = re.compile(r"from\s+PySide6\.Qt(\w+)\s+import|import\s+PySide6\.Qt(\w+)")
|
|
for py in SRC_DIR.rglob("*.py"):
|
|
for m in pat.finditer(py.read_text(encoding="utf-8")):
|
|
used.add(m.group(1) or m.group(2))
|
|
return used
|
|
|
|
|
|
def _drop_modules() -> set[str]:
|
|
"""Qt module suffixes listed in slim-pyside6.sh's DROP_MODULES array."""
|
|
text = _read(SLIM_SCRIPT)
|
|
m = re.search(r"DROP_MODULES=\((?P<body>.*?)\)", text, re.DOTALL)
|
|
assert m is not None, "DROP_MODULES=( ... ) not found in slim-pyside6.sh"
|
|
return _tokens(m.group("body"))
|
|
|
|
|
|
def test_slim_does_not_drop_a_qt_module_winpodx_uses():
|
|
used = _used_qt_modules()
|
|
# Sanity: the five modules we expect winpodx to use are present.
|
|
assert {"Core", "Gui", "Widgets", "Svg", "DBus"} <= used, (
|
|
f"unexpected Qt import set in src/winpodx: {sorted(used)} -- update the "
|
|
"slim guard if the GUI legitimately gained/lost a Qt module."
|
|
)
|
|
clobbered = sorted(used & _drop_modules())
|
|
assert not clobbered, (
|
|
"slim-pyside6.sh DROP_MODULES would remove Qt module(s) winpodx imports: "
|
|
f"{clobbered}. The AppImage GUI would crash on launch. Remove them from "
|
|
"DROP_MODULES in packaging/appimage/slim-pyside6.sh."
|
|
)
|
|
|
|
|
|
def test_slim_actually_drops_the_heavy_unused_modules():
|
|
"""Guard the win: the giant unused modules must stay on the droplist."""
|
|
drop = _drop_modules()
|
|
for heavy in ("WebEngineCore", "Quick", "Qml", "Multimedia", "Pdf", "Charts"):
|
|
assert heavy in drop, (
|
|
f"slim-pyside6.sh no longer drops {heavy!r} -- the AppImage will "
|
|
"bloat back up. It's unused by winpodx; keep it on DROP_MODULES."
|
|
)
|