diff --git a/.gitea/workflows/xbps-release.yml b/.gitea/workflows/xbps-release.yml new file mode 100644 index 0000000..9f37c33 --- /dev/null +++ b/.gitea/workflows/xbps-release.yml @@ -0,0 +1,326 @@ +name: xbps-release + +# Signed Void xbps repository release (ticket #29). A human pushes a version +# tag; this workflow builds every Void template in the pinned Void glibc +# container with podman (packaging/void/repo.sh), indexes the full x86_64 +# repository, signs every package and the repository metadata with the RSA4096 +# repository key, and attaches the complete signed repository to two releases: +# +# - the tag's own release (the permanent record of what that tag shipped), and +# - the rolling release named `latest`, because the client registration URL +# (README.md) serves the newest release's downloads: +# https://git.bongbetic.com/xavierk/voculinux/releases/download/latest +# +# Hard gates, each exiting 1 before anything is published: +# 1. the tag must EXACTLY equal "v" + __version__ in src/vocalinux/version.py; +# 2. the XBPS_SIGNING_KEY secret must be non-empty (no key, no repo, never a +# silent skip and never an unsigned upload); +# 3. every class of repository file must exist, nothing but glibc x86_64 data +# may exist, every package must carry its signature, and the signing key's +# fingerprint must equal the one the README tells users to pin. +# +# Nothing here commits or pushes to any branch, and nothing bumps the Void +# templates: the post-release template bump is a normal pull request +# (docs/RELEASE_PROCESS.md). The private key exists only as the CI secret and a +# 0600 temp file that the last step removes. Never Docker: podman only. + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +concurrency: + group: xbps-release + cancel-in-progress: false + +jobs: + xbps-release: + name: Build, sign and publish the xbps repository + runs-on: bongbetic-ci + timeout-minutes: 180 + env: + GITEA_API: https://git.bongbetic.com/api/v1/repos/xavierk/voculinux + # Must equal the fingerprint in README.md, packaging/void/README.md and + # packaging/void/repository-key.plist; tests/test_xbps_release_workflow.py + # enforces it. A secret holding any other key fails before publishing. + XBPS_KEY_FINGERPRINT: "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25" + XBPS_KEY_FILE: /tmp/xbps-sign-key-${{ gitea.run_id }}.pem + steps: + - name: Checkout + env: + GITEA_TOKEN: ${{ gitea.token }} + SERVER_URL: ${{ gitea.server_url }} + REPOSITORY: ${{ gitea.repository }} + COMMIT_SHA: ${{ gitea.sha }} + run: | + git init -q . && git remote add origin "${SERVER_URL}/${REPOSITORY}.git" && git -c http.extraheader="Authorization: token ${GITEA_TOKEN}" fetch -q --depth=1 origin "${COMMIT_SHA}" && git checkout -q FETCH_HEAD + + - name: Require the tag to equal the version in version.py + shell: bash + env: + TAG: ${{ gitea.ref_name }} + run: | + set -euo pipefail + VERSION=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' src/vocalinux/version.py) + if [ -z "$VERSION" ]; then + echo "::error::could not read __version__ from src/vocalinux/version.py; refusing to publish" >&2 + exit 1 + fi + echo "tag: ${TAG}" + echo "version: v${VERSION}" + if [ "$TAG" != "v${VERSION}" ]; then + echo "::error::tag ${TAG} is not exactly v${VERSION} (src/vocalinux/version.py); refusing to publish the signed repository" >&2 + exit 1 + fi + + # Fail closed: no key, no repo, no publish. This runs before any + # container so a missing secret costs seconds, not a build. + - name: Require the repository signing key + shell: bash + env: + XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }} + run: | + set -euo pipefail + if [ -z "${XBPS_SIGNING_KEY:-}" ]; then + echo "::error::XBPS_SIGNING_KEY is unset or empty; refusing to publish an unsigned repository (packaging/void/README.md)" >&2 + exit 1 + fi + umask 077 + rm -f "$XBPS_KEY_FILE" + printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE" + chmod 600 "$XBPS_KEY_FILE" + + - name: Install the tools repo.sh and the publish step need + shell: bash + run: | + set -euo pipefail + missing="" + for tool in podman openssl curl python3; do + command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool" + done + if [ -n "$missing" ]; then + SUDO="" + [ "$(id -u)" -eq 0 ] || SUDO="sudo" + $SUDO apt-get update + # shellcheck disable=SC2086 + $SUDO apt-get install -y --no-install-recommends $missing + fi + podman --version + + # repo.sh is the only path that builds or signs anything: pinned Void + # glibc image, every template in smoke-gate order, full x86_64 index, + # signed packages and metadata. It re-validates the key (RSA4096) before + # any container starts. + - name: Build and sign the repository + shell: bash + run: | + set -euo pipefail + XBPS_SIGNING_KEY_FILE="$XBPS_KEY_FILE" bash packaging/void/repo.sh + ls -la publish/xbps/repo publish/xbps/keys + + - name: Verify the signed repository is complete + shell: bash + run: | + set -euo pipefail + ROOT=$PWD + cd publish/xbps + fail() { echo "::error::$*" >&2; exit 1; } + + # Every class must exist, so a partial repository never attaches. + for pat in 'repo/*.x86_64.xbps' 'repo/*.sig2' 'repo/*-repodata*' 'keys/FINGERPRINT' 'keys/*.plist'; do + compgen -G "$pat" > /dev/null || fail "missing from the signed repository: $pat" + done + + # Every template the smoke gate builds must be in the repository. + PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$ROOT/packaging/void/smoke.sh") + [ -n "$PKGS" ] || fail "could not read the package order from packaging/void/smoke.sh" + for name in $PKGS; do + compgen -G "repo/${name}-[0-9]*.x86_64.xbps" > /dev/null || fail "package missing from the repository: ${name}" + done + + # Glibc x86_64 data only: no musl, no other architecture, no noarch. + while IFS= read -r name; do + case "$name" in + *musl*) fail "musl data in the repository: $name" ;; + *.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;; + *) fail "unexpected file (only glibc x86_64 data is published): repo/$name" ;; + esac + done < <(find repo -mindepth 1 -maxdepth 1 -printf '%f\n' | sort) + while IFS= read -r name; do + case "$name" in + FINGERPRINT | *.plist) ;; + *) fail "unexpected file among the client keys: keys/$name" ;; + esac + done < <(find keys -mindepth 1 -maxdepth 1 -printf '%f\n' | sort) + + # Never publish an unsigned package, or a signature without its package. + for pkg in repo/*.x86_64.xbps; do + [ -s "${pkg}.sig2" ] || fail "package has no signature: ${pkg}" + done + for sig in repo/*.sig2; do + [ -f "${sig%.sig2}" ] || fail "signature without its package: ${sig}" + done + + # The repository must be signed by the key the README tells users to + # pin, and the staged client key must be that key. + FP=$(sed -n 's/^fingerprint: //p' keys/FINGERPRINT) + if [ "$FP" != "$XBPS_KEY_FINGERPRINT" ]; then + fail "signing key fingerprint ${FP:-} is not the pinned ${XBPS_KEY_FINGERPRINT}; refusing to publish" + fi + [ "$(find keys -name '*.plist' | wc -l)" -eq 1 ] || fail "expected exactly one client key plist" + [ -s "keys/${FP}.plist" ] || fail "client key plist keys/${FP}.plist is missing" + echo "OK: complete signed glibc x86_64 repository, key ${FP}" + + # Releases go through the Gitea REST API. Any non-2xx response fails the + # job. Only files staged by repo.sh upload; nothing is built or signed + # here. Package files, signatures and the client key go first and the + # repodata goes last, so a client never sees an index that lists packages + # which are not there yet. + - name: Publish to the tag release and the latest release + shell: bash + env: + GITEA_TOKEN: ${{ gitea.token }} + TAG: ${{ gitea.ref_name }} + COMMIT_SHA: ${{ gitea.sha }} + run: | + set -euo pipefail + cd publish/xbps + BODY=$(mktemp) + trap 'rm -f "$BODY"' EXIT + + fail() { echo "::error::$*" >&2; exit 1; } + + urlencode() { + local s="$1" out="" c i + for ((i = 0; i < ${#s}; i++)); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9._~-]) out+="$c" ;; + *) out+=$(printf '%%%02X' "'$c") ;; + esac + done + printf '%s' "$out" + } + + # call METHOD URL [curl args...]: response body lands in $BODY. + call() { + local method="$1" url="$2" code + shift 2 + code=$(curl -sS -o "$BODY" -w '%{http_code}' -X "$method" \ + -H "Authorization: token ${GITEA_TOKEN}" "$@" "$url") \ + || fail "request failed: ${method} ${url}" + case "$code" in + 2[0-9][0-9]) ;; + *) cat "$BODY" >&2; echo >&2; fail "${method} ${url} returned HTTP ${code}" ;; + esac + } + + json_field() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "$BODY" "$1"; } + + release_payload() { + python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": sys.argv[3], "body": sys.argv[4], "draft": False, "prerelease": False}))' "$@" + } + + # release_id TAG: sets REL_ID to the release id, or to "" when the + # tag has no release. These helpers set a variable instead of + # printing, because bash clears `set -e` inside $(...): a failure in + # a nested substitution would otherwise be swallowed. + release_id() { + local code + REL_ID="" + code=$(curl -sS -o "$BODY" -w '%{http_code}' \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${GITEA_API}/releases/tags/$(urlencode "$1")") \ + || fail "request failed: GET releases/tags/$1" + case "$code" in + 200) REL_ID=$(json_field id) ;; + 404) ;; + *) cat "$BODY" >&2; echo >&2; fail "GET releases/tags/$1 returned HTTP ${code}" ;; + esac + } + + # ensure_release TAG NAME NOTES: sets REL_ID, creating the release + # when the tag has none. + ensure_release() { + local payload + release_id "$1" + if [ -z "$REL_ID" ]; then + payload=$(release_payload "$1" "$COMMIT_SHA" "$2" "$3") + call POST "${GITEA_API}/releases" -H 'Content-Type: application/json' -d "$payload" + REL_ID=$(json_field id) + fi + } + + # delete_assets ID MODE: MODE is "all", or "same" to remove only the + # assets whose names this run uploads again (a rerun on a tag release). + delete_assets() { + local listing aid aname + call GET "${GITEA_API}/releases/$1/assets" + listing=$(python3 -c 'import json,sys; [print(a["id"], a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY") + [ -n "$listing" ] || return 0 + while read -r aid aname; do + if [ "$2" = "all" ] || printf '%s\n' "$NAMES" | grep -qxF -- "$aname"; then + call DELETE "${GITEA_API}/releases/$1/assets/${aid}" + echo "deleted: ${aname}" + fi + done <<< "$listing" + } + + upload_assets() { + local f name + for f in "${FILES[@]}"; do + name=$(basename "$f") + call POST "${GITEA_API}/releases/$1/assets?name=$(urlencode "$name")" \ + -F "attachment=@${f};filename=${name}" + echo "uploaded: ${name}" + done + } + + # verify_assets ID: every file we uploaded is on the release. + verify_assets() { + local have + call GET "${GITEA_API}/releases/$1/assets" + have=$(python3 -c 'import json,sys; [print(a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY" | sort) + [ "$(printf '%s\n' "$NAMES" | sort | comm -23 - <(printf '%s\n' "$have") | wc -l)" -eq 0 ] \ + || fail "release $1 is missing uploaded assets" + } + + # Packages, signatures and the client key first, the repodata last. + mapfile -t FILES < <(printf '%s\n' repo/*.x86_64.xbps repo/*.sig2 keys/FINGERPRINT keys/*.plist repo/*-repodata* | awk '!seen[$0]++') + NAMES=$(for f in "${FILES[@]}"; do basename "$f"; done) + echo "assets to publish: $(printf '%s\n' "$NAMES" | wc -l)" + + NOTES=$(printf '%s\n\n%s\n' \ + "Signed xbps repository, Void Linux glibc x86_64. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \ + "$(cat keys/FINGERPRINT)") + + echo "== tag release ${TAG}" + ensure_release "$TAG" "$TAG" "$NOTES" + TAG_ID=$REL_ID + delete_assets "$TAG_ID" same + upload_assets "$TAG_ID" + verify_assets "$TAG_ID" + + # The rolling release the registration URL points at. The tag + # created with it stays where it was first made; the assets are the + # source of truth for what clients download. + echo "== latest release" + LATEST_NOTES=$(printf '%s\n\n%s\n' \ + "Rolling signed xbps repository, currently built from ${TAG} (${COMMIT_SHA})." \ + "$NOTES") + ensure_release latest latest "$LATEST_NOTES" + LATEST_ID=$REL_ID + PATCH_PAYLOAD=$(python3 -c 'import json,sys; print(json.dumps({"name": sys.argv[1], "body": sys.argv[2]}))' latest "$LATEST_NOTES") + call PATCH "${GITEA_API}/releases/${LATEST_ID}" -H 'Content-Type: application/json' -d "$PATCH_PAYLOAD" + delete_assets "$LATEST_ID" all + upload_assets "$LATEST_ID" + verify_assets "$LATEST_ID" + echo "OK: signed repository published to ${TAG} and latest" + + - name: Remove the signing key + if: always() + shell: bash + run: rm -f "$XBPS_KEY_FILE" diff --git a/tests/test_xbps_release_workflow.py b/tests/test_xbps_release_workflow.py new file mode 100644 index 0000000..cf68164 --- /dev/null +++ b/tests/test_xbps_release_workflow.py @@ -0,0 +1,764 @@ +"""Offline guards for the signed xbps release workflow (ticket #29). + +Releasing the Void repository is a tag push on the fork's Gitea host. The +workflow ``.gitea/workflows/xbps-release.yml`` gates the tag against +``src/vocalinux/version.py``, requires the ``XBPS_SIGNING_KEY`` secret, builds +and signs only through ``packaging/void/repo.sh`` (podman, pinned Void glibc +container), verifies the staged repository, and publishes it to the tag's +release and to the rolling ``latest`` release the client registration URL +serves. + +These tests parse the workflow and then *run* its gate, verify, build and +publish steps in a scratch directory, so they assert behaviour rather than +spelling: + +- the shell of each step is executed with the environment the workflow + itself declares (``${{ ... }}`` expressions are rendered from an explicit + context, so a step wired to the wrong secret or token fails loudly); +- ``curl`` is replaced by a small stateful stand-in for the Gitea release API + on ``PATH``, so the publish step's requests, their order, and its refusal to + continue past a non-2xx response are observable. + +Independent sources of truth: the tag/version pair comes from the test (not the +workflow), the repository layout comes from what ``xbps-rindex`` writes +(``.x86_64.xbps`` plus ``.x86_64.xbps.sig2`` and ``x86_64-repodata``), +and the Gitea REST contract (release create, asset upload and delete, tag +lookup) comes from the Gitea API documentation. +""" + +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Dict, List, Optional +from urllib.parse import unquote + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml" +JOB = "xbps-release" + +GATE_TAG = "Require the tag to equal the version in version.py" +GATE_KEY = "Require the repository signing key" +INSTALL_TOOLS = "Install the tools repo.sh and the publish step need" +BUILD = "Build and sign the repository" +VERIFY = "Verify the signed repository is complete" +PUBLISH = "Publish to the tag release and the latest release" +CLEANUP = "Remove the signing key" +STEP_ORDER = (GATE_TAG, GATE_KEY, INSTALL_TOOLS, BUILD, VERIFY, PUBLISH, CLEANUP) + +API = "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux" +TOKEN = "test-token-not-a-secret" +SHA = "0123456789abcdef0123456789abcdef01234567" +FAKE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nline-one\nline-two\n-----END RSA PRIVATE KEY-----\n" + + +def _text() -> str: + assert WORKFLOW.is_file(), f"missing signed xbps release workflow: {WORKFLOW}" + return WORKFLOW.read_text(encoding="utf-8") + + +def _workflow() -> dict: + return yaml.safe_load(_text()) + + +def _job() -> dict: + return _workflow()["jobs"][JOB] + + +def _step(name: str) -> dict: + matches = [s for s in _job()["steps"] if s.get("name") == name] + assert len(matches) == 1, f"workflow must have exactly one step named {name!r}" + return matches[0] + + +def _run_text(name: str) -> str: + return _step(name)["run"] + + +def _without_comments(text: str) -> str: + return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#")) + + +def _render(value: str, context: Dict[str, str]) -> str: + """Render ``${{ expr }}`` from an explicit context; an unknown one is a bug.""" + + def substitute(match: "re.Match[str]") -> str: + expression = match.group(1) + assert expression in context, f"workflow uses an unexpected expression: {expression}" + return context[expression] + + return re.sub(r"\$\{\{\s*([\w.]+)\s*\}\}", substitute, value) + + +def _run_step( + name: str, + cwd: Path, + context: Optional[Dict[str, str]] = None, + extra_env: Optional[Dict[str, str]] = None, + path_prefix: Optional[Path] = None, +) -> subprocess.CompletedProcess: + """Execute one step's ``run`` script with the env the workflow declares.""" + ctx = {"gitea.run_id": "42", "gitea.token": TOKEN, "gitea.sha": SHA} + ctx.update(context or {}) + step = _step(name) + env = {k: os.environ[k] for k in ("HOME", "LANG") if k in os.environ} + path = os.environ.get("PATH", "/usr/bin:/bin") + env["PATH"] = f"{path_prefix}{os.pathsep}{path}" if path_prefix else path + for source in (_job().get("env", {}), step.get("env", {})): + for key, value in source.items(): + env[key] = _render(str(value), ctx) + env.update(extra_env or {}) + script = cwd / f"{re.sub(r'[^a-z]+', '-', name.lower())}.sh" + script.write_text(step["run"], encoding="utf-8") + return subprocess.run( + ["bash", str(script)], + cwd=cwd, + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + +# --------------------------------------------------------------------------- +# Workflow shape +# --------------------------------------------------------------------------- + + +def test_triggers_only_on_version_tag_pushes() -> None: + """A tag push is a human act: no schedule, no dispatch, no PR trigger.""" + triggers = _workflow()[True] # YAML 1.1 reads the bare key `on` as True + assert triggers == {"push": {"tags": ["v*"]}}, triggers + + +def test_grants_only_contents_write() -> None: + assert _workflow()["permissions"] == {"contents": "write"} + + +def test_runs_on_the_project_runner_with_no_third_party_actions() -> None: + """Checkout is the git fetch the repo's own CI uses; nothing floats on a tag.""" + assert _job()["runs-on"] == "bongbetic-ci" + uses = [s["uses"] for s in _job()["steps"] if "uses" in s] + assert not uses, f"the signing job must not run third-party actions: {uses}" + + +def test_steps_run_in_the_gated_order() -> None: + """Both gates precede any container; verify precedes publish; key cleanup is last.""" + names = [s.get("name") for s in _job()["steps"]] + assert names[0] == "Checkout" + assert tuple(names[1:]) == STEP_ORDER, names + + +def test_signing_key_is_removed_by_an_always_step() -> None: + last = _job()["steps"][-1] + assert last["name"] == CLEANUP + assert last["if"] == "always()" + assert "rm -f" in last["run"] and "$XBPS_KEY_FILE" in last["run"] + assert _job()["env"]["XBPS_KEY_FILE"].startswith("/tmp/xbps-sign-key-") + + +def test_signing_secret_is_read_by_exactly_one_step() -> None: + """The secret is exposed to the gate that stages it, never to the build.""" + text = _text() + assert text.count("secrets.XBPS_SIGNING_KEY") == 1 + assert ( + _step(GATE_KEY)["env"]["XBPS_SIGNING_KEY"] == "${{ secrets.XBPS_SIGNING_KEY }}" + ), "the key gate must read the XBPS_SIGNING_KEY secret" + assert "XBPS_SIGNING_KEY" not in _step(BUILD).get("env", {}) + + +def test_the_tag_reaches_scripts_through_env_never_interpolated() -> None: + """`${{ gitea.ref_name }}` inside a run script is shell injection by tag name.""" + for step in _job()["steps"]: + assert "${{" not in step.get("run", ""), step.get("name") + assert _step(GATE_TAG)["env"]["TAG"] == "${{ gitea.ref_name }}" + + +def test_repo_sh_is_the_only_build_and_sign_path() -> None: + """No step builds, indexes or signs by hand; only repo.sh does, once.""" + code = _without_comments(_text()) + assert code.count("packaging/void/repo.sh") == 1 + for forbidden in ("xbps-src", "xbps-rindex", "openssl rsa", "xlint", "gpg "): + assert forbidden not in code, f"{forbidden!r} belongs inside repo.sh, not the workflow" + assert "repo.sh" in _run_text(BUILD) + + +def test_pipeline_uses_podman_and_never_docker() -> None: + code = _without_comments(_text()).lower() + assert "podman" in _run_text(INSTALL_TOOLS) + assert "docker" not in code + + +def test_workflow_never_commits_pushes_or_bumps_templates() -> None: + """Template bumps are a normal pull request, never a bot commit.""" + code = _without_comments(_text()) + for forbidden in ("git commit", "git push", "git tag", "git add", "srcpkgs", "gh pr"): + assert forbidden not in code, f"the workflow must not run {forbidden!r}" + + +def test_workflow_text_names_no_assistant() -> None: + assert not re.search(r"claude|anthropic", _text(), re.IGNORECASE) + + +def test_publish_goes_to_the_fork_gitea_api_with_the_job_token() -> None: + assert _job()["env"]["GITEA_API"] == API + env = _step(PUBLISH)["env"] + assert env["GITEA_TOKEN"] == "${{ gitea.token }}" + assert "Authorization: token ${GITEA_TOKEN}" in _run_text(PUBLISH) + assert "gh release" not in _text(), "GitHub CLI cannot reach the fork's Gitea releases" + + +# --------------------------------------------------------------------------- +# Gate 1: the tag equals the version source of truth +# --------------------------------------------------------------------------- + + +def _version_file(root: Path, body: str) -> None: + target = root / "src" / "vocalinux" / "version.py" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(body, encoding="utf-8") + + +def _gate_tag(tmp_path: Path, tag: str, version_py: str) -> subprocess.CompletedProcess: + _version_file(tmp_path, version_py) + return _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": tag}) + + +@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), # tag contains the version: loose match would pass + ("v0.18", "0.18.5", False), # version contains the tag: loose match would pass + ("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_requires_exact_v_plus_version( + tmp_path: Path, tag: str, version: str, ok: bool +) -> None: + result = _gate_tag(tmp_path, tag, f'"""Version."""\n\n__version__ = "{version}"\n') + if ok: + assert result.returncode == 0, result.stderr + else: + assert result.returncode == 1, f"{tag} vs {version} must exit 1: {result.stdout}" + assert "refusing to publish" in result.stderr + + +def test_tag_gate_fails_when_the_version_cannot_be_read(tmp_path: Path) -> None: + result = _gate_tag(tmp_path, "v0.18.5", '"""Version."""\n__version_info__ = (0, 18, 5)\n') + assert result.returncode == 1 + assert "__version__" in result.stderr + + +# --------------------------------------------------------------------------- +# Gate 2: the signing key +# --------------------------------------------------------------------------- + + +def test_unset_signing_key_exits_1_and_writes_nothing(tmp_path: Path) -> None: + key_file = tmp_path / "key.pem" + result = _run_step( + GATE_KEY, + tmp_path, + {"secrets.XBPS_SIGNING_KEY": ""}, + {"XBPS_KEY_FILE": str(key_file)}, + ) + assert result.returncode == 1 + assert "XBPS_SIGNING_KEY" in result.stderr + assert "unsigned" in result.stderr + assert not key_file.exists(), "no key material may be written when the secret is empty" + + +def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None: + key_file = tmp_path / "key.pem" + result = _run_step( + GATE_KEY, + tmp_path, + {"secrets.XBPS_SIGNING_KEY": FAKE_KEY}, + {"XBPS_KEY_FILE": str(key_file)}, + ) + assert result.returncode == 0, result.stderr + assert key_file.read_text(encoding="utf-8") == FAKE_KEY + assert stat.S_IMODE(key_file.stat().st_mode) == 0o600 + assert FAKE_KEY.splitlines()[1] not in result.stdout + result.stderr, "key was echoed" + + +def test_cleanup_step_removes_the_key_file(tmp_path: Path) -> None: + key_file = tmp_path / "key.pem" + key_file.write_text(FAKE_KEY, encoding="utf-8") + result = _run_step(CLEANUP, tmp_path, extra_env={"XBPS_KEY_FILE": str(key_file)}) + assert result.returncode == 0, result.stderr + assert not key_file.exists() + + +# --------------------------------------------------------------------------- +# Build: repo.sh is handed the key file, nothing else +# --------------------------------------------------------------------------- + + +def test_build_step_runs_repo_sh_with_the_key_file_only(tmp_path: Path) -> None: + """repo.sh gets XBPS_SIGNING_KEY_FILE and never the secret itself.""" + script = tmp_path / "packaging" / "void" / "repo.sh" + script.parent.mkdir(parents=True) + script.write_text( + textwrap.dedent("""\ + #!/bin/sh + set -eu + [ -s "$XBPS_SIGNING_KEY_FILE" ] || exit 7 + printf '%s' "$XBPS_SIGNING_KEY_FILE" > called-with + printf '%s' "${XBPS_SIGNING_KEY:-unset}" > secret-seen + mkdir -p publish/xbps/repo publish/xbps/keys + """), + encoding="utf-8", + ) + key_file = tmp_path / "key.pem" + key_file.write_text(FAKE_KEY, encoding="utf-8") + result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(key_file)}) + assert result.returncode == 0, result.stderr + assert (tmp_path / "called-with").read_text(encoding="utf-8") == str(key_file) + assert (tmp_path / "secret-seen").read_text(encoding="utf-8") == "unset" + + +def test_build_step_fails_when_repo_sh_fails(tmp_path: Path) -> None: + """A failing repo.sh (missing or non-RSA4096 key, build error) ends the job.""" + script = tmp_path / "packaging" / "void" / "repo.sh" + script.parent.mkdir(parents=True) + script.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(tmp_path / "k")}) + assert result.returncode != 0 + + +# --------------------------------------------------------------------------- +# Verify: the full signed glibc x86_64 repository, nothing else +# --------------------------------------------------------------------------- + +FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25" +SMOKE_SH = REPO_ROOT / "packaging" / "void" / "smoke.sh" + + +def _smoke_packages() -> List[str]: + """The templates the smoke gate builds, in order: the repository's package set.""" + 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() + + +#: One built package per template, versioned the way xbps-src names them. +PACKAGES = [f"{name}-1.0_1" for name in _smoke_packages()] + + +def _stage(root: Path, fingerprint: str = FINGERPRINT) -> Path: + """Lay out publish/xbps the way repo.sh stages it, plus the smoke gate's list.""" + smoke = root / "packaging" / "void" / "smoke.sh" + smoke.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(SMOKE_SH, smoke) + out = root / "publish" / "xbps" + repo, keys = out / "repo", out / "keys" + repo.mkdir(parents=True) + keys.mkdir() + for pkg in PACKAGES: + (repo / f"{pkg}.x86_64.xbps").write_bytes(b"package bytes " + pkg.encode()) + (repo / f"{pkg}.x86_64.xbps.sig2").write_bytes(b"signature " + pkg.encode()) + (repo / "x86_64-repodata").write_bytes(b"signed index") + (keys / "FINGERPRINT").write_text( + f"signed-by: Voculinux \nfingerprint: {fingerprint}\n", + encoding="utf-8", + ) + (keys / f"{fingerprint}.plist").write_text("\n", encoding="utf-8") + return out + + +def test_the_pinned_fingerprint_is_what_the_verify_step_checks() -> None: + assert _job()["env"]["XBPS_KEY_FINGERPRINT"] == FINGERPRINT + + +def test_verify_accepts_the_complete_signed_repository(tmp_path: Path) -> None: + _stage(tmp_path) + result = _run_step(VERIFY, tmp_path) + assert result.returncode == 0, result.stderr + assert "complete signed glibc x86_64 repository" in result.stdout + + +@pytest.mark.parametrize( + "missing", + [ + f"repo/{PACKAGES[0]}.x86_64.xbps", + f"repo/{PACKAGES[-1]}.x86_64.xbps", + f"repo/{PACKAGES[0]}.x86_64.xbps.sig2", + "repo/x86_64-repodata", + "keys/FINGERPRINT", + f"keys/{FINGERPRINT}.plist", + ], +) +def test_verify_rejects_a_repository_missing_any_class(tmp_path: Path, missing: str) -> None: + out = _stage(tmp_path) + (out / missing).unlink() + result = _run_step(VERIFY, tmp_path) + assert result.returncode == 1, f"removing {missing} must fail verification" + + +@pytest.mark.parametrize( + "extra", + [ + "repo/voculinux-1.0_1.x86_64-musl.xbps", + "repo/voculinux-1.0_1.aarch64.xbps", + "repo/voculinux-1.0_1.noarch.xbps", + "repo/x86_64-musl-repodata", + "repo/aarch64-repodata", + "repo/notes.txt", + "keys/private.pem", + ], +) +def test_verify_rejects_anything_but_glibc_x86_64_data(tmp_path: Path, extra: str) -> None: + out = _stage(tmp_path) + (out / extra).write_bytes(b"x") + result = _run_step(VERIFY, tmp_path) + assert result.returncode == 1, f"{extra} must not be publishable" + + +def test_verify_rejects_an_unsigned_package(tmp_path: Path) -> None: + """One signature present does not excuse a package without its own.""" + out = _stage(tmp_path) + (out / "repo" / f"{PACKAGES[-1]}.x86_64.xbps.sig2").write_bytes(b"") + result = _run_step(VERIFY, tmp_path) + assert result.returncode == 1 + assert "no signature" in result.stderr + + +def test_verify_rejects_a_signature_whose_package_is_gone(tmp_path: Path) -> None: + out = _stage(tmp_path) + (out / "repo" / f"{PACKAGES[0]}.x86_64.xbps.sig2").write_bytes(b"sig") + (out / "repo" / "ghost-1.0_1.x86_64.xbps.sig2").write_bytes(b"sig") + result = _run_step(VERIFY, tmp_path) + assert result.returncode == 1 + assert "without its package" in result.stderr + + +def test_verify_rejects_a_repository_signed_by_another_key(tmp_path: Path) -> None: + """A throwaway or wrong key must never publish under the pinned fingerprint.""" + other = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff" + _stage(tmp_path, fingerprint=other) + result = _run_step(VERIFY, tmp_path) + assert result.returncode == 1 + assert FINGERPRINT in result.stderr and other in result.stderr + + +# --------------------------------------------------------------------------- +# Publish: tag release plus rolling latest release, through a fake Gitea API +# --------------------------------------------------------------------------- + +FAKE_CURL = '''#!{python} +"""Stateful stand-in for curl against the Gitea release API.""" +import json +import os +import re +import sys +from urllib.parse import unquote, urlsplit + +STATE = os.environ["FAKE_GITEA_STATE"] +BASE = os.environ["FAKE_GITEA_BASE"] + + +def load(): + with open(STATE, encoding="utf-8") as handle: + return json.load(handle) + + +def save(state): + with open(STATE, "w", encoding="utf-8") as handle: + json.dump(state, handle) + + +def main(argv): + out = method = data = url = None + headers, forms = [], [] + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "-o": + out, i = argv[i + 1], i + 2 + elif arg == "-w": + i += 2 + elif arg == "-X": + method, i = argv[i + 1], i + 2 + elif arg == "-H": + headers.append(argv[i + 1]) + i += 2 + elif arg == "-d": + data, i = argv[i + 1], i + 2 + elif arg == "-F": + forms.append(argv[i + 1]) + i += 2 + elif arg.startswith("-"): + i += 1 + else: + url, i = arg, i + 1 + method = method or "GET" + state = load() + entry = {{ + "method": method, + "url": url, + "auth": [h for h in headers if h.lower().startswith("authorization:")], + "data": json.loads(data) if data else None, + "forms": forms, + }} + state["log"].append(entry) + status, body = route(state, method, url, data, forms) + fail = os.environ.get("FAKE_GITEA_FAIL", "") + if fail: + fmethod, fneedle, fcode = fail.split("|") + if method == fmethod and fneedle in url: + status, body = int(fcode), {{"message": "injected failure"}} + save(state) + if out: + with open(out, "w", encoding="utf-8") as handle: + handle.write(json.dumps(body) if body is not None else "") + sys.stdout.write(str(status)) + return 0 + + +def route(state, method, url, data, forms): + if not url.startswith(BASE): + return 599, {{"message": "unexpected host " + url}} + path = urlsplit(url[len(BASE):]).path + query = urlsplit(url).query + releases = state["releases"] + m = re.fullmatch(r"/releases/tags/(.+)", path) + if m and method == "GET": + tag = unquote(m.group(1)) + for rel in releases.values(): + if rel["tag_name"] == tag: + return 200, rel + return 404, {{"message": "not found"}} + if path == "/releases" and method == "POST": + payload = json.loads(data) + rid = state["next_id"] + state["next_id"] += 1 + rel = dict(payload, id=rid, assets=[]) + releases[str(rid)] = rel + return 201, rel + m = re.fullmatch(r"/releases/(\\d+)", path) + if m and method == "PATCH": + rel = releases[m.group(1)] + rel.update(json.loads(data)) + return 200, rel + m = re.fullmatch(r"/releases/(\\d+)/assets", path) + if m and method == "GET": + return 200, releases[m.group(1)]["assets"] + if m and method == "POST": + rel = releases[m.group(1)] + name = unquote(re.search(r"(?:^|&)name=([^&]*)", query).group(1)) + if any(a["name"] == name for a in rel["assets"]): + return 409, {{"message": "asset already exists"}} + source = re.match(r"attachment=@(.*?);filename=(.*)$", forms[0]) + size = os.path.getsize(source.group(1)) + asset = {{"id": state["next_id"], "name": name, "size": size}} + state["next_id"] += 1 + rel["assets"].append(asset) + return 201, asset + m = re.fullmatch(r"/releases/(\\d+)/assets/(\\d+)", path) + if m and method == "DELETE": + rel = releases[m.group(1)] + rel["assets"] = [a for a in rel["assets"] if str(a["id"]) != m.group(2)] + return 204, None + return 404, {{"message": "no such route " + method + " " + path}} + + +sys.exit(main(sys.argv[1:])) +''' + + +class FakeGitea: + """A stateful curl stand-in on PATH plus helpers to read what it recorded.""" + + def __init__(self, root: Path) -> None: + self.bin = root / "fakebin" + self.bin.mkdir() + self.state_file = root / "gitea-state.json" + curl = self.bin / "curl" + curl.write_text(FAKE_CURL.format(python=sys.executable), encoding="utf-8") + curl.chmod(0o755) + self.state_file.write_text( + json.dumps({"next_id": 100, "releases": {}, "log": []}), encoding="utf-8" + ) + + @property + def state(self) -> dict: + return json.loads(self.state_file.read_text(encoding="utf-8")) + + def seed_release(self, tag: str, assets: List[str]) -> None: + state = self.state + rid = state["next_id"] + state["next_id"] += 1 + state["releases"][str(rid)] = { + "id": rid, + "tag_name": tag, + "name": tag, + "body": "old", + "assets": [{"id": rid * 1000 + n, "name": a, "size": 1} for n, a in enumerate(assets)], + } + self.state_file.write_text(json.dumps(state), encoding="utf-8") + + def release(self, tag: str) -> dict: + for rel in self.state["releases"].values(): + if rel["tag_name"] == tag: + return rel + raise AssertionError(f"no release for tag {tag!r}") + + def asset_names(self, tag: str) -> List[str]: + return [a["name"] for a in self.release(tag)["assets"]] + + def uploads(self, tag: str) -> List[str]: + """Asset names POSTed to this tag's release, in upload order.""" + prefix = f"{API}/releases/{self.release(tag)['id']}/assets?name=" + return [ + unquote(e["url"][len(prefix) :]) + for e in self.state["log"] + if e["method"] == "POST" and e["url"].startswith(prefix) + ] + + def run_publish( + self, root: Path, tag: str = "v0.18.5", fail: str = "" + ) -> subprocess.CompletedProcess: + return _run_step( + PUBLISH, + root, + {"gitea.ref_name": tag}, + { + "FAKE_GITEA_STATE": str(self.state_file), + "FAKE_GITEA_BASE": API, + "FAKE_GITEA_FAIL": fail, + }, + path_prefix=self.bin, + ) + + +def _staged_names(out: Path) -> List[str]: + return sorted(p.name for sub in ("repo", "keys") for p in (out / sub).iterdir()) + + +@pytest.fixture() +def staged(tmp_path: Path) -> Path: + if shutil.which("python3") is None: + pytest.skip("python3 is required by the publish step") + _stage(tmp_path) + return tmp_path + + +def test_publish_attaches_the_full_repository_to_both_releases(staged: Path) -> None: + gitea = FakeGitea(staged) + result = gitea.run_publish(staged) + assert result.returncode == 0, result.stdout + result.stderr + expected = _staged_names(staged / "publish" / "xbps") + for tag in ("v0.18.5", "latest"): + assert sorted(gitea.asset_names(tag)) == expected, tag + uploaded = gitea.uploads(tag) + assert sorted(uploaded) == expected, f"{tag}: uploaded {uploaded}" + assert uploaded[-1] == "x86_64-repodata", "the index goes last, after its packages" + assert FINGERPRINT in gitea.release("v0.18.5")["body"] + + +def test_publish_creates_latest_at_the_workflow_commit(staged: Path) -> None: + gitea = FakeGitea(staged) + assert gitea.run_publish(staged).returncode == 0 + latest = gitea.release("latest") + assert latest["target_commitish"] == SHA + assert latest["tag_name"] == "latest" + tag_release = gitea.release("v0.18.5") + assert tag_release["target_commitish"] == SHA + assert not tag_release["draft"] and not latest["draft"] + assert "v0.18.5" in latest["body"], "latest says which tag it was built from" + + +def test_publish_talks_only_to_the_fork_api_authenticated(staged: Path) -> None: + gitea = FakeGitea(staged) + assert gitea.run_publish(staged).returncode == 0 + log = gitea.state["log"] + assert log, "the publish step made no API calls" + for entry in log: + assert entry["url"].startswith(API + "/"), entry["url"] + assert entry["auth"] == [f"Authorization: token {TOKEN}"], entry + # The tag release is created and filled before latest is touched. + posts = [e["data"]["tag_name"] for e in log if e["url"] == API + "/releases"] + assert posts == ["v0.18.5", "latest"] + + +def test_publish_replaces_every_asset_on_an_existing_latest_release(staged: Path) -> None: + gitea = FakeGitea(staged) + gitea.seed_release("latest", ["voculinux-0.18.4_1.x86_64.xbps", "x86_64-repodata", "old.txt"]) + assert gitea.run_publish(staged).returncode == 0 + assert sorted(gitea.asset_names("latest")) == _staged_names(staged / "publish" / "xbps") + assert len([r for r in gitea.state["releases"].values() if r["tag_name"] == "latest"]) == 1 + assert "v0.18.5" in gitea.release("latest")["body"] + + +def test_publish_rerun_keeps_foreign_assets_on_the_tag_release(staged: Path) -> None: + """Rerunning a tag replaces only its own files; hand-attached assets survive.""" + gitea = FakeGitea(staged) + gitea.seed_release("v0.18.5", ["x86_64-repodata", "voculinux-0.18.5.tar.gz"]) + assert gitea.run_publish(staged).returncode == 0 + names = gitea.asset_names("v0.18.5") + assert "voculinux-0.18.5.tar.gz" in names + assert names.count("x86_64-repodata") == 1 + assert set(_staged_names(staged / "publish" / "xbps")) <= set(names) + + +def test_publish_percent_encodes_the_colon_fingerprint_asset_name(staged: Path) -> None: + gitea = FakeGitea(staged) + assert gitea.run_publish(staged).returncode == 0 + plist_urls = [e["url"] for e in gitea.state["log"] if ".plist" in e["url"]] + assert plist_urls and all(":" not in u.split("name=")[1] for u in plist_urls), plist_urls + assert f"{FINGERPRINT}.plist" in gitea.asset_names("latest") + + +@pytest.mark.parametrize( + "fail", + [ + "POST|/releases|403", # token may not create releases + "POST|/assets?|500", # an upload fails midway + "DELETE|/assets/|500", + "GET|/releases/tags/|500", + "PATCH|/releases/|500", + ], +) +def test_publish_fails_the_job_on_any_non_2xx_response(staged: Path, fail: str) -> None: + gitea = FakeGitea(staged) + gitea.seed_release("latest", ["stale"]) + gitea.seed_release("v0.18.5", ["x86_64-repodata"]) + result = gitea.run_publish(staged, fail=fail) + assert result.returncode != 0, f"{fail} must fail the job" + assert "HTTP" in result.stderr or "failed" in result.stderr + + +def test_publish_does_not_touch_latest_when_the_tag_release_fails(staged: Path) -> None: + gitea = FakeGitea(staged) + result = gitea.run_publish(staged, fail="POST|/assets?|500") + assert result.returncode != 0 + assert not any(r["tag_name"] == "latest" for r in gitea.state["releases"].values()) + + +def test_publish_uploads_only_staged_files(staged: Path) -> None: + """Nothing is built or created by hand: uploads are exactly the staged set.""" + gitea = FakeGitea(staged) + assert gitea.run_publish(staged).returncode == 0 + staged_names = set(_staged_names(staged / "publish" / "xbps")) + for tag in ("v0.18.5", "latest"): + assert set(gitea.uploads(tag)) == staged_names + for entry in gitea.state["log"]: + for form in entry["forms"]: + path = re.match(r"attachment=@(.*?);", form).group(1) + assert path.startswith(("repo/", "keys/")), path