From 80fb4365bd2002a508a8417f3e94788a62c09e7f Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 20:16:46 +0530 Subject: [PATCH] test(void): guard the tag-built release path offline - build-release.sh: POSIX-only checks (dash runs the release), a release form is exactly X.Y.Z or X.Y.Z-alpha|beta|rcN, and the install smoke refuses a package that has no smoke recipe. - Tests: names and tag gate (exact match, committed template must agree), key refusals before any container starts (fake podman records calls), the render script pins only the build tree, podman wiring (only the sign phase sees the key, and it has no network), the xbps ordering of pre-release spellings, the workflow gates, tmpfs key staging, and the publish set (sdist, packages, signatures, repodata, client key). --- packaging/void/build-release.sh | 25 +- packaging/void/container-release.sh | 4 +- packaging/void/smoke.sh | 1 + tests/test_rename_sweep.py | 4 +- tests/test_void_app_template.py | 64 ++- tests/test_void_pywhispercpp_template.py | 13 +- tests/test_void_release_build.py | 559 +++++++++++++++++++++++ tests/test_void_signed_repo.py | 192 ++------ tests/test_void_smoke_gate.py | 65 ++- tests/test_xbps_release_workflow.py | 461 ++++++++++--------- 10 files changed, 914 insertions(+), 474 deletions(-) create mode 100644 tests/test_void_release_build.py 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"