diff --git a/packaging/void/README.md b/packaging/void/README.md new file mode 100644 index 0000000..516484f --- /dev/null +++ b/packaging/void/README.md @@ -0,0 +1,73 @@ +# Void packaging + +Void Linux (glibc, x86_64) templates in void-packages layout, built with the +standard Void source tooling. Submission to upstream void-packages is out of +scope for spec #16; these templates serve the signed personal xbps +repository first. + +## Layout + +``` +packaging/void/ +├── README.md this file +├── build.sh podman harness: lint + xbps-src build + import smoke +├── tool_checksums.txt pinned base image and void-packages commit +└── srcpkgs/ + ├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding + ├── ... ticket #25: small Python dependencies + └── voculinux ticket #27: the application +``` + +`srcpkgs/` mirrors void-packages so the tree overlays cleanly: +`cp -r packaging/void/srcpkgs/. /path/to/void-packages/srcpkgs/` +(`build.sh` does this inside the container.) + +## Templates + +| Template | Ticket | Notes | +|---|---|---| +| `python3-pywhispercpp` | #26 | Vulkan backend on by default; see below | + +## python3-pywhispercpp + +Version `1.5.0`, pinned to the same version `install.sh` +(`PYWHISPERCPP_VERSION`) and `uv.lock` pin — never newest upstream. +The sdist checksum in the template must equal the `sha256` uv.lock records +for the sdist; `tests/test_void_pywhispercpp_template.py` fails otherwise. + +- **Vulkan** is a build option, on by default. Rebuild CPU-only with + `build.sh python3-pywhispercpp -- -o ~vulkan pkg`. There is no CUDA + build: Void ships no CUDA toolkit package. Rebuilding locally with a + hand-installed toolkit (`GGML_CUDA=1`) is a documented, unsupported + escape hatch. +- **Portable CPU flags**: every ggml SIMD option is pinned off + (`-DGGML_NATIVE=OFF -DGGML_SSE42=OFF ...`), the plain x86-64 baseline + already used by the Flatpak (`#938`, `#957`). The offline test asserts + each pin in the template (the build configuration), and the harness + scans the shipped `libggml-cpu` (`.GCC.command.line`, `.comment`) for + recorded `-march=native`/`-mcpu=native`/`-mtune=native` flags and fails + on a match. The recorded-flags scan is a tripwire, not a proof: the + proof is the pinned configuration plus `setup.py`'s documented + `CMAKE_ARGS` passthrough. +- **Shared libraries**: `BUILD_SHARED_LIBS=ON` with an `$ORIGIN` rpath + (as `install.sh` does), so the extension resolves the whisper.cpp + libraries installed beside it with no `LD_LIBRARY_PATH`. +- **Offline build**: the sdist vendors whisper.cpp, pybind11, and the + ggml sources; the build fetches nothing beyond the declared distfiles. + `PYWHISPERCPP_VERSION` is injected because the sdist carries no + `version.txt` (its version otherwise comes from git metadata). +- **Runtime deps** (`python3-numpy`, `python3-requests`, `python3-tqdm`, + `python3-platformdirs`) are the sdist's own `install_requires` and were + verified to exist in current void-packages; the container build + re-verifies via dependency resolution. + +## Building + +```sh +packaging/void/build.sh python3-pywhispercpp +packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg +``` + +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. diff --git a/packaging/void/build.sh b/packaging/void/build.sh new file mode 100755 index 0000000..3c8edbd --- /dev/null +++ b/packaging/void/build.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Host-side harness for packaging/void: runs the container flow (see +# container-main.sh and container-build.sh) in the pinned Void glibc +# container via podman, never Docker. +# +# Usage: packaging/void/build.sh [-- ] +# +# packaging/void/build.sh python3-pywhispercpp +# packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg +# +# Anything after -- is passed to `xbps-src` before the package name (use it +# for -o ~vulkan CPU-only rebuilds). The package build itself fetches +# nothing from the network beyond the template's declared distfiles: +# xbps-src verifies distfile checksums before unpacking. +set -eu + +CDPATH="" +HERE=$(dirname -- "$0") +HERE=$(cd -- "$HERE" && pwd) +REPO_ROOT=$(cd -- "$HERE/../.." && pwd) +PKGNAME=${1:?usage: build.sh "" [-- ""]} +shift +if [ "${1:-}" = "--" ]; then + shift +fi +XBPS_ARGS=${*:-pkg} + +BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt") +VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt") +[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || { + echo "build.sh: pins missing from tool_checksums.txt" >&2 + exit 1 +} +case "$XBPS_ARGS" in +*~vulkan*) EXPECT_VULKAN=0 ;; +*) EXPECT_VULKAN=1 ;; +esac + +echo "Pulling pinned base image: $BASE_IMAGE" +podman pull "$BASE_IMAGE" + +podman run --rm \ + -v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \ + -v "$HERE/container-main.sh:/mnt/container-main.sh:ro" \ + -v "$HERE/container-build.sh:/mnt/container-build.sh:ro" \ + -e "VOID_COMMIT=$VOID_COMMIT" \ + -e "PKGNAME=$PKGNAME" \ + -e "XBPS_ARGS=$XBPS_ARGS" \ + -e "EXPECT_VULKAN=$EXPECT_VULKAN" \ + "$BASE_IMAGE" /bin/sh /mnt/container-main.sh +echo "OK: $PKGNAME built and imported cleanly" diff --git a/packaging/void/container-build.sh b/packaging/void/container-build.sh new file mode 100755 index 0000000..5e5e8da --- /dev/null +++ b/packaging/void/container-build.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Builder-side half of packaging/void/build.sh. Runs inside the pinned Void +# container as the unprivileged builder user (xbps-src refuses root). +# +# Not executed directly: build.sh mounts the repo's srcpkgs next to this +# file, copies it into the container, and runs it after cloning +# void-packages at the pinned commit. PKGNAME and XBPS_ARGS arrive via the +# environment. Keeping this a real file (instead of an inline `sh -c` +# string) means normal quoting, heredocs, and shellcheck apply. +set -eu + +export HOME=/home/builder +export PATH=/usr/bin:/bin:/usr/sbin:/sbin +cd /void-packages + +echo "== xlint $PKGNAME" +xlint "srcpkgs/$PKGNAME/template" + +echo "== show-options $PKGNAME" +./xbps-src show-options "$PKGNAME" || true + +echo "== xbps-src $XBPS_ARGS $PKGNAME" +# shellcheck disable=SC2086 +./xbps-src $XBPS_ARGS "$PKGNAME" diff --git a/packaging/void/container-main.sh b/packaging/void/container-main.sh new file mode 100755 index 0000000..635e525 --- /dev/null +++ b/packaging/void/container-main.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# Root-side half of the packaging/void container harness. Runs inside the +# pinned Void container as root: system setup, the void-packages checkout, +# then the builder half (container-build.sh) as the builder user, then the +# install plus import smoke as root again. +# +# Not executed directly: build.sh mounts this file and runs it. PKGNAME, +# XBPS_ARGS, EXPECT_VULKAN and VOID_COMMIT arrive via the environment. +set -eu + +export PATH=/usr/bin:/bin:/usr/sbin:/sbin +xbps-install -Suy xbps >/dev/null +xbps-install -y git base-devel xtools binutils zstd >/dev/null +git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages +git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT" +git -C /void-packages checkout --quiet "$VOID_COMMIT" +cp -r /overlay/. /void-packages/srcpkgs/ +id builder >/dev/null 2>&1 || useradd -m builder +cp /mnt/container-build.sh /run-build.sh +chown builder:builder /void-packages /run-build.sh +chmod +x /run-build.sh +su -s /bin/sh builder -c /run-build.sh + +echo "== install $PKGNAME from the local repository" +PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKGNAME"-*.x86_64.xbps /host/binpkgs/"$PKGNAME"-*.x86_64.xbps 2>/dev/null | head -n1) +[ -n "$PKGFILE" ] || { + echo "built package not found" >&2 + exit 1 +} +echo "built: $PKGFILE" +xbps-install -Sy >/dev/null +xbps-install -y --repository="$(dirname "$PKGFILE")" "$PKGNAME" >/dev/null + +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" ] || { + echo "libggml-cpu missing from the built package" >&2 + exit 1 +} +echo "member: $LIBMEMBER" +mkdir -p /flagscan +tar -xf "$PKGFILE" -C /flagscan "$LIBMEMBER" +LIBSCAN="/flagscan/${LIBMEMBER#./}" +echo "-- .GCC.command.line:" +readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null || echo "(section absent)" +echo "-- .comment:" +readelf -p .comment "$LIBSCAN" 2>/dev/null || echo "(section absent)" +if readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null | grep -E "march=native|mcpu=native|mtune=native"; then + echo "host-native CPU tuning in the shipped library" >&2 + exit 1 +fi +echo "no host-native tuning in the shipped library" + +echo "== import smoke $PKGNAME" +python3 -c "from pywhispercpp.model import Model; print('model submodule ok')" +cat > /smoke.py <<'PYEOF' +import glob +import os +import pywhispercpp +pkgdir = os.path.dirname(pywhispercpp.__file__) +sitedir = os.path.dirname(pkgdir) +libs = sorted(os.path.basename(p) for p in glob.glob(sitedir + "/lib*.so*")) +print("shipped libraries:", libs) +assert any(n.startswith("libwhisper") for n in libs), "libwhisper missing" +exts = [n for n in os.listdir(sitedir) if n.startswith("_pywhispercpp") and n.endswith(".so")] +assert exts, "compiled extension missing" +print("extension:", exts) +has_vulkan = any("vulkan" in n for n in libs) +want_vulkan = bool(int(os.environ["EXPECT_VULKAN"])) +assert has_vulkan == want_vulkan, "vulkan backend present=%s, expected=%s" % (has_vulkan, want_vulkan) +print("smoke ok") +PYEOF +EXPECT_VULKAN="$EXPECT_VULKAN" python3 /smoke.py diff --git a/packaging/void/srcpkgs/python3-pywhispercpp/template b/packaging/void/srcpkgs/python3-pywhispercpp/template new file mode 100644 index 0000000..602ca6f --- /dev/null +++ b/packaging/void/srcpkgs/python3-pywhispercpp/template @@ -0,0 +1,47 @@ +# Template file for 'python3-pywhispercpp' +pkgname=python3-pywhispercpp +version=1.5.0 +revision=1 +build_style=python3-module +hostmakedepends="cmake pkg-config python3-setuptools python3-wheel $(vopt_if vulkan shaderc)" +makedepends="python3-devel $(vopt_if vulkan vulkan-loader-devel)" +depends="python3-numpy python3-requests python3-tqdm python3-platformdirs $(vopt_if vulkan vulkan-loader)" +short_desc="Python bindings for whisper.cpp speech recognition" +maintainer="Orphaned " +license="MIT" +homepage="https://github.com/absadiki/pywhispercpp" +distfiles="${PYPI_SITE}/p/pywhispercpp/pywhispercpp-${version}.tar.gz" +checksum=5aa54c73ec96617d97aa519dbbf817e44255eddd9bd52ee1243be61028dbd018 +# The sdist ships no test suite, so there is nothing to check. +make_check=no + +# Package build options +build_options="vulkan" +build_options_default="vulkan" + +pre_build() { + # The sdist derives its version from git metadata, which is absent in + # the xbps build root, so inject the pinned version explicitly. This is + # the same version install.sh (PYWHISPERCPP_VERSION) and uv.lock pin. + export PYWHISPERCPP_VERSION=${version} + # Portable x86-64 baseline: GGML_NATIVE is ggml's default and compiles + # libggml-cpu with -march=native, so the package would inherit the + # builder's instruction set and SIGILL on older CPUs (#938). + # GGML_NATIVE=OFF alone is not enough: while the native default is on, + # ggml turns every GGML_* SIMD option on and still compiles -mavx2 + # -mfma (#957). Each option is pinned off, mirroring the Flatpak build. + # Shared libraries with an $ORIGIN rpath let the extension resolve the + # whisper.cpp libraries installed beside it (as install.sh does), with + # no LD_LIBRARY_PATH at runtime. (setup.py install never builds a + # wheel, so NO_REPAIR is meaningless here and deliberately unset.) + export CMAKE_ARGS='-DCMAKE_INSTALL_RPATH=$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON -DBUILD_SHARED_LIBS=ON -DGGML_NATIVE=OFF -DGGML_SSE42=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF -DGGML_BMI2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF' + if [ -n "$build_option_vulkan" ]; then + # Vulkan acceleration for every GPU vendor. There is no CUDA + # build: Void ships no CUDA toolkit package. + export GGML_VULKAN=1 + fi +} + +post_install() { + vlicense LICENSE +} diff --git a/packaging/void/tool_checksums.txt b/packaging/void/tool_checksums.txt new file mode 100644 index 0000000..0aae62c --- /dev/null +++ b/packaging/void/tool_checksums.txt @@ -0,0 +1,22 @@ +# Pinned build inputs for packaging/void/build.sh. +# +# Same contract as packaging/native/tool_checksums.txt: everything the +# package build downloads is pinned and verified here, so two builds of the +# same commit cannot differ. +# +# Columns: name, kind, value, source — parseable by awk. +# +# sha256 the file downloaded from must hash to +# docker build inside image ; records the tag it came from +# version pinned commit or tag; says where it was taken from +# +# To bump the base image: pull the new void-glibc-full tag, read the digest +# from `podman inspect` (Digest, not the config hash), and replace . +# The image digest comes from `podman inspect +# ghcr.io/void-linux/void-glibc-full:latest` (use the manifest digest — the +# same line has to work on x86_64; only glibc x86_64 is published). +# To bump void-packages: set to the new commit; build.sh checks out +# exactly that commit. + +base-image docker ghcr.io/void-linux/void-glibc-full@sha256:306f0cdd27a0c35c4a0089318235ce36ee21d43565721376b3f710c6f9780322 void-glibc-full:latest +void-packages version 4384dad29775c7194ff5a7a6054570406ac32e54 void-packages master as of 2026-11-05 diff --git a/tests/test_void_pywhispercpp_template.py b/tests/test_void_pywhispercpp_template.py new file mode 100644 index 0000000..76c24ce --- /dev/null +++ b/tests/test_void_pywhispercpp_template.py @@ -0,0 +1,243 @@ +"""Offline policy tests for the Void template of the whisper.cpp binding (#26). + +Spec #16 packages three templates in void-packages layout under +``packaging/void``; this ticket owns the hard one, ``python3-pywhispercpp``, +built from the pinned PyPI source distribution with the Vulkan backend, +shared libraries, and portable CPU flags. Ticket #28 builds the container +smoke gate on top; everything asserted here runs offline from sources +already in the repo. + +Independent sources of truth: ``install.sh`` pins the binding version +(``PYWHISPERCPP_VERSION``), ``uv.lock`` pins the sdist URL and sha256, and +the sdist's own ``setup.py``/``PKG-INFO`` name the build and runtime +requirements. The template must agree with all three. +""" + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +VOID_DIR = REPO_ROOT / "packaging" / "void" +TEMPLATE = VOID_DIR / "srcpkgs" / "python3-pywhispercpp" / "template" +PINS = VOID_DIR / "tool_checksums.txt" +BUILD_SH = VOID_DIR / "build.sh" +INSTALL_SH = REPO_ROOT / "install.sh" +UV_LOCK = REPO_ROOT / "uv.lock" + +#: ggml SIMD options pinned off for the plain x86-64 baseline (#957). +#: GGML_NATIVE=OFF alone is not portable: while ggml's native default is on, +#: every GGML_* SIMD option stays on and still compiles -mavx2 -mfma. +#: Mirrors PORTABLE_GGML_OPTIONS in tests/test_flatpak_packaging.py and the +#: CMAKE_ARGS line in packaging/flatpak/python3-dependencies.yaml. +PORTABLE_GGML_OPTIONS = ( + "GGML_NATIVE", + "GGML_SSE42", + "GGML_AVX", + "GGML_AVX2", + "GGML_BMI2", + "GGML_FMA", + "GGML_F16C", +) + +#: install_requires of pywhispercpp's setup.py, mapped to the Void binary +#: package names. Each name was verified to exist as srcpkgs//template +#: in current void-packages (2026-11-05); the container build re-verifies via +#: xbps-query, so a removal upstream fails there rather than silently here. +VOID_RUNTIME_DEPS = ( + "python3-numpy", + "python3-requests", + "python3-tqdm", + "python3-platformdirs", +) + + +def _template() -> str: + assert TEMPLATE.is_file(), f"missing Void template: {TEMPLATE}" + return TEMPLATE.read_text(encoding="utf-8") + + +def _pinned_version() -> str: + text = INSTALL_SH.read_text(encoding="utf-8") + match = re.search(r'^PYWHISPERCPP_VERSION="([^"]+)"', text, re.M) + assert match, "install.sh no longer pins PYWHISPERCPP_VERSION" + return match.group(1) + + +def _sdist_url_and_hash() -> tuple[str, str]: + text = UV_LOCK.read_text(encoding="utf-8") + match = re.search( + r'sdist = \{ url = "(https://files\.pythonhosted\.org/[^"]*?' + r"/pywhispercpp-([0-9.]+)\.tar\.gz)\"," + r' hash = "sha256:([0-9a-f]{64})"', + text, + ) + assert match, "uv.lock no longer pins the pywhispercpp sdist URL and hash" + return match.group(1), match.group(3) + + +def test_template_names_the_package_after_its_directory() -> None: + """pkgname must match srcpkgs/, per the Void manual.""" + text = _template() + match = re.search(r"^pkgname=(\S+)", text, re.M) + assert match, "template sets no pkgname" + assert match.group(1) == TEMPLATE.parent.name == "python3-pywhispercpp" + + +def test_template_carries_the_mandatory_void_variables() -> None: + """Mandatory vars: version, revision, short_desc, maintainer, license.""" + text = _template() + assert re.search(r"^version=[0-9][0-9.]*$", text, re.M), "unpinned or missing version" + assert re.search(r"^revision=1$", text, re.M), "new templates start at revision 1" + match = re.search(r'^short_desc="([^"]+)"', text, re.M) + assert match, "missing short_desc" + assert len(match.group(1)) <= 72, f"short_desc exceeds 72 chars: {match.group(1)!r}" + assert re.search( + r'^maintainer="?[^"]*<\S+@\S+>"?$', text, re.M + ), "maintainer is not 'name '" + assert re.search(r'^license="?MIT"?$', text, re.M), "pywhispercpp is MIT licensed" + assert re.search(r'^homepage="?https://github\.com/absadiki/pywhispercpp"?$', text, re.M) + + +def test_template_version_matches_the_installer_and_lockfile_pins() -> None: + """The template builds the pinned binding, not newest upstream.""" + text = _template() + pinned = _pinned_version() + match = re.search(r"^version=(\S+)", text, re.M) + assert match and match.group(1) == pinned, ( + f"template builds {match.group(1) if match else 'nothing'}, " f"install.sh pins {pinned}" + ) + + +def test_distfiles_and_checksum_match_the_locked_sdist() -> None: + """One PyPI source distribution, checksum-verified against uv.lock.""" + text = _template() + url, sha = _sdist_url_and_hash() + assert ( + "${PYPI_SITE}/p/pywhispercpp/pywhispercpp-${version}.tar.gz" in text + ), "distfiles must use the ${PYPI_SITE} mirror helper with ${version}" + match = re.search(r"^checksum=([0-9a-f]{64})$", text, re.M) + assert match, "checksum must be a bare sha256 hex digest" + assert match.group(1) == sha, "template checksum is behind uv.lock's sdist hash" + assert url.endswith(f"pywhispercpp-{_pinned_version()}.tar.gz") + + +def test_build_style_is_python3_module() -> None: + """setup.py exists, so the module style applies (per the Void manual).""" + assert re.search(r"^build_style=python3-module$", _template(), re.M) + + +def test_portable_cpu_flags_pin_every_ggml_simd_option_off() -> None: + """No host-native tuning: the plain x86-64 baseline, like the Flatpak.""" + text = _template() + for flag in PORTABLE_GGML_OPTIONS: + assert f"-D{flag}=OFF" in text, ( + f"template does not pin {flag} off; the package inherits the " + "builder's instruction set and SIGILLs on older CPUs (#938, #957)" + ) + + +def _code_lines() -> str: + """Template with comment lines stripped; flags live in code, not prose.""" + return "\n".join(line for line in _template().splitlines() if not line.lstrip().startswith("#")) + + +def test_no_native_tuning_or_cuda_anywhere() -> None: + """Void has no CUDA toolkit package; portable means portable.""" + text = _code_lines() + assert "-march=native" not in text and "-mcpu=native" not in text + assert "GGML_NATIVE=ON" not in text + assert "cuda" not in text.lower(), "no CUDA build: Void ships no CUDA toolkit" + + +def test_vulkan_is_a_default_on_build_option() -> None: + """Source builders rebuild CPU-only with -o ~vulkan.""" + text = _template() + match = re.search(r"^build_options=(.*)$", text, re.M) + assert match and "vulkan" in match.group(1).replace('"', "").split() + match = re.search(r"^build_options_default=(.*)$", text, re.M) + assert match and "vulkan" in match.group(1).replace('"', "").split(), "Vulkan is on by default" + # The common options description already defines desc_option_vulkan; + # a local redefinition would drift from it. + assert "desc_option_vulkan" not in text + assert "GGML_VULKAN=1" in text, "the Vulkan backend is enabled via GGML_VULKAN=1" + assert "build_option_vulkan" in text, "GGML_VULKAN must be gated on the option" + + +def test_vulkan_deps_are_conditional_and_runtime_names_exist() -> None: + """Vulkan-only deps vanish from CPU-only builds; runtime names are real.""" + text = _template() + assert "vopt_if vulkan" in text, "conditional Vulkan deps use vopt_if" + assert "vulkan-loader" in text, "libvulkan comes from vulkan-loader" + assert "Vulkan-Headers" in text or "vulkan-loader-devel" in text + assert "shaderc" in text, "ggml-vulkan requires glslc at build time" + depends = re.search(r"^depends=(.*)$", text, re.M) + assert depends, "template declares no runtime depends" + for dep in VOID_RUNTIME_DEPS: + assert dep in depends.group(1), f"{dep} missing from depends" + + +def test_build_time_deps_cover_setup_py() -> None: + """setup.py imports bdist_wheel at top level and shells out to cmake.""" + text = _template() + host = re.search(r"^hostmakedepends=(.*)$", text, re.M) + assert host, "template declares no hostmakedepends" + for dep in ("cmake", "pkg-config", "python3-setuptools", "python3-wheel"): + assert dep in host.group(1), f"{dep} missing from hostmakedepends" + make = re.search(r"^makedepends=(.*)$", text, re.M) + assert make and "python3-devel" in make.group(1), "compiled extension needs python3-devel" + + +def test_version_is_injected_for_the_offline_build() -> None: + """The sdist carries no version.txt; setup.py reads PYWHISPERCPP_VERSION.""" + assert "PYWHISPERCPP_VERSION" in _template() + + +def test_build_fetches_nothing_beyond_the_sdist() -> None: + """Only declared distfiles may come from the network.""" + text = _template() + assert text.count("https://") == 1, "homepage is the only URL a template may name" + assert "FetchContent" not in text and "ExternalProject" not in text + assert "make_check=no" in text, "the sdist ships no tests to check" + + +def test_template_style_is_lint_clean() -> None: + """Trailing whitespace and missing final newline fail xlint.""" + raw = TEMPLATE.read_bytes().decode("utf-8") + for lineno, line in enumerate(raw.splitlines(), 1): + assert line == line.rstrip(), f"trailing whitespace on line {lineno}" + assert "\t" not in line or line.startswith("\t"), f"stray tab on line {lineno}" + assert raw.endswith("\n"), "template must end with a newline" + + +def test_tool_checksums_pin_the_void_glibc_image() -> None: + """The container gate builds in one pinned image, like native/appimage.""" + assert PINS.is_file(), f"missing pins file: {PINS}" + pins = {} + for line in PINS.read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + name, kind, value, _source = line.split(None, 3) + pins[name] = (kind, value) + assert "base-image" in pins, "no pinned base image" + kind, value = pins["base-image"] + assert kind == "docker", "base-image pin kind matches native/appimage convention" + assert value.startswith( + "ghcr.io/void-linux/void-glibc-full@sha256:" + ), f"base image must be the pinned Void glibc image, got {value}" + assert len(value.rsplit(":", 1)[1]) == 64, "image digest must be a full sha256" + + +def test_build_script_uses_podman_and_the_pinned_image() -> None: + """Container work uses podman, never Docker; the image comes from the pins.""" + assert BUILD_SH.is_file(), f"missing build script: {BUILD_SH}" + text = BUILD_SH.read_text(encoding="utf-8") + assert "podman" in text, "the build harness must use podman" + scrubbed = text.lower().replace("never docker", "") + assert "docker" not in scrubbed, "never Docker" + assert "tool_checksums.txt" in text, "the image digest comes from the pins file" + # The container flow lives in mounted files, not an inline sh -c + # string: single quotes inside sh -c silently regroup the command. + for name in ("container-main.sh", "container-build.sh"): + path = VOID_DIR / name + assert path.is_file(), f"missing container script: {path}" + assert name in text, f"{BUILD_SH.name} must mount {name}"