Merge feature/28 into integration/spec16 (ticket #28: Void package smoke gate)

This commit is contained in:
soubarnak
2026-11-06 05:57:40 +05:30
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"