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"