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.
This commit is contained in:
soubarnak
2026-11-06 05:57:37 +05:30
parent edaa3a57d3
commit bd7b624f50
8 changed files with 330 additions and 27 deletions
+7
View File
@@ -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.
+29 -5
View File
@@ -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: <pkgname>` 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.
+46 -2
View File
@@ -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
+35
View File
@@ -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"
+25 -10
View File
@@ -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"
}
+22 -1
View File
@@ -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
+28 -9
View File
@@ -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
+138
View File
@@ -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"