Ticket xavierk/voculinux#28, needs #27. One documented command (packaging/void/smoke.sh, just void-smoke) lints, builds, installs and smoke-tests every template under packaging/void/srcpkgs in dependency order in the pinned Void container via podman, failing fast naming the package with the harness echoing each stage. The gate caught three latent breaks in the per-package harness and the app template, fixed here: - container-main.sh scoped the libggml scan to python3-pywhispercpp and gave the leaf packages their own import smokes (pynput by presence: no display in the container), failing loudly for packages without a smoke recipe; - voculinux template drops the deprecated wrksrc override (xbps-src renames the single-top-dir archive itself; xlint rejects wrksrc) and builds the PEP 517 wheel (the sdist ships no setup.py); - post_install promotes the pre-rename sdist names (desktop entry content, icons, /usr/bin launchers) to the renamed identity, and the payload smoke rejects pre-rename leftovers in OS-visible paths. Offline policy tests (tests/test_void_smoke_gate.py) pin the gate to the template set, the app-last order, podman-only pinned inputs and per-package failure identity; the #27 policy tests and the rename sweep cover the harness and template contracts.
139 lines
5.5 KiB
Python
139 lines
5.5 KiB
Python
"""Offline policy guards for the Void package smoke gate (ticket #28).
|
|
|
|
Spec #16 gives the packaged artefact the same treatment as the other
|
|
distribution formats: one documented command runs lint, build, install
|
|
and version/import smoke for every Void template from a clean checkout,
|
|
using podman and the pinned container, with no host Void install.
|
|
|
|
Seams under test (public interfaces, not internals):
|
|
|
|
- ``packaging/void/smoke.sh`` — the gate command itself: exit status
|
|
plus package/stage identification on stdout/stderr.
|
|
- The ``void-smoke`` just recipe — how release work and CI call the
|
|
gate, same shape as the other packaging gates.
|
|
- ``packaging/void/README.md`` — the documented one-command entry.
|
|
|
|
Independent sources of truth: ``packaging/void/srcpkgs/*/template``
|
|
owns the template set (a renamed or added template must not silently
|
|
drop out), ``packaging/void/tool_checksums.txt`` owns the pins, and
|
|
``packaging/void/build.sh`` owns the per-package container flow the
|
|
gate orchestrates. The gate must agree with all of them; the container
|
|
run itself is the proof, these tests are the wiring guard.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
VOID = REPO_ROOT / "packaging" / "void"
|
|
SRC = VOID / "srcpkgs"
|
|
SMOKE_SH = VOID / "smoke.sh"
|
|
BUILD_SH = VOID / "build.sh"
|
|
CONTAINER_MAIN = VOID / "container-main.sh"
|
|
PINS = VOID / "tool_checksums.txt"
|
|
JUSTFILE = REPO_ROOT / "justfile"
|
|
README = VOID / "README.md"
|
|
|
|
|
|
def _srcpkg_templates() -> list:
|
|
"""Every template directory under srcpkgs, in dependency build order."""
|
|
return sorted(p.name for p in SRC.iterdir() if (p / "template").is_file())
|
|
|
|
|
|
def _smoke_text() -> str:
|
|
assert SMOKE_SH.is_file(), f"missing Void smoke gate: {SMOKE_SH}"
|
|
return SMOKE_SH.read_text(encoding="utf-8")
|
|
|
|
|
|
def _gate_packages() -> list:
|
|
"""The ordered package list the gate builds, parsed from smoke.sh."""
|
|
match = re.search(r'^PKGS="([^"]+)"', _smoke_text(), re.M)
|
|
assert match, "smoke.sh must declare its ordered PKGS list"
|
|
return match.group(1).split()
|
|
|
|
|
|
def test_gate_builds_every_template_and_nothing_else() -> None:
|
|
"""A renamed or added template cannot silently drop out of the smoke."""
|
|
assert _gate_packages() and set(_gate_packages()) == set(
|
|
_srcpkg_templates()
|
|
), f"gate covers {_gate_packages()}, srcpkgs holds {_srcpkg_templates()}"
|
|
|
|
|
|
def test_gate_builds_the_app_last() -> None:
|
|
"""voculinux resolves the other templates from the local repository."""
|
|
assert (
|
|
_gate_packages()[-1] == "voculinux"
|
|
), f"the app must build last, gate order is {_gate_packages()}"
|
|
|
|
|
|
def test_gate_delegates_to_the_per_package_harness() -> None:
|
|
"""One container flow implementation; the gate only orchestrates it."""
|
|
assert "build.sh" in _smoke_text(), "smoke.sh must call build.sh per package"
|
|
|
|
|
|
def test_gate_stays_podman_only_with_pinned_inputs() -> None:
|
|
"""Podman, never Docker; pins live in tool_checksums.txt, not the gate."""
|
|
text = _smoke_text()
|
|
assert "podman" in text, "the smoke gate must use podman"
|
|
assert "docker" not in text.lower().replace("never docker", "")
|
|
assert "ghcr.io" not in text, "the base image pin lives in tool_checksums.txt"
|
|
assert PINS.is_file(), f"missing pins file: {PINS}"
|
|
|
|
|
|
def test_gate_names_the_failing_package() -> None:
|
|
"""Output identifies which package failed, alongside the stage echoes."""
|
|
text = _smoke_text()
|
|
assert "FAIL" in text, "the gate must report failures as FAIL"
|
|
assert re.search(
|
|
r"FAIL.*\$PKG|FAIL.*\$\{PKG", text
|
|
), "the FAIL report must name the failing package"
|
|
|
|
|
|
def _case_branch(label: str) -> str:
|
|
"""Shell lines of one case branch in container-main.sh."""
|
|
text = CONTAINER_MAIN.read_text(encoding="utf-8")
|
|
start = text.index(f"\n{label})")
|
|
end = text.index(";;", start)
|
|
return text[start:end]
|
|
|
|
|
|
def test_harness_scopes_the_ggml_scan_to_the_binding() -> None:
|
|
"""Only the whisper binding ships libggml; leaves must not need it."""
|
|
assert "libggml-cpu" in _case_branch("python3-pywhispercpp")
|
|
assert "libggml-cpu" not in _case_branch("*")
|
|
|
|
|
|
def test_harness_smokes_the_leaf_packages() -> None:
|
|
"""PyAudio imports headless; pynput needs a display, so prove presence."""
|
|
assert "import pyaudio" in _case_branch("python3-PyAudio")
|
|
branch = _case_branch("python3-pynput")
|
|
assert "pynput" in branch
|
|
assert "find_spec" in branch or "version(" in branch
|
|
|
|
|
|
def test_harness_rejects_packages_without_a_smoke_recipe() -> None:
|
|
"""A future template falls into *) and must fail loudly, never pass mute."""
|
|
branch = _case_branch("*")
|
|
assert "exit 1" in branch
|
|
|
|
|
|
def test_just_recipe_calls_the_gate_like_other_packaging_gates() -> None:
|
|
"""`just void-smoke` runs the gate script with no shared-output step."""
|
|
recipe = re.search(
|
|
r"^void-smoke:([^\n]*)\n((?:[ \t].*\n|\n)+)",
|
|
JUSTFILE.read_text(encoding="utf-8"),
|
|
re.MULTILINE,
|
|
)
|
|
assert recipe, "justfile must define a void-smoke recipe"
|
|
assert not recipe.group(1).strip(), "void-smoke must not take a shared-output prerequisite"
|
|
assert "packaging/void/smoke.sh" in recipe.group(
|
|
2
|
|
), "void-smoke must run packaging/void/smoke.sh"
|
|
|
|
|
|
def test_readme_documents_the_one_command_gate() -> None:
|
|
"""AC1: one documented command from a clean checkout, no host Void."""
|
|
text = README.read_text(encoding="utf-8")
|
|
assert "void-smoke" in text or "smoke.sh" in text, "README must document the smoke gate command"
|
|
assert "podman" in text.lower(), "README must state the gate uses podman"
|