diff --git a/packaging/void/build-release.sh b/packaging/void/build-release.sh index 20e3460..bcb1de9 100755 --- a/packaging/void/build-release.sh +++ b/packaging/void/build-release.sh @@ -82,7 +82,7 @@ source_version() { # release form. xbps_version() { printf '%s\n' "$1" | - sed -n -E 's/^([0-9]+(\.[0-9]+)*)(-(alpha|beta|rc)[0-9]*)?$/\1\3/p' | + sed -n -E 's/^([0-9]+\.[0-9]+\.[0-9]+)(-(alpha|beta|rc)[0-9]*)?$/\1\2/p' | tr -d '-' } @@ -98,6 +98,16 @@ derive() { SDIST_NAME="voculinux-$XBPS_VERSION.tar.gz" } +# present PATTERN: true when at least one file matches the glob PATTERN. A plain +# POSIX loop: the release runs under /bin/sh, which is dash on Void, and bash-only +# builtins are not available there. +present() { + for match in $1; do + [ -e "$match" ] && return 0 + done + return 1 +} + # template_value KEY: the value of the single KEY= line of the committed template. template_value() { count=$(grep -c "^$1=" "$TEMPLATE" || true) @@ -118,7 +128,8 @@ check_template() { } # check_key KEY_FILE: RSA 4096, and for a release the public half must be the -# committed repository-key.plist key. Sets FINGERPRINT and CLIENT_PLIST. +# committed repository-key.plist key. Sets FINGERPRINT and writes the client +# key to $WORK/client.plist. check_key() { KEY_FILE=$1 [ -n "$KEY_FILE" ] || @@ -219,7 +230,7 @@ verify_set() { cd "$OUT" for pattern in 'sdist/*.tar.gz' 'xbps/repo/*.x86_64.xbps' 'xbps/repo/*.sig2' \ 'xbps/repo/*-repodata*' 'xbps/keys/FINGERPRINT' 'xbps/keys/*.plist'; do - compgen -G "$pattern" >/dev/null || fail "missing from the signed set: $pattern" + present "$pattern" || fail "missing from the signed set: $pattern" done [ "$(ls sdist | wc -l)" -eq 1 ] && [ -f "sdist/$SDIST_NAME" ] || fail "the sdist directory must hold exactly sdist/$SDIST_NAME" @@ -251,7 +262,7 @@ verify_set() { [ -f "xbps/repo/voculinux-${XBPS_VERSION}_1.x86_64.xbps" ] || fail "the repository lacks voculinux-${XBPS_VERSION}_1" else - compgen -G "xbps/repo/${name}-[0-9]*.x86_64.xbps" >/dev/null || + present "xbps/repo/${name}-[0-9]*.x86_64.xbps" || fail "package missing from the repository: $name" fi done @@ -285,8 +296,10 @@ run_pipeline() { KEY_FILE="$WORK/throwaway.pem" OUT="$WORK/out" fi - [ -e "$OUT" ] && { [ -d "$OUT" ] && [ -z "$(ls -A "$OUT")" ]; } || - { [ ! -e "$OUT" ] || fail "$OUT exists and is not empty; choose a new output directory"; } + if [ -e "$OUT" ]; then + [ -d "$OUT" ] && [ -z "$(ls -A "$OUT")" ] || + fail "$OUT exists and is not empty; choose a new output directory" + fi check_key "$KEY_FILE" build_sdist phase_build diff --git a/packaging/void/container-release.sh b/packaging/void/container-release.sh index bd4018f..6673342 100755 --- a/packaging/void/container-release.sh +++ b/packaging/void/container-release.sh @@ -149,12 +149,14 @@ phase_install() { printf '%s\n' "$VERSION_OUT" printf '%s' "$VERSION_OUT" | grep -qF "$VERSION" || fail "voculinux --version does not show $VERSION" - echo "== payload of the built packages" + echo "== payload of the built packages; every package needs a smoke recipe" for PKG in $PKGS; do PKGFILE=$(ls "$PKG_STAGE"/"$PKG"-[0-9]*.x86_64.xbps | head -n1) case "$PKG" in voculinux) payload_voculinux "$PKGFILE" ;; python3-pywhispercpp) payload_pywhispercpp "$PKGFILE" ;; + python3-pynput | python3-PyAudio) ;; # checked by import below + *) fail "no smoke recipe for package $PKG; add one alongside its template" ;; esac done diff --git a/packaging/void/smoke.sh b/packaging/void/smoke.sh index 208445b..4dac15c 100755 --- a/packaging/void/smoke.sh +++ b/packaging/void/smoke.sh @@ -19,6 +19,7 @@ HERE=$(cd -- "$HERE" && pwd) # Dependency order: the app last. tests/test_void_smoke_gate.py fails if this # list drifts from packaging/void/srcpkgs. +# shellcheck disable=SC2034 # read as data by build-release.sh, which parses this line PKGS="python3-PyAudio python3-pynput python3-pywhispercpp voculinux" exec "$HERE/build-release.sh" smoke diff --git a/tests/test_rename_sweep.py b/tests/test_rename_sweep.py index 8e916ff..91ba498 100644 --- a/tests/test_rename_sweep.py +++ b/tests/test_rename_sweep.py @@ -179,7 +179,7 @@ def _allowed(path: str, line: str) -> bool: return True if path.startswith(_TOOLING_PREFIXES) and _PACKAGE_SEGMENT.search(line): return True - if path == "packaging/void/container-main.sh" and _PRE_RENAME_GUARD.search(line): + if path == "packaging/void/container-release.sh" and _PRE_RENAME_GUARD.search(line): return True if path == "src/vocalinux/utils/update_checker.py" and ( 'casefold() == "vocalinux"' in line or '"/{name}/vocalinux"' in line @@ -253,7 +253,7 @@ def test_allowlist_keeps_the_retained_surfaces() -> None: ("src/vocalinux/ui/tray_indicator.py", "from vocalinux.utils import paths"), ("src/vocalinux/app_identity.py", 'LEGACY_BUS_NAME: str = "com.vocalinux.Vocalinux"'), ("tests/test_app_identity.py", "from vocalinux import app_identity"), - ("packaging/void/container-main.sh", 'grep -E "usr/share/applications/vocalinux"'), + ("packaging/void/container-release.sh", 'grep -E "usr/share/applications/vocalinux"'), ("install.sh", 'local PKG_NAME="vocalinux"'), ] for path, line in kept: diff --git a/tests/test_void_app_template.py b/tests/test_void_app_template.py index 490c03a..2d4cae4 100644 --- a/tests/test_void_app_template.py +++ b/tests/test_void_app_template.py @@ -2,7 +2,9 @@ Spec #16 ships Voculinux itself as a Void package: an in-repo xbps template in void-packages layout under ``packaging/void`` builds the app -from the canonical, checksum-verified release source distribution, +from the canonical, checksum-verified release source distribution, which the +release path builds from the tag and stages for xbps-src (see +``tests/test_void_release_build.py``), depends on Void's own interpreter, GTK, desktop bindings and packaged Python libraries plus the dependency templates from tickets #25 and #26, and installs the renamed desktop entry and icons. Speech models are not @@ -12,9 +14,9 @@ install time. Independent sources of truth: ``src/vocalinux/version.py`` owns the version, ``pyproject.toml`` owns the dependency ranges, ``MANIFEST.in`` plus the renamed ``voculinux.desktop`` own the install paths, and the -GitHub release publish path (``releases/download``) plus ``xbps-src`` +fork's release path (``releases/download/``) plus ``xbps-src`` conventions own the fetch shape. The template must agree with all of -them; the container smoke gate (ticket #28) builds on top. +them; the container smoke gate (ticket #28) runs the same release path. """ import re @@ -27,8 +29,8 @@ SRC = VOID / "srcpkgs" TEMPLATE = SRC / "voculinux" / "template" INSTALL_MSG = SRC / "voculinux" / "INSTALL.msg" PINS = VOID / "tool_checksums.txt" -BUILD_SH = VOID / "build.sh" -CONTAINER_MAIN = VOID / "container-main.sh" +RELEASE_PATH = VOID / "build-release.sh" +CONTAINER_RELEASE = VOID / "container-release.sh" VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py" PYPROJECT = REPO_ROOT / "pyproject.toml" DISTRO_MAP = REPO_ROOT / "scripts" / "distro-package-map.yaml" @@ -75,9 +77,10 @@ RUNTIME_HELPER_PACKAGES = { "pulseaudio-utils": "pactl source listing", } -#: The checksum the template may carry before the fork's release exists. It is -#: explicit and fails closed in xbps-src; it is never the upstream digest. -PENDING_CHECKSUM = "PENDING_FORK_RELEASE_SHA256" +#: The placeholder the committed template carries until the first fork release +#: has a recorded digest. Every release build replaces it in its build tree; it +#: is never a real digest, and it never reaches a published package. +PLACEHOLDER_CHECKSUM = "0" * 64 #: sha256 of the upstream 0.18.5 archive. The template must never pin it: the #: package is the fork's own release, not a copy of upstream's. @@ -245,33 +248,29 @@ def test_template_version_matches_version_py() -> None: def test_distfiles_sources_the_canonical_release_by_checksum() -> None: - """One fork release-asset distfile, checksum-verified; never upstream or git.""" + """One fork release-asset distfile, checksum-bound; never upstream or git.""" variables = _template_vars() distfiles = variables["distfiles"] - version = variables["version"] assert distfiles == ( - f"{FORK_RELEASE_HOST}/releases/download/v${{version}}/" + f"{FORK_RELEASE_HOST}/releases/download/${{_tag}}/" f"{variables['pkgname']}-${{version}}.tar.gz" - ), "distfiles must be the fork's own release sdist for this version" + ), "distfiles must name the fork's release asset by its tag and xbps version" assert "github.com" not in distfiles and "VocaHQ" not in distfiles, "no upstream archive" for moving in ("/master", "/main", "/archive/", "git+"): assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}" assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball" - # The sdist's top-level directory is pkgname-version, so xbps-src needs no - # wrksrc override (deprecated; xlint rejects it). - root = distfiles.rsplit("/", 1)[1].replace("${version}", version) - assert root == f"{variables['pkgname']}-{version}.tar.gz", "archive name must match pkgname" + assert variables["_tag"] == f"v{_app_version()}", "_tag is the tag, hyphen and all" + assert "v${version}" not in distfiles, "a pre-release tag keeps its hyphen in the URL" assert "wrksrc" not in variables, "deprecated wrksrc override: xlint fails the template" -def test_checksum_is_pinned_to_the_fork_release_or_explicitly_pending() -> None: - """A 64-hex digest once the fork release exists; until then, the explicit - pending marker. Never the upstream digest, never empty.""" +def test_checksum_is_a_recorded_digest_or_the_build_placeholder() -> None: + """A 64-hex digest once recorded; until then the all-zero placeholder that + every release build replaces. Never the upstream digest, never empty.""" checksum = _template_vars()["checksum"] assert checksum != UPSTREAM_0_18_5_SHA256, "the template must not pin the upstream archive" - assert ( - re.fullmatch(r"[0-9a-f]{64}", checksum) or checksum == PENDING_CHECKSUM - ), f"checksum must be a sha256 or the explicit pending marker, not {checksum!r}" + assert re.fullmatch(r"[0-9a-f]{64}", checksum), f"not a sha256: {checksum!r}" + assert "PENDING" not in _template_text(), "no pending marker: the build pins the digest" def test_every_project_dependency_is_covered() -> None: @@ -407,24 +406,21 @@ def test_install_msg_line_length() -> None: assert raw.endswith("\n"), "INSTALL.msg must end with a newline" -def test_container_harness_covers_the_app_package() -> None: - """build.sh stays podman-only; container-main smokes the installed app.""" - build = BUILD_SH.read_text(encoding="utf-8") - assert "podman" in build, "the build harness must use podman" - assert "docker" not in build.lower().replace("never docker", "") - harness = CONTAINER_MAIN.read_text(encoding="utf-8") +def test_release_path_covers_the_app_package() -> None: + """The install smoke runs the renamed binary and the app's payload checks.""" + harness = CONTAINER_RELEASE.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 + "no pre-rename" in harness or "pre-rename file names" in harness ), "the payload check must reject pre-rename file names in the package" - assert "python3-pywhispercpp" in harness or "PKGNAME" in harness + assert "models or wheels" in harness, "the payload check rejects models and wheels" + assert "import vocalinux.main" in harness, "the install smoke imports the app" + assert "pywhispercpp.model" in harness, "the install smoke imports the speech binding" + assert "PYNPUT_BACKEND=dummy" in harness and "import evdev" in harness + assert "build-release.sh" in RELEASE_PATH.read_text(encoding="utf-8") def test_void_distro_inventory_matches_void_packages_reality() -> None: diff --git a/tests/test_void_pywhispercpp_template.py b/tests/test_void_pywhispercpp_template.py index 76c24ce..d69a6cb 100644 --- a/tests/test_void_pywhispercpp_template.py +++ b/tests/test_void_pywhispercpp_template.py @@ -20,7 +20,7 @@ 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" +BUILD_SH = VOID_DIR / "build-release.sh" INSTALL_SH = REPO_ROOT / "install.sh" UV_LOCK = REPO_ROOT / "uv.lock" @@ -235,9 +235,8 @@ def test_build_script_uses_podman_and_the_pinned_image() -> None: 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}" + # The container flow lives in a mounted file, not an inline sh -c string: + # single quotes inside sh -c silently regroup the command. + name = "container-release.sh" + assert (VOID_DIR / name).is_file(), f"missing container script: {name}" + assert name in text, f"{BUILD_SH.name} must mount {name}" diff --git a/tests/test_void_release_build.py b/tests/test_void_release_build.py new file mode 100644 index 0000000..8a4c4d4 --- /dev/null +++ b/tests/test_void_release_build.py @@ -0,0 +1,559 @@ +"""Offline guards for the one Void release path (spec #16; tickets #28 and #29). + +``packaging/void/build-release.sh`` is the release path. The Gitea tag workflow, +the local dry run and the smoke gate (``packaging/void/smoke.sh``) all call it, +so a passing dry run exercises the code a tag runs. These tests pin its seams: + +- the xbps spelling a tag maps to, and the tag gate (exact ``v`` plus + ``__version__``, and the committed template must already name that release); +- the signing-key checks, which must stop before any container starts (a fake + ``podman`` on PATH records any call and must never be reached); +- ``render-voculinux.sh``, run against a scratch void-packages tree, which pins + the build tree and never the repository's template; +- the podman wiring: which phase may see the key, and which phase has no network; +- the xbps version ordering the mapping depends on (``xbps-uhelper cmpver``, + skipped where xbps is not installed). + +The container runs themselves are the proof; these are the guards around them. +Expected values come from the documented rules, not from the code under test. +""" + +import hashlib +import os +import plistlib +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +VOID = REPO_ROOT / "packaging" / "void" +BUILD_RELEASE = VOID / "build-release.sh" +CONTAINER_RELEASE = VOID / "container-release.sh" +RENDER = VOID / "render-voculinux.sh" +FINGERPRINT_PY = VOID / "xbps-fingerprint.py" +SMOKE_SH = VOID / "smoke.sh" +PINS = VOID / "tool_checksums.txt" +PLIST = VOID / "repository-key.plist" +TEMPLATE = VOID / "srcpkgs" / "voculinux" / "template" +VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py" + +PINNED_FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25" +SDIST = "voculinux-0.19.0beta.tar.gz" +XBPS_UHELPER = shutil.which("xbps-uhelper") +OPENSSL = shutil.which("openssl") + +#: Files the release path reads from its own directory, copied into fixtures. +FIXTURE_FILES = (BUILD_RELEASE, CONTAINER_RELEASE, RENDER, FINGERPRINT_PY, SMOKE_SH, PINS, PLIST) + + +def _body(text: str, name: str) -> str: + """The body of a top-level shell function, from its opening line to the closing brace.""" + match = re.search(rf"^{name}\(\) \{{\n(.*?)^\}}$", text, re.MULTILINE | re.DOTALL) + assert match, f"{name}() must be a top-level function" + return match.group(1) + + +def _code(path: Path) -> str: + """A shell or Python script with comment-only lines removed.""" + return "\n".join( + line + for line in path.read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith("#") + ) + + +def make_tree( + root: Path, + version: str, + template_version: str, + template_tag: str, + version_py: Optional[str] = None, +) -> Path: + """A scratch copy of the release tree, with the given version and committed template.""" + pkg = root / "packaging" / "void" + pkg.mkdir(parents=True) + for source in FIXTURE_FILES: + shutil.copy2(source, pkg / source.name) + (pkg / "srcpkgs" / "voculinux").mkdir(parents=True) + text = TEMPLATE.read_text(encoding="utf-8") + text = re.sub(r"^version=.*$", f"version={template_version}", text, flags=re.MULTILINE) + text = re.sub(r"^_tag=.*$", f"_tag={template_tag}", text, flags=re.MULTILINE) + (pkg / "srcpkgs" / "voculinux" / "template").write_text(text, encoding="utf-8") + src = root / "src" / "vocalinux" + src.mkdir(parents=True) + (src / "version.py").write_text( + version_py if version_py is not None else f'"""Version."""\n\n__version__ = "{version}"\n', + encoding="utf-8", + ) + return root + + +def run_build(tree: Path, *args: str, path: Optional[str] = None, tmp: Optional[Path] = None): + """Run the tree's build-release.sh with a minimal, explicit environment.""" + env = {"PATH": path or os.environ["PATH"], "HOME": os.environ.get("HOME", "/")} + if tmp is not None: + env["TMPDIR"] = str(tmp) + return subprocess.run( + ["sh", str(tree / "packaging" / "void" / "build-release.sh"), *args], + cwd=tree, + env=env, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + +# --------------------------------------------------------------------------- +# Names: a tag maps to one xbps spelling, and only release forms map at all +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("version", "xbps"), + [ + ("0.18.5", "0.18.5"), + ("1.0.0", "1.0.0"), + ("0.19.0-alpha", "0.19.0alpha"), + ("0.19.0-beta", "0.19.0beta"), + ("0.19.0-rc1", "0.19.0rc1"), + ("0.19.0-rc12", "0.19.0rc12"), + ], +) +def test_a_tag_maps_to_its_xbps_spelling(tmp_path: Path, version: str, xbps: str) -> None: + """xbps versions have no '-': the hyphen goes, and the rest is unchanged.""" + tree = make_tree(tmp_path, version, template_version=xbps, template_tag=f"v{version}") + result = run_build(tree, "names", f"v{version}") + assert result.returncode == 0, result.stderr + names = dict(line.split("=", 1) for line in result.stdout.splitlines()) + assert names == { + "TAG": f"v{version}", + "VERSION": version, + "XBPS_VERSION": xbps, + "SDIST_NAME": f"voculinux-{xbps}.tar.gz", + } + + +@pytest.mark.parametrize( + "version", + [ + "0.19.0-beta.2", + "0.19.0+local", + "0.19.0_beta", + "0.19", + "0.19.0-dev", + "0.19.0-post1", + "0.19.0-beta-2", + "v0.19.0", + ], +) +def test_a_version_outside_the_release_forms_is_refused(tmp_path: Path, version: str) -> None: + """Only X.Y.Z and X.Y.Z-alpha|beta|rcN name a release; anything else stops.""" + tree = make_tree(tmp_path, version, template_version="0.19.0", template_tag=f"v{version}") + result = run_build(tree, "names", f"v{version}") + assert result.returncode == 1, result.stdout + assert "not a release form" in result.stderr + + +# --------------------------------------------------------------------------- +# The tag gate: exact match, and the committed template must already agree +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("tag", "version", "ok"), + [ + ("v0.18.5", "0.18.5", True), + ("v0.19.0-beta", "0.19.0-beta", True), + ("0.18.5", "0.18.5", False), # no v prefix + ("v0.18.50", "0.18.5", False), # the tag contains the version + ("v0.18", "0.18.5", False), # the version contains the tag + ("v0.18.5-beta", "0.18.5", False), + ("v0.18.5", "0.18.5-beta", False), + ("v0.18.4", "0.18.5", False), + ("vv0.18.5", "0.18.5", False), + ], +) +def test_tag_gate_is_an_exact_match_with_version_py( + tmp_path: Path, tag: str, version: str, ok: bool +) -> None: + xbps = version.replace("-", "") + tree = make_tree(tmp_path, version, template_version=xbps, template_tag=f"v{version}") + result = run_build(tree, "check-tag", tag) + if ok: + assert result.returncode == 0, result.stderr + assert "tag gate passed" in result.stdout + else: + assert result.returncode == 1, f"{tag} against {version} must stop: {result.stdout}" + assert "refusing to publish" in result.stderr + + +def test_the_committed_template_must_name_this_release(tmp_path: Path) -> None: + """A release whose template still names the previous version never publishes.""" + tree = make_tree(tmp_path, "0.19.0-beta", template_version="0.18.5", template_tag="v0.18.5") + result = run_build(tree, "check-tag", "v0.19.0-beta") + assert result.returncode == 1 + assert "committed template version is '0.18.5', not '0.19.0beta'" in result.stderr + + +def test_the_committed_template_tag_must_match_the_tag(tmp_path: Path) -> None: + tree = make_tree(tmp_path, "0.19.0-beta", template_version="0.19.0beta", template_tag="v0.19.0") + result = run_build(tree, "check-tag", "v0.19.0-beta") + assert result.returncode == 1 + assert "_tag is 'v0.19.0'" in result.stderr + + +def test_version_py_must_define_the_version_exactly_once(tmp_path: Path) -> None: + body = '__version__ = "0.18.5"\n__version__ = "0.18.6"\n' + tree = make_tree(tmp_path, "0.18.5", "0.18.5", "v0.18.5", version_py=body) + result = run_build(tree, "check-tag", "v0.18.5") + assert result.returncode == 1 + assert "exactly once" in result.stderr + + +# --------------------------------------------------------------------------- +# Version ordering: the pre-release spellings sort below the release +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(XBPS_UHELPER is None, reason="xbps-uhelper is not installed") +@pytest.mark.parametrize( + ("lower", "higher"), + [ + ("0.18.5beta", "0.18.5"), + ("0.18.5rc1", "0.18.5"), + ("0.18.5alpha", "0.18.5beta"), + ("0.18.5beta", "0.18.5rc1"), + ("0.18.5rc1", "0.18.5rc2"), + ("0.18.5rc2", "0.18.5rc10"), + ("0.18.5", "0.19.0beta"), + ("0.19.0beta", "0.19.0"), + ], +) +def test_xbps_sorts_pre_release_spellings_below_the_release(lower: str, higher: str) -> None: + """A stable user is never offered a pre-release of the same version as newer.""" + result = subprocess.run( + [XBPS_UHELPER, "cmpver", f"voculinux-{lower}_1", f"voculinux-{higher}_1"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + # xbps-uhelper exits -1 (255) when the first package is older than the second. + assert result.returncode == 255, f"{lower} must sort below {higher}: rc={result.returncode}" + + +# --------------------------------------------------------------------------- +# Signing key: every refusal happens before any container starts +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def fake_podman(tmp_path: Path) -> tuple: + """A podman that records its calls and fails. Any call means a container would start.""" + bindir = tmp_path / "fakebin" + bindir.mkdir() + log = tmp_path / "podman-calls.log" + script = bindir / "podman" + script.write_text(f'#!/bin/sh\necho "$@" >> "{log}"\nexit 97\n', encoding="utf-8") + script.chmod(0o755) + return f"{bindir}{os.pathsep}{os.environ['PATH']}", log + + +def _key(path: Path, bits: int) -> Path: + subprocess.run( + [OPENSSL, "genrsa", "-out", str(path), str(bits)], + capture_output=True, + check=True, + timeout=60, + ) + return path + + +@pytest.mark.skipif(OPENSSL is None, reason="openssl is not installed") +@pytest.mark.parametrize( + ("case", "message"), + [ + ("missing-arg", "no signing key"), + ("empty", "missing or empty"), + ("garbage", "not a valid RSA private key"), + ("rsa2048", "not RSA 4096"), + ("rsa4096-other", "does not match packaging/void/repository-key.plist"), + ], +) +def test_a_bad_key_stops_the_release_before_any_container( + tmp_path: Path, fake_podman: tuple, case: str, message: str +) -> None: + path, log = fake_podman + tree = make_tree(tmp_path / "tree", "0.18.5", template_version="0.18.5", template_tag="v0.18.5") + out = tmp_path / "out" + args = ["release", "v0.18.5", "--out-dir", str(out)] + if case == "empty": + key = tmp_path / "empty.pem" + key.write_bytes(b"") + elif case == "garbage": + key = tmp_path / "garbage.pem" + key.write_text("not a key\n", encoding="utf-8") + elif case == "rsa2048": + key = _key(tmp_path / "rsa2048.pem", 2048) + elif case == "rsa4096-other": + key = _key(tmp_path / "other.pem", 4096) + if case != "missing-arg": + args += ["--key-file", str(key)] + tmp = tmp_path / "work" + tmp.mkdir() + result = run_build(tree, *args, path=path, tmp=tmp) + assert result.returncode == 1, result.stdout + result.stderr + assert message in result.stderr + assert not log.exists(), "a container started with a refused key" + assert not out.exists(), "a refused release must not create its output directory" + for work in tmp.glob("voculinux-release.*"): + assert not (work / "dist").exists(), "the sdist must not be built for a refused key" + + +@pytest.mark.skipif(OPENSSL is None, reason="openssl is not installed") +def test_the_key_file_is_never_printed(tmp_path: Path, fake_podman: tuple) -> None: + """Refusals name the problem, never the key material.""" + path, _ = fake_podman + tree = make_tree(tmp_path / "tree", "0.18.5", template_version="0.18.5", template_tag="v0.18.5") + key = _key(tmp_path / "other.pem", 4096) + secret_line = key.read_text(encoding="ascii").splitlines()[1] + tmp = tmp_path / "work" + tmp.mkdir() + result = run_build( + tree, + "release", + "v0.18.5", + "--key-file", + str(key), + "--out-dir", + str(tmp_path / "o"), + path=path, + tmp=tmp, + ) + assert secret_line not in result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# render-voculinux.sh: the build tree is pinned, the repository never is +# --------------------------------------------------------------------------- + + +def _scratch_void_packages(tmp_path: Path) -> Path: + vp = tmp_path / "void-packages" + (vp / "srcpkgs" / "voculinux").mkdir(parents=True) + shutil.copy2(TEMPLATE, vp / "srcpkgs" / "voculinux" / "template") + return vp + + +def _render(tmp_path: Path, vp: Path, payload: bytes, **overrides: str): + inputs = tmp_path / "inputs" + inputs.mkdir(exist_ok=True) + (inputs / SDIST).write_bytes(payload) + env = { + "PATH": os.environ["PATH"], + "VOID_PACKAGES": str(vp), + "SDIST_DIR": str(inputs), + "XBPS_VERSION": "0.19.0beta", + "TAG": "v0.19.0-beta", + "SDIST_NAME": SDIST, + "SDIST_SHA256": hashlib.sha256(payload).hexdigest(), + } + env.update(overrides) + return subprocess.run( + ["sh", str(RENDER)], env=env, capture_output=True, text=True, timeout=30, check=False + ) + + +def test_render_pins_version_tag_and_checksum_in_the_build_tree(tmp_path: Path) -> None: + payload = b"canonical sdist bytes for the release" + vp = _scratch_void_packages(tmp_path) + repo_before = TEMPLATE.read_bytes() + result = _render(tmp_path, vp, payload) + assert result.returncode == 0, result.stderr + text = (vp / "srcpkgs" / "voculinux" / "template").read_text(encoding="utf-8") + digest = hashlib.sha256(payload).hexdigest() + assert re.search(r"^version=0\.19\.0beta$", text, re.MULTILINE) + assert re.search(r"^_tag=v0\.19\.0-beta$", text, re.MULTILINE) + assert re.search(rf"^checksum={digest}$", text, re.MULTILINE) + assert "releases/download/${_tag}/voculinux-${version}.tar.gz" in text, "distfiles unchanged" + staged = vp / "hostdir" / "sources" / "voculinux-0.19.0beta" / SDIST + assert staged.read_bytes() == payload, "xbps-src must find the sdist where it looks" + assert TEMPLATE.read_bytes() == repo_before, "the repository template must never change" + + +def test_render_refuses_a_payload_that_does_not_match_its_digest(tmp_path: Path) -> None: + vp = _scratch_void_packages(tmp_path) + before = (vp / "srcpkgs" / "voculinux" / "template").read_bytes() + result = _render(tmp_path, vp, b"payload", SDIST_SHA256="0" * 64) + assert result.returncode == 1 + assert "is not the expected" in result.stderr + assert (vp / "srcpkgs" / "voculinux" / "template").read_bytes() == before + + +def test_render_refuses_a_template_without_exactly_one_checksum_line(tmp_path: Path) -> None: + vp = _scratch_void_packages(tmp_path) + template = vp / "srcpkgs" / "voculinux" / "template" + template.write_text(template.read_text(encoding="utf-8") + "checksum=abc\n", encoding="utf-8") + result = _render(tmp_path, vp, b"payload") + assert result.returncode == 1 + assert "exactly one checksum= line" in result.stderr + + +@pytest.mark.parametrize("bad_tag", ["v0.19.0-beta;rm", "v0.19.0 beta", 'v0.19.0"beta']) +def test_render_refuses_values_outside_the_safe_set(tmp_path: Path, bad_tag: str) -> None: + vp = _scratch_void_packages(tmp_path) + result = _render(tmp_path, vp, b"payload", TAG=bad_tag) + assert result.returncode == 1 + assert "outside [A-Za-z0-9._-]" in result.stderr + + +# --------------------------------------------------------------------------- +# Podman wiring: who may see the key, who may use the network +# --------------------------------------------------------------------------- + + +def test_only_the_sign_phase_mounts_the_key() -> None: + """Build scripts run in the build phase; they must never see the release key.""" + text = _code(BUILD_RELEASE) + build = _body(text, "phase_build") + sign = _body(text, "phase_sign") + install = _body(text, "phase_install") + for name, body in (("build", build), ("install", install)): + assert "KEY_FILE" not in body, f"the {name} phase must not mount or name the key" + assert "/run/xbps-sign-key.pem" not in body + assert '-v "$KEY_FILE:/run/xbps-sign-key.pem:ro"' in sign, "the key is mounted read-only" + + +def test_the_sign_phase_has_no_network() -> None: + sign = _body(_code(BUILD_RELEASE), "phase_sign") + assert "--network none" in sign + + +def test_the_build_phase_is_the_only_one_that_runs_build_scripts() -> None: + text = _code(CONTAINER_RELEASE) + assert "xbps-src pkg" in _body_of_container(text, "phase_build") + for name in ("phase_sign", "phase_install"): + assert "xbps-src" not in _body_of_container(text, name), f"{name} must not build anything" + + +def _body_of_container(text: str, name: str) -> str: + match = re.search(rf"^{name}\(\) \{{\n(.*?)^\}}$", text, re.MULTILINE | re.DOTALL) + assert match, f"{name}() must be a function in container-release.sh" + return match.group(1) + + +def test_every_podman_run_uses_the_pinned_base_image() -> None: + text = _code(BUILD_RELEASE) + runs = text.count("podman run") + assert runs == 3, "build, sign and install: exactly three containers" + assert text.count('"$BASE_IMAGE" /bin/sh /mnt/container-release.sh') == 3 + assert "ghcr.io" not in text, "the base image is pinned in tool_checksums.txt only" + + +@pytest.mark.parametrize("path", [BUILD_RELEASE, CONTAINER_RELEASE, RENDER, SMOKE_SH]) +def test_no_docker_in_the_release_path(path: Path) -> None: + assert not re.search(r"\bdocker\b", _code(path), re.IGNORECASE), f"{path.name} names Docker" + + +def test_the_checks_run_before_the_sdist_and_before_any_container() -> None: + text = _code(BUILD_RELEASE) + pipeline = _body(text, "run_pipeline") + order = [ + pipeline.index("check_template"), + pipeline.index("check_key"), + pipeline.index("build_sdist"), + pipeline.index("phase_build"), + ] + assert order == sorted(order), order + + +def test_the_key_is_never_echoed_or_copied() -> None: + text = _code(BUILD_RELEASE) + assert "set -x" not in text + assert not re.search(r'cat\s+"?\$KEY_FILE', text) + assert not re.search(r'cp\s+"?\$KEY_FILE', text), "the key is mounted, never copied" + + +def test_the_build_tree_is_pinned_before_xbps_src_runs() -> None: + build = _body_of_container(_code(CONTAINER_RELEASE), "phase_build") + assert build.index("render-voculinux.sh") < build.index("xbps-src binary-bootstrap") + assert build.index("chown -R builder") < build.index("xbps-src binary-bootstrap") + + +def test_render_writes_only_inside_the_build_tree() -> None: + text = _code(RENDER) + assert 'TEMPLATE="$VP/srcpkgs/voculinux/template"' in text + assert 'DEST="$VP/hostdir/sources' in text + assert "$ROOT" not in text and "REPO" not in text, "the repository is never a target" + + +def test_the_install_phase_proves_the_key_is_needed() -> None: + install = _body_of_container(_code(CONTAINER_RELEASE), "phase_install") + assert "xbps-install -S --repository" in install, "the unsigned attempt runs without -y" + assert "refused without the key" in install + assert "/var/db/xbps/keys" in install + + +# --------------------------------------------------------------------------- +# The fingerprint helper: the pinned value is derived, not typed +# --------------------------------------------------------------------------- + + +def test_the_committed_key_has_the_pinned_fingerprint(tmp_path: Path) -> None: + pem = tmp_path / "pub.pem" + pem.write_bytes(plistlib.loads(PLIST.read_bytes())["public-key"]) + result = subprocess.run( + [sys.executable, "-I", str(FINGERPRINT_PY), "fingerprint", str(pem)], + capture_output=True, + text=True, + timeout=60, + check=True, + ) + assert result.stdout.strip() == PINNED_FINGERPRINT + + +def test_the_plist_writer_round_trips_the_public_key(tmp_path: Path) -> None: + if OPENSSL is None: + pytest.skip("openssl is not installed") + key = _key(tmp_path / "key.pem", 2048) + pem = tmp_path / "pub.pem" + subprocess.run( + [OPENSSL, "rsa", "-in", str(key), "-pubout", "-out", str(pem)], + capture_output=True, + check=True, + timeout=60, + ) + out = tmp_path / "client.plist" + subprocess.run( + [ + sys.executable, + "-I", + str(FINGERPRINT_PY), + "plist", + str(pem), + "Test ", + str(out), + ], + capture_output=True, + check=True, + timeout=60, + ) + entry = plistlib.loads(out.read_bytes()) + assert entry["public-key"] == pem.read_bytes() + assert entry["public-key-size"] == 2048 + assert entry["signature-by"] == "Test " + + +def test_smoke_is_the_same_path_with_a_throwaway_key() -> None: + """The smoke gate delegates to the release path; it never names the release key.""" + text = _code(SMOKE_SH) + assert 'exec "$HERE/build-release.sh" smoke' in text + assert "XBPS_SIGNING_KEY" not in text + release = _code(BUILD_RELEASE) + assert "throwaway.pem" in release and "genrsa" in release diff --git a/tests/test_void_signed_repo.py b/tests/test_void_signed_repo.py index 96b5cad..5480f27 100644 --- a/tests/test_void_signed_repo.py +++ b/tests/test_void_signed_repo.py @@ -1,37 +1,26 @@ """Offline policy guards for the signed Void xbps repository (ticket #29). -Spec #16 serves the Void templates from a signed personal xbps -repository: every template builds in dependency order in the pinned Void -container via podman, the repository index carries full repo data, and -packages plus metadata are signed with the RSA4096 repository key. The -pipeline fails closed — a missing, unreadable, or non-RSA4096 key aborts -before any container runs, so an unsigned repository can never publish. +Spec #16 serves the Void templates from a signed personal xbps repository. +The release path itself (build, sign, install smoke) is guarded in +``tests/test_void_release_build.py``; this module guards what must stay true +around it: the committed public key and its pinned fingerprint, the README and +root README registration text users copy, the release workflow's publish target, +the template bump documented as a pull request, and the rule that no private key +is ever committed. Seams under test (public interfaces, not internals): -- ``packaging/void/repo.sh`` — the host entry: key handling plus the - podman delegation. Exit status and stderr on a missing key are the - fail-closed contract. -- ``packaging/void/container-repo.sh`` — the container half: build-all, - index, sign, verify, stage. Its xbps-rindex invocations are the - signing contract. -- The ``void-repo`` just recipe — how release work and CI call the - pipeline, same shape as the other packaging gates. -- ``packaging/void/README.md`` — the documented maintainer and client - flow, including the CI secret name. -- ``packaging/void/repository-key.plist`` — the committed PUBLIC half of - the repository key, whose fingerprint the READMEs and the Gitea release - workflow pin. ``tests/test_xbps_release_workflow.py`` runs the workflow. +- ``packaging/void/repository-key.plist``: the committed PUBLIC half of the + repository key. Its fingerprint is what the READMEs pin. +- ``README.md`` and ``packaging/void/README.md``: the client registration text. +- ``.gitea/workflows/xbps-release.yml``: the tag trigger, the Gitea API it + publishes to, and the release path it calls. +- ``docs/RELEASE_PROCESS.md``: the template bump is a pull request. -Independent sources of truth: the xbps fingerprint definition (the MD5 of -the SSH-format ``ssh-rsa`` blob of the public key, per libxbps -``xbps_pubkey2fp``), ``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, -``packaging/void/smoke.sh`` owns the dependency order the signed repo -must agree with, and ``openssl`` plus ``xbps-rindex`` own the RSA4096 -signing mechanics the container half must invoke. The container run -itself is the proof; these tests are the wiring guard. +Independent sources of truth: the xbps fingerprint definition (the MD5 of the +ssh-rsa blob of the public key, per libxbps ``xbps_pubkey2fp``), computed here +with the standard library only, and ``packaging/void/smoke.sh`` owns the package +order the signed repository must agree with. """ import base64 @@ -52,8 +41,8 @@ ROOT_README = REPO_ROOT / "README.md" RELEASE_PROCESS = REPO_ROOT / "docs" / "RELEASE_PROCESS.md" VOID = REPO_ROOT / "packaging" / "void" SRC = VOID / "srcpkgs" -REPO_SH = VOID / "repo.sh" -CONTAINER_REPO = VOID / "container-repo.sh" +BUILD_RELEASE = VOID / "build-release.sh" +CONTAINER_RELEASE = VOID / "container-release.sh" SMOKE_SH = VOID / "smoke.sh" PINS = VOID / "tool_checksums.txt" JUSTFILE = REPO_ROOT / "justfile" @@ -72,18 +61,6 @@ def _srcpkg_templates() -> list: return sorted(p.name for p in SRC.iterdir() if (p / "template").is_file()) -def _repo_text() -> str: - assert REPO_SH.is_file(), f"missing signed-repo pipeline: {REPO_SH}" - return REPO_SH.read_text(encoding="utf-8") - - -def _container_text() -> str: - assert ( - CONTAINER_REPO.is_file() - ), f"missing container half of the signed-repo pipeline: {CONTAINER_REPO}" - return CONTAINER_REPO.read_text(encoding="utf-8") - - def _smoke_packages() -> list: """The ordered package list the smoke gate builds, from smoke.sh.""" match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M) @@ -91,124 +68,11 @@ def _smoke_packages() -> list: return match.group(1).split() -def test_pipeline_builds_every_template_and_nothing_else() -> None: - """A renamed or added template cannot silently drop out of the repo.""" - text = _repo_text() - assert "smoke.sh" in text or "PKGS" in text, ( - "repo.sh must take its package set from the smoke gate order, " - "not a second hand-maintained list" - ) - assert set(_smoke_packages()) == set(_srcpkg_templates()), ( - f"smoke order covers {_smoke_packages()}, " f"srcpkgs holds {_srcpkg_templates()}" - ) - - -def test_pipeline_builds_the_app_last() -> None: - """voculinux resolves the other templates from the signed repo.""" - assert ( - _smoke_packages()[-1] == "voculinux" - ), f"the app must build last, order is {_smoke_packages()}" - - -def test_pipeline_stays_podman_only_with_pinned_inputs() -> None: - """Podman, never Docker; pins live in tool_checksums.txt, not the flow.""" - for path, text in ( - (REPO_SH, _repo_text()), - (CONTAINER_REPO, _container_text()), - ): - assert "podman" in text or path == CONTAINER_REPO, f"{path.name} must use podman" - assert "docker" not in text.lower().replace( - "never docker", "" - ), f"{path.name} must never invoke Docker" - assert "ghcr.io" not in _repo_text(), "the base image pin lives in tool_checksums.txt" - assert PINS.is_file(), f"missing pins file: {PINS}" - - -def test_pipeline_fails_closed_without_a_key() -> None: - """No key file means no container, no repo, no publish — never unsigned.""" - text = _repo_text() - assert KEY_FILE_VAR in text, f"repo.sh must read the key path from {KEY_FILE_VAR}" - assert ( - SIGNING_SECRET in text or "SIGNING_KEY" in text - ), "repo.sh must name the CI secret carrying the private key" - for marker in ("exit 1",): - assert marker in text, "the missing-key path must abort non-zero" - assert re.search( - r"[Nn]o .*key|missing .*key|key .*missing", text - ), "the missing-key abort must say the key is missing" - - -def test_pipeline_requires_rsa4096_before_any_container_runs() -> None: - """The contract is RSA4096: any other key aborts before podman starts.""" - text = _repo_text() - assert "4096" in text, "repo.sh must pin the 4096-bit key size" - assert "openssl" in text, "repo.sh must validate the key with openssl" - key_check = text.index("openssl") - first_run = min(i for marker in ("podman pull", "podman run") if (i := text.find(marker)) >= 0) - assert key_check < first_run, "key validation must run before the first podman invocation" - - -def test_container_signs_packages_and_metadata_with_the_key() -> None: - """xbps-rindex signs every binary package and the repository metadata.""" - text = _container_text() - assert "xbps-rindex -a" in text, "the repo index (full repodata) is missing" - assert "-S" in text or "--sign-pkg" in text, "per-package signing (--sign-pkg) is missing" - assert ( - re.search(r"(^|\s)-s(\s|$)", text) or "--sign " in text - ), "repository metadata signing (-s/--sign) is missing" - assert "--privkey" in text, "signing must use the provisioned private key" - assert "--signedby" in text, "signing must carry the signer identity" - - -def test_container_lints_every_template_before_building() -> None: - """One bad template fails the repo, same as the per-package harness.""" - assert ( - "xlint" in _container_text() - ), "container-repo.sh must xlint each template before building it" - - -def test_container_names_the_key_fingerprint_for_clients() -> None: - """Clients pin the key by fingerprint; the pipeline must report it.""" - assert ( - "ingerprint" in _container_text() - ), "container-repo.sh must capture the key fingerprint for release notes" - - -def test_container_ships_an_importable_client_key() -> None: - """The staged plist must parse: xbps rejects raw <> in signature-by.""" - text = _container_text() - assert ".plist" in text, "container-repo.sh must stage the client .plist" - assert "<" in text and ">" in text, ( - "the plist writer must XML-escape the signer identity " - "(xbps shows Fingerprint and refuses the import otherwise)" - ) - - -def test_container_proves_signatures_with_a_clean_rootdir_install() -> None: - """The pipeline verifies what it signed: a fresh rootdir install.""" - text = _container_text() - assert ( - "xbps-install" in text and "-r " in text - ), "container-repo.sh must prove the signed repo with a rootdir install" - assert "voculinux" in text, "the proof install must be the app, resolving deps from the repo" - - -def test_just_recipe_calls_the_pipeline_like_other_packaging_gates() -> None: - """`just void-repo` runs the pipeline script with no shared-output step.""" - recipe = re.search( - r"^void-repo:([^\n]*)\n((?:[ \t].*\n|\n)+)", - JUSTFILE.read_text(encoding="utf-8"), - re.MULTILINE, - ) - assert recipe, "justfile must define a void-repo recipe" - assert not recipe.group(1).strip(), "void-repo must not take a shared-output prerequisite" - assert "packaging/void/repo.sh" in recipe.group(2), "void-repo must run packaging/void/repo.sh" - - def test_readme_documents_the_signed_repo_flow() -> None: """Maintainer key setup, CI secret, fail-closed rule, and client setup.""" text = README.read_text(encoding="utf-8") - assert "void-repo" in text or "repo.sh" in text, "README must document the signed-repo command" + assert "build-release.sh" in text, "README must document the release command" + assert "Runner requirements" in text, "README must document the runner requirements" assert "4096" in text, "README must document RSA4096 key generation" assert SIGNING_SECRET in text, "README must name the CI secret" assert ( @@ -228,17 +92,19 @@ def _gitea_workflow_text() -> str: def test_the_xbps_job_lives_in_the_gitea_workflow_not_the_github_release() -> None: - """One signing path: the tag-triggered Gitea workflow runs repo.sh.""" + """One signing path: the tag-triggered Gitea workflow runs build-release.sh.""" github = _release_workflow_text() for marker in ( "build-xbps", "xbps-repo", "XBPS_SIGNING_KEY", - "packaging/void/repo.sh", + "packaging/void/build-release.sh", ): assert marker not in github, f"release.yml must not carry the xbps job ({marker})" gitea = _gitea_workflow_text() - assert "packaging/void/repo.sh" in gitea, "the Gitea workflow must run the signed-repo pipeline" + assert ( + "packaging/void/build-release.sh" in gitea + ), "the Gitea workflow must run the release path" assert SIGNING_SECRET in gitea, "the Gitea workflow must read the XBPS_SIGNING_KEY secret" assert "podman" in gitea, "the Gitea workflow must run the pipeline with podman" workflow = yaml.safe_load(gitea) @@ -415,12 +281,6 @@ def test_readmes_pin_the_fingerprint_of_the_committed_key() -> None: assert fingerprint in path.read_text(encoding="utf-8"), f"{name} must show {fingerprint}" -def test_workflow_publishes_only_under_the_pinned_fingerprint() -> None: - """A secret holding another key fails the verify step before publishing.""" - workflow = yaml.safe_load(_gitea_workflow_text()) - assert workflow["jobs"]["xbps-release"]["env"]["XBPS_KEY_FINGERPRINT"] == _key_fingerprint() - - def test_void_readme_documents_the_secret_and_the_public_key_only_rule() -> None: text = README.read_text(encoding="utf-8") assert "repository-key.plist" in text, "README must name the committed public key" diff --git a/tests/test_void_smoke_gate.py b/tests/test_void_smoke_gate.py index fb8c38e..4d4088c 100644 --- a/tests/test_void_smoke_gate.py +++ b/tests/test_void_smoke_gate.py @@ -7,8 +7,9 @@ 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. +- ``packaging/void/smoke.sh`` — the gate command. It runs the same release + path as a tag (``build-release.sh smoke``), so a passing gate is a release + that would build. - 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. @@ -16,8 +17,8 @@ Seams under test (public interfaces, not internals): 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 +``packaging/void/container-release.sh`` owns the install smoke each +package must pass. The gate must agree with all of them; the container run itself is the proof, these tests are the wiring guard. """ @@ -28,8 +29,8 @@ 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" +RELEASE_PATH = VOID / "build-release.sh" +CONTAINER_RELEASE = VOID / "container-release.sh" PINS = VOID / "tool_checksums.txt" JUSTFILE = REPO_ROOT / "justfile" README = VOID / "README.md" @@ -66,9 +67,11 @@ def test_gate_builds_the_app_last() -> None: ), 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_delegates_to_the_release_path() -> None: + """One release path; the gate only selects the smoke mode, and its exit status is the path's.""" + text = _smoke_text() + assert 'exec "$HERE/build-release.sh" smoke' in text, "smoke.sh must exec the release path" + assert "build-release.sh" in RELEASE_PATH.read_text(encoding="utf-8") def test_gate_stays_podman_only_with_pinned_inputs() -> None: @@ -80,41 +83,35 @@ def test_gate_stays_podman_only_with_pinned_inputs() -> None: 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})") + """Shell lines of one case branch in the install phase of container-release.sh.""" + text = CONTAINER_RELEASE.read_text(encoding="utf-8") + start = text.index(f"\n\t\t{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_the_ggml_scan_belongs_to_the_whisper_binding_only() -> None: + """Only the whisper binding ships libggml; the payload check is its own.""" + assert "payload_pywhispercpp" in _case_branch("python3-pywhispercpp") + assert "payload_voculinux" not in _case_branch("python3-pywhispercpp") + text = CONTAINER_RELEASE.read_text(encoding="utf-8") + assert "libggml-cpu" in text and "libggml-vulkan" in text -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_the_leaf_packages_are_imported_headless() -> None: + """PyAudio and pynput are proven by import: pynput's dummy backend needs no display.""" + text = CONTAINER_RELEASE.read_text(encoding="utf-8") + assert "import pyaudio" in text + assert "PYNPUT_BACKEND=dummy" in text and "import pynput.keyboard" in text -def test_harness_rejects_packages_without_a_smoke_recipe() -> None: +def test_a_package_without_a_smoke_recipe_fails_the_release() -> None: """A future template falls into *) and must fail loudly, never pass mute.""" - branch = _case_branch("*") - assert "exit 1" in branch + text = CONTAINER_RELEASE.read_text(encoding="utf-8") + assert "no smoke recipe for package" in _case_branch("*") or ( + "no smoke recipe for package" in text + ) def test_just_recipe_calls_the_gate_like_other_packaging_gates() -> None: diff --git a/tests/test_xbps_release_workflow.py b/tests/test_xbps_release_workflow.py index cf68164..b8da32c 100644 --- a/tests/test_xbps_release_workflow.py +++ b/tests/test_xbps_release_workflow.py @@ -1,29 +1,25 @@ -"""Offline guards for the signed xbps release workflow (ticket #29). +"""Offline guards for the signed xbps release workflow (tickets #28 and #29). Releasing the Void repository is a tag push on the fork's Gitea host. The -workflow ``.gitea/workflows/xbps-release.yml`` gates the tag against -``src/vocalinux/version.py``, requires the ``XBPS_SIGNING_KEY`` secret, builds -and signs only through ``packaging/void/repo.sh`` (podman, pinned Void glibc -container), verifies the staged repository, and publishes it to the tag's -release and to the rolling ``latest`` release the client registration URL -serves. +workflow ``.gitea/workflows/xbps-release.yml`` gates the tag, stages the +signing key on tmpfs, runs ``packaging/void/build-release.sh`` (the one release +path), and publishes exactly what that script staged to the tag's release and +to the rolling ``latest`` release the client registration URL serves. -These tests parse the workflow and then *run* its gate, verify, build and -publish steps in a scratch directory, so they assert behaviour rather than -spelling: +These tests parse the workflow and then *run* its gate, key, tools, and publish +steps in a scratch directory, so they assert behaviour rather than spelling: -- the shell of each step is executed with the environment the workflow - itself declares (``${{ ... }}`` expressions are rendered from an explicit - context, so a step wired to the wrong secret or token fails loudly); -- ``curl`` is replaced by a small stateful stand-in for the Gitea release API - on ``PATH``, so the publish step's requests, their order, and its refusal to - continue past a non-2xx response are observable. +- each step's shell runs with the environment the workflow itself declares + (``${{ ... }}`` is rendered from an explicit context, so an unexpected + expression fails loudly); +- ``curl`` is replaced by a stateful stand-in for the Gitea release API on + PATH, so the publish step's requests, their order and its refusal to continue + past a non-2xx response are observable. Independent sources of truth: the tag/version pair comes from the test (not the -workflow), the repository layout comes from what ``xbps-rindex`` writes -(``.x86_64.xbps`` plus ``.x86_64.xbps.sig2`` and ``x86_64-repodata``), -and the Gitea REST contract (release create, asset upload and delete, tag -lookup) comes from the Gitea API documentation. +workflow), the staged layout is what ``build-release.sh`` writes, and the Gitea +REST contract (release create, asset upload and delete, tag lookup) comes from +the Gitea API documentation. """ import json @@ -33,7 +29,7 @@ import shutil import stat import subprocess import sys -import textwrap +import tempfile from pathlib import Path from typing import Dict, List, Optional from urllib.parse import unquote @@ -43,21 +39,24 @@ import yaml REPO_ROOT = Path(__file__).resolve().parents[1] WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml" +VOID = REPO_ROOT / "packaging" / "void" +SMOKE_SH = VOID / "smoke.sh" JOB = "xbps-release" GATE_TAG = "Require the tag to equal the version in version.py" GATE_KEY = "Require the repository signing key" -INSTALL_TOOLS = "Install the tools repo.sh and the publish step need" -BUILD = "Build and sign the repository" -VERIFY = "Verify the signed repository is complete" +TOOLS = "Check the runner has the release tools" +BUILD = "Build, sign and stage the repository" PUBLISH = "Publish to the tag release and the latest release" CLEANUP = "Remove the signing key" -STEP_ORDER = (GATE_TAG, GATE_KEY, INSTALL_TOOLS, BUILD, VERIFY, PUBLISH, CLEANUP) +STEP_ORDER = (GATE_TAG, GATE_KEY, TOOLS, BUILD, PUBLISH, CLEANUP) API = "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux" TOKEN = "test-token-not-a-secret" SHA = "0123456789abcdef0123456789abcdef01234567" +FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25" FAKE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nline-one\nline-two\n-----END RSA PRIVATE KEY-----\n" +SDIST = "voculinux-0.18.5.tar.gz" def _text() -> str: @@ -104,27 +103,29 @@ def _run_step( context: Optional[Dict[str, str]] = None, extra_env: Optional[Dict[str, str]] = None, path_prefix: Optional[Path] = None, + path: Optional[str] = None, ) -> subprocess.CompletedProcess: """Execute one step's ``run`` script with the env the workflow declares.""" ctx = {"gitea.run_id": "42", "gitea.token": TOKEN, "gitea.sha": SHA} ctx.update(context or {}) step = _step(name) env = {k: os.environ[k] for k in ("HOME", "LANG") if k in os.environ} - path = os.environ.get("PATH", "/usr/bin:/bin") - env["PATH"] = f"{path_prefix}{os.pathsep}{path}" if path_prefix else path + base = path if path is not None else os.environ.get("PATH", "/usr/bin:/bin") + env["PATH"] = f"{path_prefix}{os.pathsep}{base}" if path_prefix else base for source in (_job().get("env", {}), step.get("env", {})): for key, value in source.items(): env[key] = _render(str(value), ctx) env.update(extra_env or {}) script = cwd / f"{re.sub(r'[^a-z]+', '-', name.lower())}.sh" script.write_text(step["run"], encoding="utf-8") + bash = shutil.which("bash", path=os.environ.get("PATH", "/usr/bin:/bin")) or "/bin/bash" return subprocess.run( - ["bash", str(script)], + [bash, str(script)], cwd=cwd, env=env, capture_output=True, text=True, - timeout=30, + timeout=60, check=False, ) @@ -152,7 +153,7 @@ def test_runs_on_the_project_runner_with_no_third_party_actions() -> None: def test_steps_run_in_the_gated_order() -> None: - """Both gates precede any container; verify precedes publish; key cleanup is last.""" + """Both gates precede any container; the cleanup of the key is last.""" names = [s.get("name") for s in _job()["steps"]] assert names[0] == "Checkout" assert tuple(names[1:]) == STEP_ORDER, names @@ -163,7 +164,12 @@ def test_signing_key_is_removed_by_an_always_step() -> None: assert last["name"] == CLEANUP assert last["if"] == "always()" assert "rm -f" in last["run"] and "$XBPS_KEY_FILE" in last["run"] - assert _job()["env"]["XBPS_KEY_FILE"].startswith("/tmp/xbps-sign-key-") + + +def test_the_key_file_lives_on_tmpfs_under_a_per_run_name() -> None: + path = _job()["env"]["XBPS_KEY_FILE"] + assert path.startswith("/dev/shm/xbps-sign-key-"), path + assert "${{ gitea.run_id }}" in path def test_signing_secret_is_read_by_exactly_one_step() -> None: @@ -181,20 +187,33 @@ def test_the_tag_reaches_scripts_through_env_never_interpolated() -> None: for step in _job()["steps"]: assert "${{" not in step.get("run", ""), step.get("name") assert _step(GATE_TAG)["env"]["TAG"] == "${{ gitea.ref_name }}" + assert _step(BUILD)["env"]["TAG"] == "${{ gitea.ref_name }}" -def test_repo_sh_is_the_only_build_and_sign_path() -> None: - """No step builds, indexes or signs by hand; only repo.sh does, once.""" +def test_the_build_step_is_the_release_path_with_the_key_file_only() -> None: + """One path: build-release.sh release, handed the key file and nothing else.""" code = _without_comments(_text()) - assert code.count("packaging/void/repo.sh") == 1 - for forbidden in ("xbps-src", "xbps-rindex", "openssl rsa", "xlint", "gpg "): - assert forbidden not in code, f"{forbidden!r} belongs inside repo.sh, not the workflow" - assert "repo.sh" in _run_text(BUILD) + assert code.count("packaging/void/build-release.sh release") == 1 + run = _run_text(BUILD) + assert '--key-file "$XBPS_KEY_FILE"' in run + assert "--out-dir publish" in run + for forbidden in ("xbps-src", "xbps-rindex", "openssl rsa", "xlint", "gpg ", "repo.sh"): + assert forbidden not in code, f"{forbidden!r} belongs inside build-release.sh" + + +def test_no_step_installs_packages_and_the_runner_must_bring_its_tools() -> None: + """The job checks its tools fail-fast; it does not install them with apt.""" + code = _without_comments(_text()) + assert "apt-get" not in code + check = _run_text(TOOLS) + for tool in ("podman", "openssl", "git", "sha256sum", "python3.11", "uv"): + assert tool in check, f"the tool check must name {tool}" + assert "0.12.*" in check and "0.13.*" in check, "uv must be checked against the pinned range" def test_pipeline_uses_podman_and_never_docker() -> None: code = _without_comments(_text()).lower() - assert "podman" in _run_text(INSTALL_TOOLS) + assert "podman --version" in code assert "docker" not in code @@ -218,18 +237,31 @@ def test_publish_goes_to_the_fork_gitea_api_with_the_job_token() -> None: # --------------------------------------------------------------------------- -# Gate 1: the tag equals the version source of truth +# Gate 1: the tag equals the version source of truth, and the template agrees # --------------------------------------------------------------------------- -def _version_file(root: Path, body: str) -> None: - target = root / "src" / "vocalinux" / "version.py" - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(body, encoding="utf-8") +def _gate_tree(root: Path, version: str, template_version: str, template_tag: str) -> Path: + """A scratch repository with the real release script and a chosen version.""" + pkg = root / "packaging" / "void" + pkg.mkdir(parents=True) + for name in ("build-release.sh", "smoke.sh", "tool_checksums.txt", "repository-key.plist"): + shutil.copy2(VOID / name, pkg / name) + template = (VOID / "srcpkgs" / "voculinux" / "template").read_text(encoding="utf-8") + template = re.sub(r"^version=.*$", f"version={template_version}", template, flags=re.M) + template = re.sub(r"^_tag=.*$", f"_tag={template_tag}", template, flags=re.M) + (pkg / "srcpkgs" / "voculinux").mkdir(parents=True) + (pkg / "srcpkgs" / "voculinux" / "template").write_text(template, encoding="utf-8") + (root / "src" / "vocalinux").mkdir(parents=True) + (root / "src" / "vocalinux" / "version.py").write_text( + f'"""Version."""\n\n__version__ = "{version}"\n', encoding="utf-8" + ) + return root -def _gate_tag(tmp_path: Path, tag: str, version_py: str) -> subprocess.CompletedProcess: - _version_file(tmp_path, version_py) +def _gate_tag(tmp_path: Path, tag: str, version: str) -> subprocess.CompletedProcess: + xbps = version.replace("-", "") + _gate_tree(tmp_path, version, template_version=xbps, template_tag=f"v{version}") return _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": tag}) @@ -250,7 +282,7 @@ def _gate_tag(tmp_path: Path, tag: str, version_py: str) -> subprocess.Completed def test_tag_gate_requires_exact_v_plus_version( tmp_path: Path, tag: str, version: str, ok: bool ) -> None: - result = _gate_tag(tmp_path, tag, f'"""Version."""\n\n__version__ = "{version}"\n') + result = _gate_tag(tmp_path, tag, version) if ok: assert result.returncode == 0, result.stderr else: @@ -258,17 +290,42 @@ def test_tag_gate_requires_exact_v_plus_version( assert "refusing to publish" in result.stderr +def test_tag_gate_refuses_a_committed_template_for_another_release(tmp_path: Path) -> None: + """The tag may match version.py, but the template the build tree starts from must too.""" + _gate_tree(tmp_path, "0.19.0-beta", template_version="0.18.5", template_tag="v0.18.5") + result = _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": "v0.19.0-beta"}) + assert result.returncode == 1 + assert "committed Void template" in result.stderr + + def test_tag_gate_fails_when_the_version_cannot_be_read(tmp_path: Path) -> None: - result = _gate_tag(tmp_path, "v0.18.5", '"""Version."""\n__version_info__ = (0, 18, 5)\n') + _gate_tree(tmp_path, "0.18.5", template_version="0.18.5", template_tag="v0.18.5") + (tmp_path / "src" / "vocalinux" / "version.py").write_text( + '"""Version."""\n__version_info__ = (0, 18, 5)\n', encoding="utf-8" + ) + result = _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": "v0.18.5"}) assert result.returncode == 1 assert "__version__" in result.stderr # --------------------------------------------------------------------------- -# Gate 2: the signing key +# Gate 2: the signing key is staged on tmpfs, 0600, never echoed # --------------------------------------------------------------------------- +def _tmpfs_dir() -> Optional[Path]: + """A fresh directory on /dev/shm when that is tmpfs, or None on a host without one.""" + shm = Path("/dev/shm") + if not shm.is_dir(): + return None + fstype = subprocess.run( + ["stat", "-f", "-c", "%T", str(shm)], capture_output=True, text=True, check=False + ).stdout.strip() + if fstype != "tmpfs": + return None + return Path(tempfile.mkdtemp(prefix="xbps-key-test-", dir=str(shm))) + + def test_unset_signing_key_exits_1_and_writes_nothing(tmp_path: Path) -> None: key_file = tmp_path / "key.pem" result = _run_step( @@ -283,7 +340,15 @@ def test_unset_signing_key_exits_1_and_writes_nothing(tmp_path: Path) -> None: assert not key_file.exists(), "no key material may be written when the secret is empty" -def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None: +def test_a_key_file_outside_tmpfs_is_refused(tmp_path: Path) -> None: + """A key that would land on disk stops the job before the secret is written.""" + if ( + subprocess.run( + ["stat", "-f", "-c", "%T", str(tmp_path)], capture_output=True, text=True, check=False + ).stdout.strip() + == "tmpfs" + ): + pytest.skip("the test directory is itself tmpfs") key_file = tmp_path / "key.pem" result = _run_step( GATE_KEY, @@ -291,10 +356,49 @@ def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None {"secrets.XBPS_SIGNING_KEY": FAKE_KEY}, {"XBPS_KEY_FILE": str(key_file)}, ) - assert result.returncode == 0, result.stderr - assert key_file.read_text(encoding="utf-8") == FAKE_KEY - assert stat.S_IMODE(key_file.stat().st_mode) == 0o600 - assert FAKE_KEY.splitlines()[1] not in result.stdout + result.stderr, "key was echoed" + assert result.returncode == 1 + assert "not tmpfs" in result.stderr + assert not key_file.exists() + + +def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None: + shm = _tmpfs_dir() + if shm is None: + pytest.skip("no tmpfs at /dev/shm on this host") + try: + key_file = shm / "key.pem" + result = _run_step( + GATE_KEY, + tmp_path, + {"secrets.XBPS_SIGNING_KEY": FAKE_KEY}, + {"XBPS_KEY_FILE": str(key_file)}, + ) + assert result.returncode == 0, result.stderr + assert key_file.read_text(encoding="utf-8") == FAKE_KEY + assert stat.S_IMODE(key_file.stat().st_mode) == 0o600 + assert FAKE_KEY.splitlines()[1] not in result.stdout + result.stderr, "key was echoed" + finally: + shutil.rmtree(shm, ignore_errors=True) + + +def test_an_existing_key_file_is_never_reused(tmp_path: Path) -> None: + """Noclobber: a file already at the key path fails the job rather than being overwritten.""" + shm = _tmpfs_dir() + if shm is None: + pytest.skip("no tmpfs at /dev/shm on this host") + try: + key_file = shm / "key.pem" + key_file.write_text("someone else's file\n", encoding="utf-8") + result = _run_step( + GATE_KEY, + tmp_path, + {"secrets.XBPS_SIGNING_KEY": FAKE_KEY}, + {"XBPS_KEY_FILE": str(key_file)}, + ) + assert result.returncode != 0 + assert key_file.read_text(encoding="utf-8") == "someone else's file\n" + finally: + shutil.rmtree(shm, ignore_errors=True) def test_cleanup_step_removes_the_key_file(tmp_path: Path) -> None: @@ -306,155 +410,17 @@ def test_cleanup_step_removes_the_key_file(tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# Build: repo.sh is handed the key file, nothing else +# Tools: a runner without the release tools stops before any work # --------------------------------------------------------------------------- -def test_build_step_runs_repo_sh_with_the_key_file_only(tmp_path: Path) -> None: - """repo.sh gets XBPS_SIGNING_KEY_FILE and never the secret itself.""" - script = tmp_path / "packaging" / "void" / "repo.sh" - script.parent.mkdir(parents=True) - script.write_text( - textwrap.dedent("""\ - #!/bin/sh - set -eu - [ -s "$XBPS_SIGNING_KEY_FILE" ] || exit 7 - printf '%s' "$XBPS_SIGNING_KEY_FILE" > called-with - printf '%s' "${XBPS_SIGNING_KEY:-unset}" > secret-seen - mkdir -p publish/xbps/repo publish/xbps/keys - """), - encoding="utf-8", - ) - key_file = tmp_path / "key.pem" - key_file.write_text(FAKE_KEY, encoding="utf-8") - result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(key_file)}) - assert result.returncode == 0, result.stderr - assert (tmp_path / "called-with").read_text(encoding="utf-8") == str(key_file) - assert (tmp_path / "secret-seen").read_text(encoding="utf-8") == "unset" - - -def test_build_step_fails_when_repo_sh_fails(tmp_path: Path) -> None: - """A failing repo.sh (missing or non-RSA4096 key, build error) ends the job.""" - script = tmp_path / "packaging" / "void" / "repo.sh" - script.parent.mkdir(parents=True) - script.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") - result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(tmp_path / "k")}) - assert result.returncode != 0 - - -# --------------------------------------------------------------------------- -# Verify: the full signed glibc x86_64 repository, nothing else -# --------------------------------------------------------------------------- - -FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25" -SMOKE_SH = REPO_ROOT / "packaging" / "void" / "smoke.sh" - - -def _smoke_packages() -> List[str]: - """The templates the smoke gate builds, in order: the repository's package set.""" - match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M) - assert match, "smoke.sh must declare its ordered PKGS list" - return match.group(1).split() - - -#: One built package per template, versioned the way xbps-src names them. -PACKAGES = [f"{name}-1.0_1" for name in _smoke_packages()] - - -def _stage(root: Path, fingerprint: str = FINGERPRINT) -> Path: - """Lay out publish/xbps the way repo.sh stages it, plus the smoke gate's list.""" - smoke = root / "packaging" / "void" / "smoke.sh" - smoke.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(SMOKE_SH, smoke) - out = root / "publish" / "xbps" - repo, keys = out / "repo", out / "keys" - repo.mkdir(parents=True) - keys.mkdir() - for pkg in PACKAGES: - (repo / f"{pkg}.x86_64.xbps").write_bytes(b"package bytes " + pkg.encode()) - (repo / f"{pkg}.x86_64.xbps.sig2").write_bytes(b"signature " + pkg.encode()) - (repo / "x86_64-repodata").write_bytes(b"signed index") - (keys / "FINGERPRINT").write_text( - f"signed-by: Voculinux \nfingerprint: {fingerprint}\n", - encoding="utf-8", - ) - (keys / f"{fingerprint}.plist").write_text("\n", encoding="utf-8") - return out - - -def test_the_pinned_fingerprint_is_what_the_verify_step_checks() -> None: - assert _job()["env"]["XBPS_KEY_FINGERPRINT"] == FINGERPRINT - - -def test_verify_accepts_the_complete_signed_repository(tmp_path: Path) -> None: - _stage(tmp_path) - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 0, result.stderr - assert "complete signed glibc x86_64 repository" in result.stdout - - -@pytest.mark.parametrize( - "missing", - [ - f"repo/{PACKAGES[0]}.x86_64.xbps", - f"repo/{PACKAGES[-1]}.x86_64.xbps", - f"repo/{PACKAGES[0]}.x86_64.xbps.sig2", - "repo/x86_64-repodata", - "keys/FINGERPRINT", - f"keys/{FINGERPRINT}.plist", - ], -) -def test_verify_rejects_a_repository_missing_any_class(tmp_path: Path, missing: str) -> None: - out = _stage(tmp_path) - (out / missing).unlink() - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 1, f"removing {missing} must fail verification" - - -@pytest.mark.parametrize( - "extra", - [ - "repo/voculinux-1.0_1.x86_64-musl.xbps", - "repo/voculinux-1.0_1.aarch64.xbps", - "repo/voculinux-1.0_1.noarch.xbps", - "repo/x86_64-musl-repodata", - "repo/aarch64-repodata", - "repo/notes.txt", - "keys/private.pem", - ], -) -def test_verify_rejects_anything_but_glibc_x86_64_data(tmp_path: Path, extra: str) -> None: - out = _stage(tmp_path) - (out / extra).write_bytes(b"x") - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 1, f"{extra} must not be publishable" - - -def test_verify_rejects_an_unsigned_package(tmp_path: Path) -> None: - """One signature present does not excuse a package without its own.""" - out = _stage(tmp_path) - (out / "repo" / f"{PACKAGES[-1]}.x86_64.xbps.sig2").write_bytes(b"") - result = _run_step(VERIFY, tmp_path) +def test_a_runner_missing_the_release_tools_fails_fast(tmp_path: Path) -> None: + empty = tmp_path / "empty-bin" + empty.mkdir() + result = _run_step(TOOLS, tmp_path, path=str(empty)) assert result.returncode == 1 - assert "no signature" in result.stderr - - -def test_verify_rejects_a_signature_whose_package_is_gone(tmp_path: Path) -> None: - out = _stage(tmp_path) - (out / "repo" / f"{PACKAGES[0]}.x86_64.xbps.sig2").write_bytes(b"sig") - (out / "repo" / "ghost-1.0_1.x86_64.xbps.sig2").write_bytes(b"sig") - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 1 - assert "without its package" in result.stderr - - -def test_verify_rejects_a_repository_signed_by_another_key(tmp_path: Path) -> None: - """A throwaway or wrong key must never publish under the pinned fingerprint.""" - other = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff" - _stage(tmp_path, fingerprint=other) - result = _run_step(VERIFY, tmp_path) - assert result.returncode == 1 - assert FINGERPRINT in result.stderr and other in result.stderr + assert "lacks" in result.stderr and "podman" in result.stderr + assert "packaging/void/README.md" in result.stderr, "the message names the runner requirements" # --------------------------------------------------------------------------- @@ -647,8 +613,40 @@ class FakeGitea: ) +def _smoke_packages() -> List[str]: + """The templates the release builds, in order, from the smoke gate's list.""" + match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M) + assert match, "smoke.sh must declare its ordered PKGS list" + return match.group(1).split() + + +def _stage(root: Path) -> Path: + """Lay out publish/ the way build-release.sh stages a release.""" + out = root / "publish" + sdist = out / "sdist" + repo = out / "xbps" / "repo" + keys = out / "xbps" / "keys" + for directory in (sdist, repo, keys): + directory.mkdir(parents=True) + (sdist / SDIST).write_bytes(b"canonical sdist bytes") + (out / "SDIST_SHA256").write_text("a" * 64 + "\n", encoding="utf-8") + for name in _smoke_packages(): + pkg = f"{name}-0.18.5_1" + (repo / f"{pkg}.x86_64.xbps").write_bytes(b"package bytes " + pkg.encode()) + (repo / f"{pkg}.x86_64.xbps.sig2").write_bytes(b"signature " + pkg.encode()) + (repo / "x86_64-repodata").write_bytes(b"signed index") + (keys / "FINGERPRINT").write_text( + f"signed-by: Voculinux \nfingerprint: {FINGERPRINT}\n", + encoding="utf-8", + ) + (keys / f"{FINGERPRINT}.plist").write_text("\n", encoding="utf-8") + return out + + def _staged_names(out: Path) -> List[str]: - return sorted(p.name for sub in ("repo", "keys") for p in (out / sub).iterdir()) + return sorted( + p.name for sub in ("sdist", "xbps/repo", "xbps/keys") for p in (out / sub).iterdir() + ) @pytest.fixture() @@ -659,11 +657,12 @@ def staged(tmp_path: Path) -> Path: return tmp_path -def test_publish_attaches_the_full_repository_to_both_releases(staged: Path) -> None: +def test_publish_attaches_the_sdist_and_the_repository_to_both_releases(staged: Path) -> None: gitea = FakeGitea(staged) result = gitea.run_publish(staged) assert result.returncode == 0, result.stdout + result.stderr - expected = _staged_names(staged / "publish" / "xbps") + expected = _staged_names(staged / "publish") + assert SDIST in expected for tag in ("v0.18.5", "latest"): assert sorted(gitea.asset_names(tag)) == expected, tag uploaded = gitea.uploads(tag) @@ -672,6 +671,12 @@ def test_publish_attaches_the_full_repository_to_both_releases(staged: Path) -> assert FINGERPRINT in gitea.release("v0.18.5")["body"] +def test_publish_states_the_sdist_digest_in_the_notes(staged: Path) -> None: + gitea = FakeGitea(staged) + assert gitea.run_publish(staged).returncode == 0 + assert "sdist sha256: " + "a" * 64 in gitea.release("latest")["body"] + + def test_publish_creates_latest_at_the_workflow_commit(staged: Path) -> None: gitea = FakeGitea(staged) assert gitea.run_publish(staged).returncode == 0 @@ -692,37 +697,42 @@ def test_publish_talks_only_to_the_fork_api_authenticated(staged: Path) -> None: for entry in log: assert entry["url"].startswith(API + "/"), entry["url"] assert entry["auth"] == [f"Authorization: token {TOKEN}"], entry - # The tag release is created and filled before latest is touched. posts = [e["data"]["tag_name"] for e in log if e["url"] == API + "/releases"] - assert posts == ["v0.18.5", "latest"] + assert posts == ["v0.18.5", "latest"], "the tag release is filled before latest is touched" def test_publish_replaces_every_asset_on_an_existing_latest_release(staged: Path) -> None: gitea = FakeGitea(staged) gitea.seed_release("latest", ["voculinux-0.18.4_1.x86_64.xbps", "x86_64-repodata", "old.txt"]) assert gitea.run_publish(staged).returncode == 0 - assert sorted(gitea.asset_names("latest")) == _staged_names(staged / "publish" / "xbps") + assert sorted(gitea.asset_names("latest")) == _staged_names(staged / "publish") assert len([r for r in gitea.state["releases"].values() if r["tag_name"] == "latest"]) == 1 assert "v0.18.5" in gitea.release("latest")["body"] -def test_publish_rerun_keeps_foreign_assets_on_the_tag_release(staged: Path) -> None: - """Rerunning a tag replaces only its own files; hand-attached assets survive.""" +def test_publish_replaces_a_same_named_asset_on_a_rerun(staged: Path) -> None: + """A rerun of a tag replaces its own files, the sdist included: nothing stale survives.""" gitea = FakeGitea(staged) - gitea.seed_release("v0.18.5", ["x86_64-repodata", "voculinux-0.18.5.tar.gz"]) + gitea.seed_release("v0.18.5", [SDIST, "x86_64-repodata"]) + before = {a["name"]: a["id"] for a in gitea.release("v0.18.5")["assets"]} assert gitea.run_publish(staged).returncode == 0 - names = gitea.asset_names("v0.18.5") - assert "voculinux-0.18.5.tar.gz" in names - assert names.count("x86_64-repodata") == 1 - assert set(_staged_names(staged / "publish" / "xbps")) <= set(names) + after = {a["name"]: a["id"] for a in gitea.release("v0.18.5")["assets"]} + assert after[SDIST] != before[SDIST], "the sdist on the tag release must be the staged one" + assert after["x86_64-repodata"] != before["x86_64-repodata"] + assert sorted(after) == _staged_names(staged / "publish") -def test_publish_percent_encodes_the_colon_fingerprint_asset_name(staged: Path) -> None: +def test_publish_uploads_only_what_the_release_path_staged(staged: Path) -> None: + """Nothing is built or uploaded by hand: every upload lives under publish/.""" gitea = FakeGitea(staged) assert gitea.run_publish(staged).returncode == 0 - plist_urls = [e["url"] for e in gitea.state["log"] if ".plist" in e["url"]] - assert plist_urls and all(":" not in u.split("name=")[1] for u in plist_urls), plist_urls - assert f"{FINGERPRINT}.plist" in gitea.asset_names("latest") + staged_names = set(_staged_names(staged / "publish")) + for tag in ("v0.18.5", "latest"): + assert set(gitea.uploads(tag)) == staged_names + for entry in gitea.state["log"]: + for form in entry["forms"]: + path = re.match(r"attachment=@(.*?);", form).group(1) + assert path.startswith(("sdist/", "xbps/")), f"not from the staged set: {path}" @pytest.mark.parametrize( @@ -751,14 +761,17 @@ def test_publish_does_not_touch_latest_when_the_tag_release_fails(staged: Path) assert not any(r["tag_name"] == "latest" for r in gitea.state["releases"].values()) -def test_publish_uploads_only_staged_files(staged: Path) -> None: - """Nothing is built or created by hand: uploads are exactly the staged set.""" +def test_publish_percent_encodes_the_colon_fingerprint_asset_name(staged: Path) -> None: gitea = FakeGitea(staged) assert gitea.run_publish(staged).returncode == 0 - staged_names = set(_staged_names(staged / "publish" / "xbps")) - for tag in ("v0.18.5", "latest"): - assert set(gitea.uploads(tag)) == staged_names - for entry in gitea.state["log"]: - for form in entry["forms"]: - path = re.match(r"attachment=@(.*?);", form).group(1) - assert path.startswith(("repo/", "keys/")), path + plist_urls = [e["url"] for e in gitea.state["log"] if ".plist" in e["url"]] + assert plist_urls and all(":" not in u.split("name=")[1] for u in plist_urls), plist_urls + assert f"{FINGERPRINT}.plist" in gitea.asset_names("latest") + + +def test_publish_refuses_to_run_without_a_staged_sdist_digest(staged: Path) -> None: + (staged / "publish" / "SDIST_SHA256").unlink() + gitea = FakeGitea(staged) + result = gitea.run_publish(staged) + assert result.returncode != 0 + assert not gitea.state["log"], "nothing may be uploaded without the staged digest"