Files
voculinux/tests/test_void_signed_repo.py
T
xavierk ba6462ccf8 refactor(ci): move the xbps job out of the GitHub release workflow
The signed repository is now published by the tag-triggered Gitea workflow,
so release.yml drops the build-xbps job, its place in the checksum job's
needs list, the xbps-repo artifact pattern, and the checksum lines for
files that no longer arrive. The release notes point at the Gitea release
instead of promising attached repository files.

The policy tests now assert the new shape: release.yml carries no xbps
job, and the Gitea workflow is the one place that runs repo.sh.
2026-11-06 18:26:14 +05:30

315 lines
13 KiB
Python

"""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.
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.
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,
``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.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml"
GITEA_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml"
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"
SMOKE_SH = VOID / "smoke.sh"
PINS = VOID / "tool_checksums.txt"
JUSTFILE = REPO_ROOT / "justfile"
README = VOID / "README.md"
#: The CI secret carrying the PEM-encoded RSA4096 repository private key.
SIGNING_SECRET = "XBPS_SIGNING_KEY"
#: The host-side path variable pointing at the key file. A file (not the
#: secret content on the command line) keeps newlines intact and the key
#: out of process listings.
KEY_FILE_VAR = "XBPS_SIGNING_KEY_FILE"
def _srcpkg_templates() -> list:
"""Every template directory under srcpkgs, in dependency build order."""
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)
assert match, "smoke.sh must declare its ordered PKGS 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 "4096" in text, "README must document RSA4096 key generation"
assert SIGNING_SECRET in text, "README must name the CI secret"
assert (
"fail" in text.lower() and "closed" in text.lower()
), "README must state the fail-closed rule: no key, no repo"
assert ".plist" in text, "README must document the client key install"
def _release_workflow_text() -> str:
assert RELEASE_WORKFLOW.is_file(), f"missing release workflow: {RELEASE_WORKFLOW}"
return RELEASE_WORKFLOW.read_text(encoding="utf-8")
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."""
github = _release_workflow_text()
for marker in (
"build-xbps",
"xbps-repo",
"XBPS_SIGNING_KEY",
"packaging/void/repo.sh",
):
assert (
marker not in github
), f"release.yml must not carry the xbps job ({marker})"
gitea = GITEA_WORKFLOW.read_text(encoding="utf-8")
assert (
"packaging/void/repo.sh" in gitea
), "the Gitea workflow must run the signed-repo pipeline"
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"
#: Concrete release-download prefix every client snippet must name. The
#: xbps job attaches the signed repo flat to the tag release with
#: ``gh release upload "${{ github.ref_name }}"``, so the repository URL is
#: the per-tag download prefix below.
CONCRETE_REPO_DOWNLOAD = "https://github.com/VocaHQ/vocalinux/releases/download/"
def test_client_registration_names_concrete_repo_url() -> None:
"""No self-referential placeholder: every client snippet names the
concrete release-download URL the xbps job attaches the repo to."""
workflow = _release_workflow_text()
root = ROOT_README.read_text(encoding="utf-8")
void_readme = README.read_text(encoding="utf-8")
for name, text in (
("release.yml", workflow),
("README.md", root),
("packaging/void/README.md", void_readme),
):
assert (
"<repo-url-from-release-notes>" not in text
), f"{name} still carries the self-referential repo-URL placeholder"
assert (
"repository=<" not in text
), f"{name} still carries a placeholder repository URL"
assert (
CONCRETE_REPO_DOWNLOAD in root
), "root README must name the concrete release download URL"
assert (
CONCRETE_REPO_DOWNLOAD in void_readme
), "void README must name the concrete release download URL"
def test_root_readme_registers_the_void_repo() -> None:
"""Root README: Void install from the signed repo, not just the track README."""
text = ROOT_README.read_text(encoding="utf-8")
assert "xbps" in text.lower(), "root README must name xbps"
for marker in ("FINGERPRINT", ".plist", "repository="):
assert (
marker in text
), f"root README must document client registration ({marker})"
assert "xbps-install" in text, "root README must give the install command"
def test_root_readme_states_void_scope_and_third_party_caveat() -> None:
"""glibc x86_64 only, and this is a third-party repo outside upstream Void."""
text = ROOT_README.read_text(encoding="utf-8").lower()
assert "x86_64" in text, "root README must scope the repo to x86_64"
assert "glibc" in text, "root README must scope the repo to glibc"
assert (
"third-party" in text or "third party" in text
), "root README must say the repo is third-party, not upstream Void"
def test_release_process_documents_void_template_bump_as_pr() -> None:
"""Template version/checksum bumps land as a pull request, never direct to main."""
text = RELEASE_PROCESS.read_text(encoding="utf-8")
assert (
"srcpkgs" in text or "xbps" in text.lower()
), "docs/RELEASE_PROCESS.md must cover the Void template bump"
assert re.search(
r"[Pp]ull request", text
), "docs/RELEASE_PROCESS.md must require the bump as a pull request"