Add build-xbps tag job to release.yml: verifies the tag against src/vocalinux/version.py, fails closed when XBPS_SIGNING_KEY is unset, builds and signs via podman-only repo.sh, and attaches packages, signatures, repodata, and client key files to the release. Fold the xbps-repo artifact into publish-checksums (manifest + SHA256SUMS). Document the Void install in the root README (glibc x86_64 scope, fingerprint check, third-party repo caveat) and the post-tag template version/checksum bump as a pull request in docs/RELEASE_PROCESS.md. Proof: local podman run with a throwaway RSA4096 key built all four templates, signed packages + repodata, and verified voculinux installs from the signed repo in a clean rootdir (fingerprint eb:29:5d:9e:5c:94:c1:63:ae:35:cd:35:db:7a:28:da).
297 lines
13 KiB
Python
297 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"
|
|
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 "<" in text and ">" in text, (
|
|
"the plist writer must XML-escape the signer identity "
|
|
"(xbps shows Fingerprint and refuses the import otherwise)"
|
|
)
|
|
|
|
|
|
def test_container_proves_signatures_with_a_clean_rootdir_install() -> None:
|
|
"""The pipeline verifies what it signed: a fresh rootdir install."""
|
|
text = _container_text()
|
|
assert (
|
|
"xbps-install" in text and "-r " in text
|
|
), "container-repo.sh must prove the signed repo with a rootdir install"
|
|
assert "voculinux" in text, "the proof install must be the app, resolving deps from the repo"
|
|
|
|
|
|
def test_just_recipe_calls_the_pipeline_like_other_packaging_gates() -> None:
|
|
"""`just void-repo` runs the pipeline script with no shared-output step."""
|
|
recipe = re.search(
|
|
r"^void-repo:([^\n]*)\n((?:[ \t].*\n|\n)+)",
|
|
JUSTFILE.read_text(encoding="utf-8"),
|
|
re.MULTILINE,
|
|
)
|
|
assert recipe, "justfile must define a void-repo recipe"
|
|
assert not recipe.group(1).strip(), "void-repo must not take a shared-output prerequisite"
|
|
assert "packaging/void/repo.sh" in recipe.group(2), "void-repo must run packaging/void/repo.sh"
|
|
|
|
|
|
def test_readme_documents_the_signed_repo_flow() -> None:
|
|
"""Maintainer key setup, CI secret, fail-closed rule, and client setup."""
|
|
text = README.read_text(encoding="utf-8")
|
|
assert "void-repo" in text or "repo.sh" in text, "README must document the signed-repo command"
|
|
assert "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 _xbps_job_text() -> str:
|
|
"""The release xbps job block only — other jobs have their own runtimes."""
|
|
text = _release_workflow_text()
|
|
lines = text.splitlines(keepends=True)
|
|
start = next(
|
|
(i for i, line in enumerate(lines) if re.match(r"^ build-xbps:", line)),
|
|
None,
|
|
)
|
|
assert start is not None, "release.yml must define a build-xbps job"
|
|
end = next(
|
|
(
|
|
i
|
|
for i, line in enumerate(lines[start + 1 :], start + 1)
|
|
if re.match(r"^ [a-z][a-z0-9-]*:", line)
|
|
),
|
|
len(lines),
|
|
)
|
|
return "".join(lines[start:end])
|
|
|
|
|
|
def test_release_workflow_builds_signed_xbps_on_tag() -> None:
|
|
"""The tag pipeline builds the signed repo and attaches it to the release."""
|
|
text = _xbps_job_text()
|
|
assert (
|
|
"packaging/void/repo.sh" in text
|
|
), "the xbps job must run the signed-repo pipeline, not a second inline flow"
|
|
assert "podman" in text, "the release xbps job must install podman (never Docker)"
|
|
assert "docker" not in text.lower().replace(
|
|
"never docker", ""
|
|
), "the release xbps job must never invoke Docker"
|
|
|
|
|
|
def test_release_workflow_xbps_job_fails_closed_without_key() -> None:
|
|
"""An unset XBPS_SIGNING_KEY fails the xbps job — never a silent skip."""
|
|
text = _xbps_job_text()
|
|
assert SIGNING_SECRET in text, "the xbps job must read the XBPS_SIGNING_KEY secret"
|
|
assert re.search(
|
|
r"XBPS_SIGNING_KEY.{0,400}?exit 1", text, re.DOTALL
|
|
), "an empty XBPS_SIGNING_KEY must exit 1, not skip the publish"
|
|
|
|
|
|
def test_release_workflow_xbps_job_gates_tag_against_version() -> None:
|
|
"""The xbps job only ships the repo when the tag matches version.py."""
|
|
text = _xbps_job_text()
|
|
assert "version.py" in text, "the xbps job must gate on version.py"
|
|
assert re.search(
|
|
r"[Tt]ag.{0,200}?[Vv]ersion|[Vv]ersion.{0,200}?[Tt]ag", text
|
|
), "release.yml must compare the tag against the version source of truth"
|
|
|
|
|
|
def test_release_workflow_attaches_signed_repo_to_release() -> None:
|
|
"""Packages, signatures, repodata, and the client key land on the release."""
|
|
text = _xbps_job_text()
|
|
assert "gh release upload" in text, "the xbps job must attach its output to the release"
|
|
for marker in ("*.x86_64.xbps", "repodata", "FINGERPRINT"):
|
|
assert marker in text, f"the xbps job must attach {marker} to the release"
|
|
|
|
|
|
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"
|