Files
voculinux/tests/test_void_release_build.py
T
xavierk d4ac89f0a1
CI / security (pull_request) Successful in 1m22s
CI / lint (pull_request) Successful in 23s
CI / ai-review (pull_request) Successful in 8s
fix(void): stage the sdist into the publish set before verifying it
The completeness check requires sdist/<name> in the output directory, but
nothing copied the built sdist there. Copy it from the build work directory
before the signing phase, and pin that order in a test.
2026-11-06 20:34:37 +05:30

568 lines
22 KiB
Python

"""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
def test_the_sdist_is_staged_for_publication_before_the_completeness_check() -> None:
"""The publish set is DIR/sdist plus DIR/xbps; the sdist must be copied there first."""
pipeline = _body(_code(BUILD_RELEASE), "run_pipeline")
assert 'mkdir -p "$OUT/sdist"' in pipeline
assert 'cp "$WORK/inputs/$SDIST_NAME" "$OUT/sdist/$SDIST_NAME"' in pipeline
assert pipeline.index('cp "$WORK/inputs/') < pipeline.index("verify_set"), "stage, then verify"