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).
This commit is contained in:
2026-11-06 20:16:46 +05:30
parent 05ef78922d
commit 80fb4365bd
10 changed files with 914 additions and 474 deletions
+19 -6
View File
@@ -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
+3 -1
View File
@@ -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
+1
View File
@@ -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
+2 -2
View File
@@ -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:
+30 -34
View File
@@ -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/<tag>``) 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:
+6 -7
View File
@@ -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}"
+559
View File
@@ -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 <t@example.com>",
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 <t@example.com>"
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
+26 -166
View File
@@ -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 "&lt;" in text and "&gt;" 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"
+31 -34
View File
@@ -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:
+237 -224
View File
@@ -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
(``<pkg>.x86_64.xbps`` plus ``<pkg>.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 <xbps@voculinux.com>\nfingerprint: {fingerprint}\n",
encoding="utf-8",
)
(keys / f"{fingerprint}.plist").write_text("<plist></plist>\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 <xbps@voculinux.com>\nfingerprint: {FINGERPRINT}\n",
encoding="utf-8",
)
(keys / f"{FINGERPRINT}.plist").write_text("<plist></plist>\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"