From ba6462ccf8313fc0b54dbad39f1bfda1f483ff8d Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 18:26:14 +0530 Subject: [PATCH] refactor(ci): move the xbps job out of the GitHub release workflow The signed repository is now published by the tag-triggered Gitea workflow, so release.yml drops the build-xbps job, its place in the checksum job's needs list, the xbps-repo artifact pattern, and the checksum lines for files that no longer arrive. The release notes point at the Gitea release instead of promising attached repository files. The policy tests now assert the new shape: release.yml carries no xbps job, and the Gitea workflow is the one place that runs repo.sh. --- .github/workflows/release.yml | 120 +++-------------------------- tests/test_release_integrity.py | 13 ++-- tests/test_void_signed_repo.py | 129 ++++++++++++++------------------ 3 files changed, 74 insertions(+), 188 deletions(-) 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"