The release is built on the maintainer's x86_64 Void host, not in CI. The bongbetic-ci runner is ARM64 and cannot build the glibc x86_64 package. A tag push no longer starts the xbps workflow: it is manual dispatch only, and its header says why. - build-release.sh release refuses an uncommitted or untracked checkout before any container starts. It writes BUILT_FROM (the tag and the commit it was built from), which nothing recorded before. - publish-release.sh TAG --out-dir DIR publishes a built out-dir to the Gitea release API in the workflow's order. It refuses unless origin's tag peels to the BUILT_FROM commit, the sdist digest matches, and the client key is the committed one. --dry-run runs every check and prints the planned calls without sending one. The token comes from GITEA_TOKEN or the tea login and is never printed. - Tests run both scripts offline against a local bare origin and a stateful Gitea stand-in. - docs/RELEASE_PROCESS.md and packaging/void/README.md describe the local sequence: merge, annotated tag by ref, clean build, dry-run, publish, checksum.
630 lines
25 KiB
Python
630 lines
25 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",
|
|
)
|
|
# A release is built from a clean git checkout, so the fixture is one: committed,
|
|
# with nothing left uncommitted. Tests that want a dirty tree change it after this.
|
|
git = ["git", "-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid"]
|
|
subprocess.run(["git", "init", "-q"], cwd=root, check=True, capture_output=True)
|
|
subprocess.run(["git", "add", "-A"], cwd=root, check=True, capture_output=True)
|
|
subprocess.run(
|
|
[*git, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "fixture"],
|
|
cwd=root,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
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("release_checkout"),
|
|
pipeline.index("check_key"),
|
|
pipeline.index("build_sdist"),
|
|
pipeline.index("phase_build"),
|
|
]
|
|
assert order == sorted(order), order
|
|
|
|
|
|
def test_a_release_records_the_commit_it_is_built_from() -> None:
|
|
"""publish-release.sh refuses unless origin's tag peels to this commit."""
|
|
text = _code(BUILD_RELEASE)
|
|
pipeline = _body(text, "run_pipeline")
|
|
assert 'printf \'tag=%s\\ncommit=%s\\n\' "$TAG" "$SOURCE_COMMIT" >"$OUT/BUILT_FROM"' in pipeline
|
|
assert "rev-parse --verify HEAD" in _body(text, "release_checkout")
|
|
|
|
|
|
@pytest.fixture()
|
|
def no_container(tmp_path: Path) -> tuple:
|
|
"""A podman that must never run, with a log that proves it did not."""
|
|
bindir = tmp_path / "nocontainer-bin"
|
|
bindir.mkdir()
|
|
log = tmp_path / "nocontainer-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 test_a_release_refuses_an_uncommitted_checkout_before_anything_runs(
|
|
tmp_path: Path, no_container: tuple
|
|
) -> None:
|
|
path, log = no_container
|
|
tree = make_tree(tmp_path / "tree", "0.18.5", template_version="0.18.5", template_tag="v0.18.5")
|
|
(tree / "scratch.txt").write_text("not committed\n", encoding="utf-8")
|
|
out = tmp_path / "out"
|
|
tmp = tmp_path / "work"
|
|
tmp.mkdir()
|
|
result = run_build(tree, "release", "v0.18.5", "--out-dir", str(out), path=path, tmp=tmp)
|
|
assert result.returncode == 1, result.stdout + result.stderr
|
|
assert "uncommitted or untracked changes" in result.stderr
|
|
assert not log.exists() and not out.exists()
|
|
|
|
|
|
def test_a_release_refuses_a_tree_that_is_not_a_git_checkout(
|
|
tmp_path: Path, no_container: tuple
|
|
) -> None:
|
|
path, log = no_container
|
|
tree = make_tree(tmp_path / "tree", "0.18.5", template_version="0.18.5", template_tag="v0.18.5")
|
|
shutil.rmtree(tree / ".git")
|
|
out = tmp_path / "out"
|
|
tmp = tmp_path / "work"
|
|
tmp.mkdir()
|
|
result = run_build(tree, "release", "v0.18.5", "--out-dir", str(out), path=path, tmp=tmp)
|
|
assert result.returncode == 1, result.stdout + result.stderr
|
|
assert "is not one" in result.stderr
|
|
assert not log.exists() and not out.exists()
|
|
|
|
|
|
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"
|