From bd7b624f50399652cc226a18554c69f449b411f0 Mon Sep 17 00:00:00 2001 From: soubarnak Date: Fri, 6 Nov 2026 05:57:37 +0530 Subject: [PATCH] feat(void): add package smoke gate across all templates 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. --- justfile | 7 ++ packaging/void/README.md | 34 +++++- packaging/void/container-main.sh | 48 +++++++- packaging/void/smoke.sh | 35 ++++++ packaging/void/srcpkgs/voculinux/template | 35 ++++-- tests/test_rename_sweep.py | 23 +++- tests/test_void_app_template.py | 37 ++++-- tests/test_void_smoke_gate.py | 138 ++++++++++++++++++++++ 8 files changed, 330 insertions(+), 27 deletions(-) create mode 100755 packaging/void/smoke.sh create mode 100644 tests/test_void_smoke_gate.py diff --git a/justfile b/justfile index 4ce40d5..8665f98 100644 --- a/justfile +++ b/justfile @@ -127,6 +127,13 @@ native-packages: build native-smoke distro="debian:12": docker run --rm -v "$PWD/dist:/out:ro" -v "$PWD/packaging/native:/pk:ro" {{distro}} bash /pk/smoke-test.sh +# Lint, build, install and smoke every Void template in the pinned Void +# container via podman (never Docker), as ticket #28 gates. Needs no host +# Void install; release work and CI call it the same way as the other +# packaging gates. +void-smoke: + bash packaging/void/smoke.sh + # Build the canonical sdist, prepare an explicitly unattested local PKGBUILD, # then build/install it unchanged on current Arch (needs uv and docker). # Keep distributions and diagnostics away from other packaging commands' outputs. diff --git a/packaging/void/README.md b/packaging/void/README.md index 500e6c4..ff9be12 100644 --- a/packaging/void/README.md +++ b/packaging/void/README.md @@ -11,6 +11,7 @@ repository first. packaging/void/ ├── README.md this file ├── build.sh podman harness: lint + xbps-src build + import smoke +├── smoke.sh ticket #28 gate: every template, one command ├── tool_checksums.txt pinned base image and void-packages commit └── srcpkgs/ ├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding @@ -68,9 +69,11 @@ Version `0.18.5`, matching `src/vocalinux/version.py` — never newest upstream. The template builds the canonical release source distribution (the `releases/download` sdist, the same bytes published to PyPI) with a pinned `checksum`; `tests/test_void_app_template.py` fails otherwise. -The 0.18.5 asset still carries the pre-rename archive root, so the -template sets `wrksrc` explicitly; the first release cut after the -rename drops the override and points `distfiles` at the renamed asset. +The 0.18.5 asset still carries the pre-rename archive root; xbps-src +moves the single top-level directory to `pkgname-version` itself, so no +`wrksrc` override is needed (`wrksrc` is deprecated and `xlint` rejects +it). The first release cut after the rename points `distfiles` at the +renamed asset. - **Runtime deps** are Void's own interpreter, GTK and desktop bindings (`python3`, `python3-gobject`, `python3-cairo`, `gtk+3`, @@ -85,7 +88,9 @@ rename drops the override and points `distfiles` at the renamed asset. or wheels. - **Renamed paths**: the `voculinux.desktop` entry, the hicolor icons, and a `usr/share/voculinux/packaged` marker the app reads to present - package-manager updates. + package-manager updates. The 0.18.5 sdist predates the rename, so + `post_install` promotes the old file names at install time; the payload + smoke rejects any pre-rename leftovers in the OS-visible paths. - **INSTALL.msg** covers what xbps cannot express: the `input` group for the evdev hotkey backend, first-run model downloads, optional engines as manual additions (vosk, OpenAI whisper, Parakeet, faster-whisper, @@ -105,4 +110,23 @@ packaging/void/build.sh voculinux Podman only, never Docker. The base image digest and the void-packages commit come from `tool_checksums.txt`; bump them there, never in the -script. Ticket #28 wires this into the package smoke gate. +script. + +## Smoke gate + +One command runs the whole gate from a clean checkout — no host Void +install — linting, building, installing and smoke-testing every +template under `srcpkgs` in dependency order (the app last): + +```sh +packaging/void/smoke.sh +# or: just void-smoke +``` + +Each package delegates to `build.sh`, so the pinned container and the +per-package stages (xlint, xbps-src, install, payload/version/import +smoke) stay implemented once. The gate fails fast: a broken package +prints `FAIL: ` naming which package broke, above it the +harness echoes which stage broke. `tests/test_void_smoke_gate.py` +asserts offline that the gate stays wired to every `srcpkgs` template, +so a renamed or added template cannot silently drop out. diff --git a/packaging/void/container-main.sh b/packaging/void/container-main.sh index 2fbfa87..930bc94 100755 --- a/packaging/void/container-main.sh +++ b/packaging/void/container-main.sh @@ -52,11 +52,40 @@ voculinux) exit 1 fi echo "no models or wheels in the shipped package" + echo "== payload $PKGNAME: no pre-rename leftovers" + if tar -tf "$PKGFILE" 2>/dev/null | grep -E "usr/share/applications/vocalinux|usr/share/icons/.*/vocalinux"; then + echo "pre-rename file names in the OS-visible payload" >&2 + exit 1 + fi + echo "no pre-rename leftovers in the shipped package" echo "== version and import smoke $PKGNAME" voculinux --version - python3 -c "from importlib.metadata import version; print('voculinux ' + version('voculinux'))" + cat > /voculinux-smoke.py <<'PYEOF' +# The 0.18.5 sdist predates the rename: the importable top-level package +# and the distribution metadata still carry the old name. The first +# release cut after the rename moves these to voculinux. +import importlib + +MODULES = ( + "vocalinux.main", + "vocalinux.ui.tray_indicator", + "vocalinux.speech_recognition.recognition_manager", + "vocalinux.speech_recognition.command_processor", + "vocalinux.text_injection.text_injector", + "vocalinux.text_injection.ibus_engine", + "vocalinux.ui.keyboard_backends", + "pywhispercpp.model", +) +for module in MODULES: + importlib.import_module(module) +print("import-smoke OK:", ", ".join(MODULES)) + +from importlib.metadata import version +print("vocalinux", version("vocalinux")) +PYEOF + python3 /voculinux-smoke.py ;; -*) +python3-pywhispercpp) echo "== compiler flag scan of the shipped ggml library" LIBMEMBER=$(tar -tf "$PKGFILE" 2>/dev/null | grep -E "libggml-cpu\.so" | head -n1) [ -n "$LIBMEMBER" ] || { @@ -98,4 +127,19 @@ print("smoke ok") PYEOF EXPECT_VULKAN="$EXPECT_VULKAN" python3 /smoke.py ;; +python3-PyAudio) + echo "== import smoke $PKGNAME" + python3 -c "import pyaudio; print('pyaudio', pyaudio.__version__)" + ;; +python3-pynput) + # pynput selects a display backend at import and raises without one; + # the container has no display, so presence plus the installed + # version (the same proof the native smoke accepts) is the smoke. + echo "== import smoke $PKGNAME" + python3 -c "import importlib.metadata, importlib.util; assert importlib.util.find_spec('pynput'), 'pynput not importable'; print('pynput', importlib.metadata.version('pynput'))" + ;; +*) + echo "no smoke recipe for package '$PKGNAME' — add one alongside the template" >&2 + exit 1 + ;; esac diff --git a/packaging/void/smoke.sh b/packaging/void/smoke.sh new file mode 100755 index 0000000..ae4ba7d --- /dev/null +++ b/packaging/void/smoke.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Smoke gate for all Void templates (ticket #28): lint, build, install +# and version/import smoke for every template, one command, from a clean +# checkout. Runs under podman, never Docker; no host Void install needed. +# +# Usage: packaging/void/smoke.sh +# or: just void-smoke +# +# Builds every template under packaging/void/srcpkgs in dependency order +# (leaves first, the app last — it resolves the others from the local +# repository at install time) by delegating each package to build.sh, +# which runs xlint, xbps-src, install and the payload/version/import +# smoke in the pinned Void container. Fails fast naming the package; the +# harness echoes each stage, so the output shows which package broke at +# which stage. +set -eu + +CDPATH="" +HERE=$(dirname -- "$0") +HERE=$(cd -- "$HERE" && pwd) + +# Dependency order: the app last. tests/test_void_smoke_gate.py fails if +# this list drifts from packaging/void/srcpkgs or the app moves earlier. +PKGS="python3-PyAudio python3-pynput python3-pywhispercpp voculinux" + +for PKG in $PKGS; do + echo "== smoke $PKG" + "$HERE/build.sh" "$PKG" || { + echo "FAIL: $PKG (see the stage above: xlint, xbps-src, install, payload/version/import smoke)" >&2 + exit 1 + } + echo "OK: $PKG linted, built, installed and smoked" +done + +echo "SMOKE GATE PASSED: $PKGS" diff --git a/packaging/void/srcpkgs/voculinux/template b/packaging/void/srcpkgs/voculinux/template index 821f5ce..9871159 100644 --- a/packaging/void/srcpkgs/voculinux/template +++ b/packaging/void/srcpkgs/voculinux/template @@ -2,7 +2,9 @@ pkgname=voculinux version=0.18.5 revision=1 -build_style=python3-module +# The release sdist ships no setup.py (setuptools pyproject-only), so the +# build goes through the PEP 517 wheel (cf. Solaar, PhotoCollage). +build_style=python3-pep517 hostmakedepends="python3-setuptools" makedepends="python3-devel" depends="python3 python3-gobject python3-cairo gtk+3 libayatana-appindicator ibus gobject-introspection python3-numpy python3-requests python3-pysocks python3-psutil python3-evdev python3-xlib python3-tqdm python3-platformdirs python3-PyAudio python3-pynput python3-pywhispercpp" @@ -12,21 +14,34 @@ license="AGPL-3.0-only" homepage="https://github.com/VocaHQ/vocalinux" distfiles="https://github.com/VocaHQ/vocalinux/releases/download/v${version}/vocalinux-${version}.tar.gz" checksum=1def4d8f0f14f2df2f2fee64ca29e6865e3d82e551f7d465c2a78ec6d4d72b1d -# The 0.18.5 release asset still carries the pre-rename archive root, so -# build under that directory. The first release cut after the rename ships -# the archive under the new name; that bump drops this override and points -# distfiles at the renamed asset with its published digest. -wrksrc="vocalinux-${version}" +# The 0.18.5 release asset still carries the pre-rename archive root. +# xbps-src moves a single-top-dir archive to pkgname-version itself, so no +# wrksrc override is needed (wrksrc is deprecated and xlint rejects it). +# The first release cut after the rename ships the archive under the new +# name; that bump points distfiles at the renamed asset with its digest. post_install() { - # Entry points land on PATH through the module build; install the - # renamed desktop entry, the full icon set, and the marker the app - # reads to present package-manager updates (see INSTALL.msg). + # The 0.18.5 sdist predates the rename: the entry and icons still + # carry the old names. Promote them at install time — the entry lands + # as voculinux.desktop with rewritten Name/Exec/Icon, old-prefixed + # icons land renamed (vinstall takes the installed name, as vman + # uses it). The first release cut after the rename ships the new + # names; that bump drops the renames. Entry points land on PATH + # through the wheel build; the marker is what the app reads to + # present package-manager updates (see INSTALL.msg). vlicense LICENSE + sed -e 's/vocalinux/voculinux/g' -e 's/Vocalinux/Voculinux/g' \ + vocalinux.desktop > voculinux.desktop vinstall voculinux.desktop 644 usr/share/applications for icon in resources/icons/scalable/*.svg; do - vinstall "$icon" 644 usr/share/icons/hicolor/scalable/apps + vinstall "$icon" 644 usr/share/icons/hicolor/scalable/apps \ + "$(basename "$icon" | sed 's/^vocalinux/voculinux/')" done + # The wheel's entry points still carry the old names: promote the + # launchers (this runs before the provides hooks, so cmd:voculinux + # is what the package provides). + mv "${DESTDIR}/usr/bin/vocalinux" "${DESTDIR}/usr/bin/voculinux" + mv "${DESTDIR}/usr/bin/vocalinux-gui" "${DESTDIR}/usr/bin/voculinux-gui" vmkdir usr/share/voculinux touch "${DESTDIR}/usr/share/voculinux/packaged" } diff --git a/tests/test_rename_sweep.py b/tests/test_rename_sweep.py index de8704a..c07a725 100644 --- a/tests/test_rename_sweep.py +++ b/tests/test_rename_sweep.py @@ -22,7 +22,17 @@ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) OLD_NAME = re.compile(r"vocalinux", re.IGNORECASE) -SKIP_DIRS = {".git", "__pycache__", ".venv", "venv", "node_modules", ".next", "build", "dist", "out"} +SKIP_DIRS = { + ".git", + "__pycache__", + ".venv", + "venv", + "node_modules", + ".next", + "build", + "dist", + "out", +} SKIP_SUFFIXES = (".egg-info",) SKIP_FILES = {"uv.lock", "CHANGELOG.md"} SKIP_PREFIXES = ("requirements/",) @@ -129,6 +139,17 @@ def _allowed(path: str, line: str) -> bool: if path == "tests/test_void_app_template.py" and '"vocalinux"' in line: # The app-template policy test asserts the old name is gone. return True + if path == "tests/test_void_app_template.py" and "usr/bin/vocalinux" in line: + # Pre-rename sdist launchers the template promotes at install time + # (ticket #28); dropped at the first post-rename release bump. + return True + if path == "packaging/void/container-main.sh" and ( + "usr/share/applications/vocalinux" in line or 'version("vocalinux")' in line + ): + # Pre-rename sdist compat (ticket #28): the payload guard names the + # old file names it rejects, and the import smoke queries the old + # distribution name. Dropped at the first post-rename release bump. + return True if '== "vocalinux"' in line or '.startswith("vocalinux' in line: # Retained module identity comparisons in test isolation helpers. return True diff --git a/tests/test_void_app_template.py b/tests/test_void_app_template.py index dfeb7c9..934462c 100644 --- a/tests/test_void_app_template.py +++ b/tests/test_void_app_template.py @@ -194,7 +194,9 @@ def test_mandatory_void_variables_are_present() -> None: "checksum", ): assert variables.get(key), f"template is missing {key}" - assert variables["build_style"] == "python3-module" + # The release sdist ships no setup.py (setuptools pyproject-only), so + # xbps-src must build the PEP 517 wheel, not run setup.py directly. + assert variables["build_style"] == "python3-pep517" assert "python3-setuptools" in variables["hostmakedepends"].split() assert "python3-devel" in variables["makedepends"].split() assert variables["revision"] == "1", "new templates start at revision 1" @@ -228,13 +230,16 @@ def test_distfiles_sources_the_canonical_release_by_checksum() -> None: for moving in ("/master", "/main", "/archive/", "git+"): assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}" assert re.fullmatch(r"[0-9a-f]{64}", variables["checksum"]), "checksum must be sha256" - # The unpacked top-level directory must be where the template builds: - # wrksrc defaults to pkgname-version, so a distfile rooted under the - # pre-rename archive name needs an explicit wrksrc override. + # The unpacked top-level directory must be where the template builds. + # xbps-src moves a single-top-dir archive to pkgname-version itself + # (common/hooks/do-extract/00-distfiles.sh), so the pre-rename archive + # root needs no override — and wrksrc is deprecated, xlint rejects it. root = Path(distfiles.rsplit("/", 1)[1].replace("${version}", version)) assert root.suffixes[-2:] == [".tar", ".gz"] - expected_wrksrc = variables.get("wrksrc", f"{variables['pkgname']}-${{version}}") - assert expected_wrksrc.replace("${version}", version) == root.name[: -len(".tar.gz")] + assert ( + root.name[: -len(".tar.gz")] != f"{variables['pkgname']}-{version}" + ), "the release asset is still rooted under the pre-rename archive name" + assert "wrksrc" not in variables, "deprecated wrksrc override: xlint fails the template" def test_every_project_dependency_is_covered() -> None: @@ -292,15 +297,22 @@ def test_package_ships_no_speech_models() -> None: assert "model" not in code, "models are not package payload" -def test_post_install_uses_the_renamed_paths() -> None: - """Desktop entry, icons, license and the packaged-install marker install.""" +def test_post_install_installs_the_renamed_paths_from_the_old_sdist() -> None: + """The 0.18.5 sdist predates the rename, so post_install promotes the + old names at install time: the entry lands as voculinux.desktop with + rewritten Name/Exec/Icon, and old-prefixed icons land renamed.""" body = _post_install_body() assert "vlicense LICENSE" in body + assert "vocalinux.desktop" in body, "the sdist entry still carries the old name" assert "voculinux.desktop" in body and "usr/share/applications" in body + assert "s/Vocalinux/Voculinux/" in body, "the entry Name must be rewritten" + assert "s/vocalinux/voculinux/" in body, "the entry Exec/Icon must be rewritten" assert "resources/icons/scalable" in body assert "usr/share/icons/hicolor/scalable/apps" in body + assert "s/^vocalinux/voculinux/" in body, "old-prefixed icons must land renamed" + assert "usr/bin/vocalinux" in body, "the sdist launchers still carry the old names" + assert "usr/bin/voculinux" in body, "the launchers must land under the new names" assert "usr/share/voculinux/packaged" in body - assert "vocalinux" not in body, "post_install must use only the renamed install paths" def test_build_fetches_nothing_beyond_the_sdist() -> None: @@ -353,9 +365,16 @@ def test_container_harness_covers_the_app_package() -> None: assert "docker" not in build.lower().replace("never docker", "") harness = CONTAINER_MAIN.read_text(encoding="utf-8") assert "voculinux --version" in harness, "the app smoke checks the entry point" + assert "vocalinux.ui.tray_indicator" in harness, "the smoke imports the desktop modules" + assert ( + "vocalinux.speech_recognition.recognition_manager" in harness + ), "the smoke imports the speech modules" assert "usr/share/applications/voculinux.desktop" in harness assert "usr/share/icons/hicolor/scalable/apps/voculinux.svg" in harness assert "usr/share/voculinux/packaged" in harness + assert ( + "no pre-rename leftovers" in harness + ), "the payload check must reject pre-rename file names in the package" assert "python3-pywhispercpp" in harness or "PKGNAME" in harness diff --git a/tests/test_void_smoke_gate.py b/tests/test_void_smoke_gate.py new file mode 100644 index 0000000..fb8c38e --- /dev/null +++ b/tests/test_void_smoke_gate.py @@ -0,0 +1,138 @@ +"""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"