feat(void): add signed xbps repository pipeline
Build every template in smoke-gate order, index the full repository and sign packages plus metadata with the RSA4096 key, in the pinned Void container via podman. Fail-closed without a key: no key file, no repo. Ticket #29 (needs #27, #28).
This commit is contained in:
@@ -178,3 +178,6 @@ opentree.toml
|
||||
# Nix build output symlinks
|
||||
result
|
||||
result-*
|
||||
|
||||
# Signed xbps repository staging (ticket #29, local build output)
|
||||
/publish/
|
||||
|
||||
@@ -134,6 +134,15 @@ native-smoke distro="debian:12":
|
||||
void-smoke:
|
||||
bash packaging/void/smoke.sh
|
||||
|
||||
# Build every Void template, index the full repository and sign packages
|
||||
# plus metadata with the RSA4096 repository key, in the pinned Void
|
||||
# container via podman (never Docker), as ticket #29 pipelines. Fails
|
||||
# closed without a key: set XBPS_SIGNING_KEY_FILE (CI writes the
|
||||
# XBPS_SIGNING_KEY secret to a file first). Needs no host Void install;
|
||||
# run `just void-smoke` first — the repo assembles tested packages.
|
||||
void-repo:
|
||||
bash packaging/void/repo.sh
|
||||
|
||||
# Build the canonical sdist, prepare an explicitly unattested local PKGBUILD,
|
||||
# then build/install it unchanged on current Arch (needs uv and docker).
|
||||
# Keep distributions and diagnostics away from other packaging commands' outputs.
|
||||
|
||||
@@ -12,6 +12,8 @@ packaging/void/
|
||||
├── README.md this file
|
||||
├── build.sh podman harness: lint + xbps-src build + import smoke
|
||||
├── smoke.sh ticket #28 gate: every template, one command
|
||||
├── repo.sh ticket #29 pipeline: build all, index, sign
|
||||
├── container-repo.sh ticket #29 container half (index + sign + verify)
|
||||
├── tool_checksums.txt pinned base image and void-packages commit
|
||||
└── srcpkgs/
|
||||
├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding
|
||||
@@ -130,3 +132,52 @@ prints `FAIL: <pkgname>` naming which package broke, above it the
|
||||
harness echoes which stage broke. `tests/test_void_smoke_gate.py`
|
||||
asserts offline that the gate stays wired to every `srcpkgs` template,
|
||||
so a renamed or added template cannot silently drop out.
|
||||
|
||||
## Signed repository
|
||||
|
||||
`packaging/void/repo.sh` builds every template in smoke-gate order, indexes
|
||||
the full repository (`x86_64-repodata`), and signs every binary package
|
||||
(`*.sig2`) plus the repository metadata with the RSA4096 repository key —
|
||||
one command, same pinned container via podman, no host Void install:
|
||||
|
||||
```sh
|
||||
# Run the smoke gate first: the repo assembles tested packages.
|
||||
packaging/void/smoke.sh
|
||||
# or: just void-smoke
|
||||
|
||||
printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem
|
||||
XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh
|
||||
# or: XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem just void-repo
|
||||
```
|
||||
|
||||
The pipeline stages `publish/xbps/repo/` (packages, signatures, repodata)
|
||||
plus `publish/xbps/keys/` (the client `.plist` named by key fingerprint,
|
||||
and a `FINGERPRINT` file carrying the signer identity and fingerprint for
|
||||
release notes). `publish/` is local build output and stays uncommitted.
|
||||
|
||||
Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before any
|
||||
container runs, so an unsigned repository can never publish. CI holds the
|
||||
PEM-encoded key in the `XBPS_SIGNING_KEY` secret and writes it to a file
|
||||
first (a file keeps newlines intact and the key out of process listings).
|
||||
|
||||
**Maintainer key setup (one time):**
|
||||
|
||||
```sh
|
||||
openssl genrsa -out xbps-sign-key.pem 4096
|
||||
# back the key up somewhere safe, then store it as XBPS_SIGNING_KEY
|
||||
```
|
||||
|
||||
**Client setup:**
|
||||
|
||||
```sh
|
||||
# as root on the Void system (the release notes carry the repo URL):
|
||||
cp <fingerprint>.plist /var/db/xbps/keys/
|
||||
echo 'repository=<repo-url-from-release-notes>' > /etc/xbps.d/voculinux.conf
|
||||
xbps-install -Sy voculinux
|
||||
```
|
||||
|
||||
Answer `Y` when xbps shows the fingerprint prompt on first sync, and check
|
||||
it matches the published `FINGERPRINT` file. `tests/test_void_signed_repo.py`
|
||||
asserts offline that the pipeline stays wired to every `srcpkgs` template,
|
||||
stays podman-only on the pinned inputs, and keeps the fail-closed key
|
||||
contract.
|
||||
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/bin/sh
|
||||
# Repository half of packaging/void/repo.sh (ticket #29). Runs inside the
|
||||
# pinned Void container as root: system setup, the void-packages checkout
|
||||
# at the pinned commit, then every template built in smoke-gate order as
|
||||
# the unprivileged builder user (xbps-src refuses root), then index, sign,
|
||||
# verify and stage as root again.
|
||||
#
|
||||
# Not executed directly: repo.sh mounts this file and runs it. VOID_COMMIT,
|
||||
# PKGS and SIGNEDBY arrive via the environment; the RSA4096 private key is
|
||||
# mounted read-only at /run/xbps-sign-key.pem (repo.sh validated it before
|
||||
# any container ran).
|
||||
#
|
||||
# Keeping this a real file (instead of an inline `sh -c` string) means
|
||||
# normal quoting, heredocs, and shellcheck apply.
|
||||
set -eu
|
||||
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
KEY=/run/xbps-sign-key.pem
|
||||
OUT=/mnt/out
|
||||
REPO="$OUT/repo"
|
||||
KEYS="$OUT/keys"
|
||||
|
||||
[ -r "$KEY" ] || {
|
||||
echo "container-repo.sh: signing key not mounted at $KEY" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
xbps-install -Suy xbps >/dev/null
|
||||
xbps-install -y git base-devel xtools binutils zstd openssl >/dev/null
|
||||
git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages
|
||||
git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT"
|
||||
git -C /void-packages checkout --quiet "$VOID_COMMIT"
|
||||
cp -r /overlay/. /void-packages/srcpkgs/
|
||||
id builder >/dev/null 2>&1 || useradd -m builder
|
||||
chown builder:builder /void-packages
|
||||
|
||||
echo "== build every template in order: $PKGS"
|
||||
cd /void-packages
|
||||
# shellcheck disable=SC2086
|
||||
for PKG in $PKGS; do
|
||||
echo "== xlint $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && xlint 'srcpkgs/$PKG/template'"
|
||||
echo "== xbps-src pkg $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && ./xbps-src pkg '$PKG'"
|
||||
done
|
||||
|
||||
echo "== collect built packages"
|
||||
mkdir -p "$REPO" "$KEYS"
|
||||
# shellcheck disable=SC2086
|
||||
for PKG in $PKGS; do
|
||||
PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKG"-*.x86_64.xbps 2>/dev/null | head -n1)
|
||||
[ -n "$PKGFILE" ] || {
|
||||
echo "FAIL: $PKG (built package missing from hostdir/binpkgs)" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "collected: $(basename "$PKGFILE")"
|
||||
cp "$PKGFILE" "$REPO/"
|
||||
done
|
||||
|
||||
echo "== index the full repository"
|
||||
# shellcheck disable=SC2086
|
||||
xbps-rindex -a "$REPO"/*.x86_64.xbps
|
||||
|
||||
echo "== sign every binary package"
|
||||
xbps-rindex -S --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"/*.x86_64.xbps
|
||||
|
||||
echo "== sign the repository metadata"
|
||||
xbps-rindex -s --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"
|
||||
|
||||
echo "== prove an unsigned install is refused"
|
||||
set +e
|
||||
PROBE=$(xbps-install -r /tmp/keyprobe --repository="$REPO" -Sy voculinux </dev/null 2>&1)
|
||||
PROBE_RC=$?
|
||||
set -e
|
||||
if [ "$PROBE_RC" -eq 0 ] || ! printf '%s' "$PROBE" | grep -q "RSA signed"; then
|
||||
echo "FAIL: unsigned install was not refused — signatures are not enforced" >&2
|
||||
printf '%s\n' "$PROBE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "unsigned install refused as required"
|
||||
|
||||
FP=$(printf '%s' "$PROBE" | grep -i Fingerprint | awk '{print $2}' | head -n1)
|
||||
[ -n "$FP" ] || {
|
||||
echo "FAIL: could not read the key fingerprint from the refusal" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "key fingerprint: $FP"
|
||||
|
||||
echo "== stage the client key"
|
||||
openssl rsa -in "$KEY" -pubout -out /tmp/repo-pub.pem 2>/dev/null
|
||||
PUB64=$(base64 -w0 /tmp/repo-pub.pem)
|
||||
# XML-escape the signer identity: xbps writes </> itself on import
|
||||
# and rejects a plist carrying raw angle brackets.
|
||||
ESCAPED_SIGNEDBY=$(printf '%s' "$SIGNEDBY" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g')
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||
printf '%s\n' '<plist version="1.0">' '<dict>' ' <key>public-key</key>'
|
||||
printf ' <data>%s</data>\n' "$PUB64"
|
||||
printf '%s\n' ' <key>public-key-size</key>' ' <integer>4096</integer>' ' <key>signature-by</key>'
|
||||
printf ' <string>%s</string>\n' "$ESCAPED_SIGNEDBY"
|
||||
printf '%s\n' '</dict>' '</plist>'
|
||||
} > "$KEYS/$FP.plist"
|
||||
{
|
||||
printf 'signed-by: %s\n' "$SIGNEDBY"
|
||||
printf 'fingerprint: %s\n' "$FP"
|
||||
} > "$KEYS/FINGERPRINT"
|
||||
echo "staged: keys/$FP.plist"
|
||||
|
||||
echo "== verify: install the app from the signed repo in a clean rootdir"
|
||||
VERIFY=/tmp/verify-install
|
||||
mkdir -p "$VERIFY/var/db/xbps/keys"
|
||||
# The app's full dependency tree resolves from the official remote, so the
|
||||
# verify rootdir trusts the official keys too; our repo stays enforced
|
||||
# because its packages verify only against the staged key below.
|
||||
cp /var/db/xbps/keys/*.plist "$VERIFY/var/db/xbps/keys/"
|
||||
cp "$KEYS/$FP.plist" "$VERIFY/var/db/xbps/keys/"
|
||||
xbps-install -y -r "$VERIFY" \
|
||||
--repository="$REPO" \
|
||||
--repository=https://repo-default.voidlinux.org/current \
|
||||
-Sy voculinux
|
||||
xbps-query -r "$VERIFY" -l | grep -q "^ii voculinux-" || {
|
||||
echo "FAIL: voculinux not installed in the verify rootdir" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "verified: voculinux installs from the signed repository"
|
||||
|
||||
ls "$REPO"
|
||||
echo "SIGNED REPO OK: fingerprint $FP"
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/bin/sh
|
||||
# Signed xbps repository pipeline (ticket #29): build every template in
|
||||
# smoke-gate order, index the full repository, and sign every binary
|
||||
# package plus the repository metadata with the RSA4096 repository key.
|
||||
# Runs in the pinned Void container via podman, never Docker; no host
|
||||
# Void install needed.
|
||||
#
|
||||
# Usage: XBPS_SIGNING_KEY_FILE=/path/to/key.pem packaging/void/repo.sh
|
||||
# or: XBPS_SIGNING_KEY_FILE=/path/to/key.pem just void-repo
|
||||
#
|
||||
# Options (all optional):
|
||||
# --key-file PATH repository private key (default: $XBPS_SIGNING_KEY_FILE)
|
||||
# --out-dir DIR stage the signed repo here (default: publish/xbps)
|
||||
# --signed-by "N <m>" signer identity (default: Voculinux <xbps@voculinux.com>)
|
||||
#
|
||||
# The private key arrives as a file so CI can write the XBPS_SIGNING_KEY
|
||||
# secret to disk with newlines intact:
|
||||
#
|
||||
# printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem
|
||||
# XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh
|
||||
#
|
||||
# Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before
|
||||
# any container runs, so an unsigned repository can never publish.
|
||||
set -eu
|
||||
|
||||
CDPATH=""
|
||||
HERE=$(dirname -- "$0")
|
||||
HERE=$(cd -- "$HERE" && pwd)
|
||||
REPO_ROOT=$(cd -- "$HERE/../.." && pwd)
|
||||
|
||||
KEY_FILE=${XBPS_SIGNING_KEY_FILE:-}
|
||||
OUT_DIR="$REPO_ROOT/publish/xbps"
|
||||
SIGNEDBY="Voculinux <xbps@voculinux.com>"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--key-file) KEY_FILE=${2:?usage: repo.sh --key-file PATH} ; shift 2 ;;
|
||||
--out-dir) OUT_DIR=${2:?usage: repo.sh --out-dir DIR} ; shift 2 ;;
|
||||
--signed-by) SIGNEDBY=${2:?usage: repo.sh --signed-by "Name <mail>"} ; shift 2 ;;
|
||||
-*) echo "repo.sh: unknown option: $1" >&2 ; exit 1 ;;
|
||||
*) echo "repo.sh: unexpected argument: $1" >&2 ; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Fail closed before any container runs: no key, no repo, no publish.
|
||||
[ -n "$KEY_FILE" ] || {
|
||||
echo "repo.sh: no signing key: pass --key-file or set XBPS_SIGNING_KEY_FILE" >&2
|
||||
echo "repo.sh: CI writes the XBPS_SIGNING_KEY secret to a file first:" >&2
|
||||
echo "repo.sh: printf '%s' \"\$XBPS_SIGNING_KEY\" > /tmp/xbps-sign-key.pem" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] || {
|
||||
echo "repo.sh: signing key missing or empty: $KEY_FILE" >&2
|
||||
echo "repo.sh: refusing to publish an unsigned repository" >&2
|
||||
exit 1
|
||||
}
|
||||
openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 || {
|
||||
echo "repo.sh: not a valid RSA private key: $KEY_FILE" >&2
|
||||
echo "repo.sh: refusing to publish an unsigned repository" >&2
|
||||
exit 1
|
||||
}
|
||||
openssl rsa -in "$KEY_FILE" -text -noout 2>/dev/null | head -1 | grep -q "4096 bit" || {
|
||||
echo "repo.sh: signing key is not RSA4096: $KEY_FILE" >&2
|
||||
echo "repo.sh: generate one with: openssl genrsa -out xbps-sign-key.pem 4096" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
|
||||
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
|
||||
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || {
|
||||
echo "repo.sh: pins missing from tool_checksums.txt" >&2
|
||||
exit 1
|
||||
}
|
||||
# One dependency order for the whole track: the signed repo builds the
|
||||
# same set in the same order as the smoke gate (the app last), so a
|
||||
# renamed or added template cannot silently drop out of either.
|
||||
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh")
|
||||
[ -n "$PKGS" ] || {
|
||||
echo "repo.sh: could not read the package order from smoke.sh" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
echo "Pulling pinned base image: $BASE_IMAGE"
|
||||
podman pull "$BASE_IMAGE"
|
||||
|
||||
podman run --rm \
|
||||
-v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \
|
||||
-v "$HERE/container-repo.sh:/mnt/container-repo.sh:ro" \
|
||||
-v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \
|
||||
-v "$OUT_DIR:/mnt/out" \
|
||||
-e "VOID_COMMIT=$VOID_COMMIT" \
|
||||
-e "PKGS=$PKGS" \
|
||||
-e "SIGNEDBY=$SIGNEDBY" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-repo.sh
|
||||
echo "OK: signed repository staged in $OUT_DIR"
|
||||
@@ -0,0 +1,200 @@
|
||||
"""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]
|
||||
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"
|
||||
Reference in New Issue
Block a user