diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f386f1..57c7450 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -290,19 +290,12 @@ jobs: 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=https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/' > /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. + Not attached here. The signed xbps repository is published by the + tag-triggered Gitea workflow `.gitea/workflows/xbps-release.yml` to + the release named `latest` on the project's Gitea host. It is a + third-party repository published by this project, not upstream Void + Linux. The registration lines and the key fingerprint to pin are in + the Void Linux section of the README. ### Verifying what you downloaded `SHA256SUMS` covers every file here and is attached once the aarch64 AppImage, @@ -804,7 +797,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, build-xbps] + needs: [build-and-release, build-appimage-arm64, build-native, build-native-arm64, build-flatpak-amd64, build-flatpak-arm64, attach-flatpak, attach-snap] 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 @@ -820,7 +813,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|xbps-repo)' + pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)' merge-multiple: true path: dist @@ -831,9 +824,6 @@ 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 @@ -845,12 +835,8 @@ jobs: set -euo pipefail ls -la # Bare names, so `sha256sum -c` works in whatever directory the user - # 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. + # downloaded the release into. 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. @@ -1043,94 +1029,6 @@ 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/tests/test_release_integrity.py b/tests/test_release_integrity.py index af007e4..30c5129 100644 --- a/tests/test_release_integrity.py +++ b/tests/test_release_integrity.py @@ -118,12 +118,11 @@ 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 - pattern = "'@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64|xbps-repo)'" + pattern = "'@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)'" assert f"pattern: {pattern}" in checksums @@ -179,10 +178,12 @@ 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" + # The signed xbps repository is published by the Gitea tag workflow, not + # attached to this release, so the manifest must not require or hash it: + # a pattern for files that never arrive would fail every release. + assert "xbps" not in _without_comments(block).lower() + for pattern in ("*.x86_64.xbps", "*-repodata*", "FINGERPRINT"): + assert pattern not in block, f"{pattern} is not a GitHub release artifact" 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 1b57dde..34f5574 100644 --- a/tests/test_void_signed_repo.py +++ b/tests/test_void_signed_repo.py @@ -34,6 +34,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml" +GITEA_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml" ROOT_README = REPO_ROOT / "README.md" RELEASE_PROCESS = REPO_ROOT / "docs" / "RELEASE_PROCESS.md" VOID = REPO_ROOT / "packaging" / "void" @@ -85,7 +86,8 @@ def test_pipeline_builds_every_template_and_nothing_else() -> None: "not a second hand-maintained list" ) assert set(_smoke_packages()) == set(_srcpkg_templates()), ( - f"smoke order covers {_smoke_packages()}, " f"srcpkgs holds {_srcpkg_templates()}" + f"smoke order covers {_smoke_packages()}, " + f"srcpkgs holds {_srcpkg_templates()}" ) @@ -102,11 +104,15 @@ def test_pipeline_stays_podman_only_with_pinned_inputs() -> None: (REPO_SH, _repo_text()), (CONTAINER_REPO, _container_text()), ): - assert "podman" in text or path == CONTAINER_REPO, f"{path.name} must use podman" + 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 ( + "ghcr.io" not in _repo_text() + ), "the base image pin lives in tool_checksums.txt" assert PINS.is_file(), f"missing pins file: {PINS}" @@ -130,15 +136,21 @@ def test_pipeline_requires_rsa4096_before_any_container_runs() -> None: 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" + 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 ( + "-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" @@ -176,7 +188,9 @@ def test_container_proves_signatures_with_a_clean_rootdir_install() -> None: 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" + 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: @@ -187,14 +201,20 @@ def test_just_recipe_calls_the_pipeline_like_other_packaging_gates() -> None: 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" + 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 ( + "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 ( @@ -208,62 +228,26 @@ def _release_workflow_text() -> str: 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() +def test_the_xbps_job_lives_in_the_gitea_workflow_not_the_github_release() -> None: + """One signing path: the tag-triggered Gitea workflow runs repo.sh.""" + github = _release_workflow_text() + for marker in ( + "build-xbps", + "xbps-repo", + "XBPS_SIGNING_KEY", + "packaging/void/repo.sh", + ): + assert ( + marker not in github + ), f"release.yml must not carry the xbps job ({marker})" + gitea = GITEA_WORKFLOW.read_text(encoding="utf-8") assert ( - "packaging/void/repo.sh" in 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" + "packaging/void/repo.sh" in gitea + ), "the Gitea workflow must run the signed-repo pipeline" + assert ( + SIGNING_SECRET in gitea + ), "the Gitea workflow must read the XBPS_SIGNING_KEY secret" + assert "podman" in gitea, "the Gitea workflow must run the pipeline with podman" #: Concrete release-download prefix every client snippet must name. The @@ -287,14 +271,15 @@ def test_client_registration_names_concrete_repo_url() -> None: assert ( "" not in text ), f"{name} still carries the self-referential repo-URL placeholder" - assert "repository=<" not in text, f"{name} still carries a placeholder repository URL" - assert CONCRETE_REPO_DOWNLOAD in root, "root README must name the concrete release download URL" + assert ( + "repository=<" not in text + ), f"{name} still carries a placeholder repository URL" + assert ( + CONCRETE_REPO_DOWNLOAD in root + ), "root README must name the concrete release download URL" assert ( CONCRETE_REPO_DOWNLOAD in void_readme ), "void README must name the concrete release download URL" - assert ( - "releases/download/${{ github.ref_name }}" in workflow - ), "release.yml must render the concrete per-tag download URL at publish time" def test_root_readme_registers_the_void_repo() -> None: @@ -302,7 +287,9 @@ def test_root_readme_registers_the_void_repo() -> None: 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 ( + marker in text + ), f"root README must document client registration ({marker})" assert "xbps-install" in text, "root README must give the install command"