From da450e32dbb77f74089467a5e8c28e6b674c761f Mon Sep 17 00:00:00 2001 From: soubarnak Date: Fri, 6 Nov 2026 09:15:22 +0530 Subject: [PATCH] fix(void): wire signed xbps repo into release, README, and release process 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). --- .github/workflows/release.yml | 116 +++++++++++++++++++++++++++++++- README.md | 25 +++++++ docs/RELEASE_PROCESS.md | 14 ++++ tests/test_release_integrity.py | 8 ++- tests/test_void_signed_repo.py | 96 ++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b647efd..6ca8c10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -289,6 +289,21 @@ jobs: `--dangerous` is required because this file is not a Store revision. It will not refresh from the Store. amd64 only. + ### Void Linux (glibc x86_64, signed xbps repo) + Also attached below: the signed repository files (`*.x86_64.xbps` + with signatures, `x86_64-repodata`, and the client key + `FINGERPRINT` plus the fingerprint-named `.plist`). This is a + third-party repository published by this project, not upstream + Void Linux: + ```bash + # as root (the files come from this release's assets): + cp .plist /var/db/xbps/keys/ + echo 'repository=' > /etc/xbps.d/voculinux.conf + sudo xbps-install -Sy voculinux + ``` + Answer `Y` at the fingerprint prompt only after checking it + matches the attached `FINGERPRINT` file. + ### Verifying what you downloaded `SHA256SUMS` covers every file here and is attached once the aarch64 AppImage, both Flatpak builds, and the snap finish, a few minutes after this release appears. @@ -789,7 +804,7 @@ jobs: # missing one looks like the tampered one. publish-checksums: name: Checksums and Provenance - needs: [build-and-release, build-appimage-arm64, build-native, build-native-arm64, build-flatpak-amd64, build-flatpak-arm64, attach-flatpak, attach-snap] + needs: [build-and-release, build-appimage-arm64, build-native, build-native-arm64, build-flatpak-amd64, build-flatpak-arm64, attach-flatpak, attach-snap, build-xbps] runs-on: ubuntu-latest # An attach-job failure must not skip checksums: this job hashes the build # artifacts, which exist regardless of whether an upload to the release @@ -805,7 +820,7 @@ jobs: uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: # Verification-only artifacts must never enter the release manifest. - pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)' + pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64|xbps-repo)' merge-multiple: true path: dist @@ -816,6 +831,9 @@ jobs: for pat in '*.whl' '*.tar.gz' '*.AppImage' '*.deb' '*.rpm' '*.flatpak' '*.snap'; do compgen -G "$pat" > /dev/null || { echo "missing artifact: $pat"; exit 1; } done + for pat in 'repo/*.x86_64.xbps' 'repo/*.sig2' 'repo/*-repodata*' 'keys/FINGERPRINT' 'keys/*.plist'; do + compgen -G "$pat" > /dev/null || { echo "missing artifact: $pat"; exit 1; } + done test "$(compgen -G '*.AppImage' | wc -l)" -eq 2 test "$(compgen -G '*.deb' | wc -l)" -eq 2 test "$(compgen -G '*.rpm' | wc -l)" -eq 2 @@ -827,8 +845,12 @@ jobs: set -euo pipefail ls -la # Bare names, so `sha256sum -c` works in whatever directory the user - # downloaded the release into. + # downloaded the release into. The xbps repo files hash from their + # own directories for the same reason: the release attaches them + # flat, and RSA signatures stay the primary trust root. sha256sum -- *.whl *.tar.gz *.AppImage *.deb *.rpm *.flatpak *.snap | tee SHA256SUMS + (cd repo && sha256sum -- *.x86_64.xbps *.sig2 *-repodata*) >> SHA256SUMS + (cd keys && sha256sum -- FINGERPRINT *.plist) >> SHA256SUMS # Attesting from the manifest rather than a second glob: the file we # publish and the set we attest cannot drift apart. @@ -1021,6 +1043,94 @@ jobs: ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} commit_message: 'Release ${{ github.ref_name }}' + # The signed Void xbps repository (ticket #29): every template builds in + # smoke-gate order in the pinned Void container via podman, the full + # repository index is signed with the RSA4096 repository key, and the + # packages, signatures, repodata, and client key land on this release. + # XBPS_SIGNING_KEY is required — an unsigned repository can never + # publish, so a missing key fails the job instead of skipping it (unlike + # the opt-in Flatpak remote and AUR publishes). The voculinux template + # builds the canonical release sdist, so this waits for build-and-release, + # and the tag must match src/vocalinux/version.py or the job aborts. + build-xbps: + name: Build signed xbps repository + needs: build-and-release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Verify tag matches the version source of truth + run: | + set -euo pipefail + TAG="${GITHUB_REF#refs/tags/v}" + PACKAGE_VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py) + echo "Tag version: $TAG" + echo "Package version: $PACKAGE_VERSION" + if [[ "$PACKAGE_VERSION" != *"$TAG"* ]] && [[ "$TAG" != *"$PACKAGE_VERSION"* ]]; then + echo "::error::Tag v$TAG does not match src/vocalinux/version.py ($PACKAGE_VERSION); refusing to ship the signed repo" + exit 1 + fi + + # Fail-closed: no key, no repo, no publish — never a silent skip. + - name: Require the repository signing key + env: + XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }} + run: | + if [ -z "$XBPS_SIGNING_KEY" ]; then + echo "::error::XBPS_SIGNING_KEY is unset; refusing to publish an unsigned repository (packaging/void/README.md)" + exit 1 + fi + + - name: Install podman + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends podman + + - name: Build and sign the repository + env: + XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }} + run: | + set -euo pipefail + printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem + chmod 600 /tmp/xbps-sign-key.pem + XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem bash packaging/void/repo.sh + rm -f /tmp/xbps-sign-key.pem + ls -la publish/xbps/repo publish/xbps/keys + + - name: Upload the signed repository + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: xbps-repo + path: | + publish/xbps/repo/* + publish/xbps/keys/* + if-no-files-found: error + retention-days: 7 + + - name: Attach the signed repository to the release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + cd publish/xbps + # Full signed repo or nothing: binary packages (*.x86_64.xbps) + # with their signatures, the signed repository metadata + # (*-repodata), and the client key files (FINGERPRINT plus the + # fingerprint-named .plist). Each class is required below, so a + # partial repo can never attach. + compgen -G 'repo/*.x86_64.xbps' > /dev/null || { echo "missing packages"; exit 1; } + compgen -G 'repo/*.sig2' > /dev/null || { echo "missing signatures"; exit 1; } + compgen -G 'repo/*-repodata*' > /dev/null || { echo "missing repodata"; exit 1; } + compgen -G 'keys/FINGERPRINT' > /dev/null || { echo "missing FINGERPRINT"; exit 1; } + compgen -G 'keys/*.plist' > /dev/null || { echo "missing client key"; exit 1; } + gh release upload "${{ github.ref_name }}" \ + repo/*.x86_64.xbps repo/*.sig2 repo/*-repodata* keys/FINGERPRINT keys/*.plist \ + --repo ${{ github.repository }} \ + --clobber + # GitHub Release first, Store second. A super-privileged plug can still # queue a human review and refuse edge/candidate; the .snap still # has to land on the tag for sideload. grade: stable allows candidate; do diff --git a/README.md b/README.md index 112783d..9cb2986 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,31 @@ nix profile install github:VocaHQ/vocalinux The flake also ships `nixosModules.voculinux` and `homeManagerModules.voculinux` for declarative installs (`programs.voculinux.enable = true`). The bundled pywhispercpp builds with Vulkan GPU inference. Details: [packaging/nix/README.md](packaging/nix/README.md). +### Void Linux (signed xbps repo) + +Void Linux **glibc `x86_64` only**. Each tagged release attaches a signed +personal xbps repository: binary packages (`*.x86_64.xbps`), signatures, +repository metadata (`x86_64-repodata`), and the client key +(`FINGERPRINT` plus the fingerprint-named `.plist`). + +This is a **third-party repository** — it is published by this project, +not by upstream Void Linux, and its packages are built from the +templates in `packaging/void/srcpkgs/`. Details: [packaging/void/README.md](packaging/void/README.md). + +```bash +# as root on the Void system (the files come from the release assets): +cp .plist /var/db/xbps/keys/ +echo 'repository=' > /etc/xbps.d/voculinux.conf +``` + +Prefer the release notes for the exact repository URL. On first sync, +answer `Y` at the fingerprint prompt only after checking it matches the +`FINGERPRINT` file attached to the same release: + +```bash +sudo xbps-install -Sy voculinux +``` + ### From source ```bash diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 533fe1b..e098450 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -54,6 +54,7 @@ Use this checklist for every release: - [ ] `docs/INSTALL.md` - Verify install examples use `main/install.sh` (not version-pinned raw URLs) - [ ] `docs/UPDATE.md` - Add "What's New" section for new version - [ ] `SECURITY.md` - Update supported versions table +- [ ] `packaging/void/srcpkgs/` - After the tag, bump template `version`/`checksum` to the new release as a pull request (never direct to `main`) ### Website - [ ] `web/src/app/page.tsx` - Update `softwareVersion` in schema @@ -310,6 +311,19 @@ Update the supported versions table: | < 0.4 | :x: | ``` +#### 3.5 Update Void templates (pull request) + +After the tag is cut, the Void templates in `packaging/void/srcpkgs/` must +point at the new release: bump `version` in the `voculinux` template (and +any template whose upstream moved) and refresh every pinned `checksum` +(`sha256` sums of the release sdist and distfiles). Land the bump as a +**pull request** through the normal test, lint, and type gates — never as +a direct or bot commit to `main`, and never hand-edited onto the release +itself (see `just verify-release`). `tests/test_void_app_template.py` +fails if the template version or checksum drifts from +`src/vocalinux/version.py` and the published sdist, and the signed xbps +repository for the *next* release builds from these templates. + ### Step 4: Update Website #### 4.1 Update `web/src/app/page.tsx` diff --git a/tests/test_release_integrity.py b/tests/test_release_integrity.py index 4548d67..af007e4 100644 --- a/tests/test_release_integrity.py +++ b/tests/test_release_integrity.py @@ -118,11 +118,13 @@ def test_release_publication_cannot_bypass_the_wheel_gate(): "publish-aur", "publish-aur-bin", "publish-flatpak-remote", + "build-xbps", ): assert "build-and-release" in _needs(jobs[name]), name checksums = jobs["publish-checksums"] assert "needs.build-and-release.result == 'success'" in checksums - assert "pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)'" in checksums + pattern = "'@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64|xbps-repo)'" + assert f"pattern: {pattern}" in checksums def test_aur_bin_publication_depends_only_on_its_appimage_inputs() -> None: @@ -177,6 +179,10 @@ def test_the_manifest_covers_every_kind_of_artifact_we_publish(): assert checksum_line, "publish-checksums does not generate SHA256SUMS" for pattern in ("*.whl", "*.tar.gz", "*.AppImage", "*.flatpak", "*.snap"): assert pattern in checksum_line.group(1), f"{pattern} is published but unchecksummed" + # The signed xbps repo hashes from its own directories (bare names, same + # as the flat files the release attaches), alongside the client key. + for pattern in ("*.x86_64.xbps", "*-repodata*", "FINGERPRINT", "*.plist"): + assert pattern in block, f"{pattern} is published but unchecksummed" def test_provenance_is_generated_from_the_published_manifest(): diff --git a/tests/test_void_signed_repo.py b/tests/test_void_signed_repo.py index 3735d76..ffde3e6 100644 --- a/tests/test_void_signed_repo.py +++ b/tests/test_void_signed_repo.py @@ -33,6 +33,9 @@ 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" @@ -198,3 +201,96 @@ def test_readme_documents_the_signed_repo_flow() -> None: "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"