From 5687363a4a8bf572854fb0fd921fb4dd896d3ff7 Mon Sep 17 00:00:00 2001 From: Sergey Silaev Date: Tue, 27 Oct 2026 04:26:04 +0100 Subject: [PATCH] fix(aur): gate source publication on attested sdists (#1020) * fix(aur): gate source publication on attested sdists Prepare and verify the canonical release source, build the exact PKGBUILD on Arch, and publish the gated artifact without further rewrites. Part of #701. * fix(aur): isolate local gate and binary publication Keep local gate distributions and diagnostics in a private workspace, preserving other build outputs and failed diagnostics. Publish vocalinux-bin after its own AppImage inputs without depending on the source gate or unrelated release jobs. Add regression coverage for both review findings. --- .github/workflows/release.yml | 72 +++++-- .github/workflows/unified-pipeline.yml | 40 +++- AGENTS.md | 5 +- docs/AUR.md | 75 ++++++- docs/RELEASE_PROCESS.md | 20 +- justfile | 37 ++-- packaging/aur/build-test.sh | 55 ++--- packaging/aur/vocalinux/PKGBUILD | 17 +- scripts/prepare_aur_source.py | 172 ++++++++++++++++ tests/test_aur_gate.py | 175 ++++++++++++++++ tests/test_aur_packaging.py | 73 +++++-- tests/test_prepare_aur_source.py | 265 +++++++++++++++++++++++++ tests/test_release_integrity.py | 21 +- 13 files changed, 925 insertions(+), 102 deletions(-) create mode 100644 scripts/prepare_aur_source.py create mode 100644 tests/test_aur_gate.py create mode 100644 tests/test_prepare_aur_source.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a436e22..f04c91b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -871,9 +871,51 @@ jobs: with: skip-existing: true + aur-source-test: + name: Verify AUR release source (gate) + needs: [build-and-release, publish-checksums] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + attestations: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: '0.13.3' + - name: Download the published sdist and checksum manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release download "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \ + --pattern 'vocalinux-*.tar.gz' --pattern SHA256SUMS --dir release-source + - name: Verify provenance and prepare the exact source PKGBUILD + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv run --python "$(command -v python)" --locked --only-group release-build \ + python scripts/prepare_aur_source.py \ + --sdist release-source/vocalinux-*.tar.gz --manifest release-source/SHA256SUMS \ + --tag "$GITHUB_REF_NAME" --source-commit "$GITHUB_SHA" --output build/aur-source + - name: Build and install the exact source PKGBUILD on Arch + run: | + docker run --rm -v "$PWD:/repo:ro" -e AUR_SOURCE_ONLY=1 -e AUR_FETCH_SOURCE=1 \ + archlinux:latest bash /repo/packaging/aur/build-test.sh + - name: Retain only the verified source PKGBUILD for publication + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: aur-source-pkgbuild + path: build/aur-source/PKGBUILD + if-no-files-found: error + retention-days: 7 + publish-aur: name: Publish to AUR - needs: build-and-release + needs: [build-and-release, aur-source-test] runs-on: ubuntu-latest permissions: contents: read @@ -891,27 +933,20 @@ jobs: echo "configured=true" >> "$GITHUB_OUTPUT" fi - - name: Checkout code + - name: Download the verified source PKGBUILD if: steps.aur.outputs.configured == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Bump PKGBUILD version - if: steps.aur.outputs.configured == 'true' - run: | - set -euo pipefail - TAG="${GITHUB_REF#refs/tags/v}" - for PKGBUILD in packaging/aur/vocalinux/PKGBUILD packaging/aur/vocalinux-bin/PKGBUILD; do - sed -i "s/^pkgver=.*/pkgver=${TAG//-/}/; s/^_tag=.*/_tag=${TAG}/; s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD" - grep -E '^(pkgver|_tag|pkgrel)=' "$PKGBUILD" - done + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: aur-source-pkgbuild + path: aur-source - name: Publish AUR package if: steps.aur.outputs.configured == 'true' uses: KSXGitHub/github-actions-deploy-aur@084b0d9b15415bf9cdb65d44dad1efe37a354050 # v4.2.0 with: pkgname: vocalinux - pkgbuild: packaging/aur/vocalinux/PKGBUILD - updpkgsums: 'true' + pkgbuild: aur-source/PKGBUILD + updpkgsums: 'false' commit_username: ${{ secrets.AUR_USERNAME }} commit_email: ${{ secrets.AUR_EMAIL }} ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} @@ -919,11 +954,12 @@ jobs: # Separate job: the -bin PKGBUILD pins digests of BOTH AppImages, and the # aarch64 build attaches its asset after build-and-release returns. Waiting - # for it here keeps the source package publishing early while the binary - # package never curls a 404. + # for it here keeps the binary package from curling a 404. Do not depend on + # source publication: its Arch gate and unrelated checksum producers must + # not block ready AppImages. publish-aur-bin: name: Publish -bin to AUR - needs: [publish-aur, build-appimage-arm64] + needs: [build-and-release, build-appimage-arm64] runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index ec47b6c..b10b801 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -67,6 +67,9 @@ jobs: # the whole python matrix along. aur: - 'packaging/aur/**' + - 'MANIFEST.in' + - 'README.md' + - 'LICENSE' - '.github/workflows/unified-pipeline.yml' # Same carve-out for the .deb/.rpm gate: nfpm.yaml mirrors # pyproject.toml's dependencies against distro package names, so a @@ -116,6 +119,15 @@ jobs: version: '0.13.3' - name: Build and verify reproducible wheel run: python scripts/build_reproducible_wheel.py --python "$(command -v python)" + - name: Share the canonical distributions with packaging gates + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-dist + path: | + dist/*.whl + dist/*.tar.gz + if-no-files-found: error + retention-days: 7 - name: Preserve failed wheel builds and diagnostics if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -383,21 +395,35 @@ jobs: "${{ matrix.distro }}" \ bash /pk/boot-test.sh "/dist/$(basename "$APPIMAGE")" - # Nothing built the PKGBUILD before a tag pushed it to AUR users, which is - # how #757 shipped: Arch's setuptools 84 against our old <82 cap. The gate - # swaps the not-yet-existing tag tarball for a git archive of the checkout, - # so it answers whether *this commit* builds on Arch. Deliberately - # archlinux:latest: the AUR is a rolling channel and #757 was Arch moving - # under us. + # Build the canonical sdist produced by this commit's wheel gate. PR sources + # are explicitly unattested fixtures; release.yml verifies published sources + # before passing the same prepared PKGBUILD to this Arch gate. Deliberately + # archlinux:latest: the AUR follows Arch's current distro toolchain (#757). aur-build-test: name: 🏛️ Build AUR package (gate) runs-on: ubuntu-latest - needs: changes + needs: [changes, reproducible-wheel] if: needs.changes.outputs.python == 'true' || needs.changes.outputs.aur == 'true' timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: '0.13.3' + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-dist + path: dist + - name: Prepare the local unattested source fixture + run: | + uv run --python "$(command -v python)" --locked --only-group release-build \ + python scripts/prepare_aur_source.py \ + --local-unattested --sdist dist/vocalinux-*.tar.gz --output build/aur-source + - name: Build the PKGBUILD on Arch run: | docker run --rm \ diff --git a/AGENTS.md b/AGENTS.md index 6c0a887..61dda6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ just appimage # build the AppImage in its pinned base image (needs docker) just appimage-boot fedora:42 # boot that AppImage in a distro container just native-packages # build the .deb and .rpm in the pinned base image (needs docker) just native-smoke fedora:42 # install the built package in a distro container -just aur-gate # build the AUR PKGBUILD on current Arch (needs docker) +just aur-gate # build canonical sdist + unchanged AUR fixture on current Arch (needs uv/docker) just install-gate debian:12 # run install.sh unattended in a distro container just remote-install-gate debian:12 # remote curl|bash path end to end against HEAD (needs docker) just verify-release # check a published release as published (needs gh) @@ -127,6 +127,7 @@ Use **uv 0.13.3** for `just lock` and `just export-check`, matching CI. Other ve | pywhispercpp | Pinned in `install.sh` as `PYWHISPERCPP_VERSION` (keep in sync with `uv.lock`) | | Installer build tools | `[dependency-groups].installer-build` exports to `requirements/installer-build.txt`. Bootstrap uses hashed wheels on new and reused venvs; source builds use these tools without build isolation. Its setuptools `>=83` floor keeps the pinned installer tools patched; do not add it to `[build-system]` (Arch). Backend rebuilds select pywhispercpp's complete hash block from `runtime.txt` via `scripts/installer_requirements.py`, with `--no-deps` so they cannot replace the runtime | | Release build tools | `[dependency-groups].release-build` exports to `requirements/release-build.txt`. `scripts/build_reproducible_wheel.py` builds in two fresh source trees/venvs with hashed tools and `--no-isolation`, compares wheel bytes, and stages only canonical distributions in `dist/`. These build-only venvs exclude system site-packages: host setuptools plugins must not influence locked builds, and packaging does not import GTK. Repeat runs clean recognized diagnostics and staged archives matching retained canonical digests; staged copies are deleted first so interrupted cleanup remains retryable. Unrelated/modified files are preserved and block cleanup. Never wipe all of `build/`. PR and release CI use Python 3.11 and pinned uv. Failed build outputs/logs stay under `build/reproducible-wheel/`, excluded from release artifacts | +| AUR source publication | Use the canonical release sdist, not a GitHub tag archive. `scripts/prepare_aur_source.py` (run with the locked `release-build` group) requires its published checksum and provenance from the exact release workflow/tag/commit before preparing a pinned PKGBUILD. Release CI builds/installs that unchanged recipe on current Arch; publication consumes only the gated artifact with `updpkgsums: false`. PR/local fixtures must explicitly use `--local-unattested` and are never publication inputs. `just aur-gate` keeps distributions, diagnostics and tooling in its own temporary directory, leaving shared packaging outputs and dev environments untouched; failures retain that directory and print its path. `vocalinux-bin` publication depends only on release creation and both AppImages, not the source gate or global checksums. Existing AUR provider stubs/pinned import smoke remain limited as documented in `docs/AUR.md` | | Installer Whisper export | Compiled universally from Python 3.11, constrained by `runtime.txt` and `installer-build.txt` to keep shared pins consistent. Uses a `+cpu` torch pin and best-match index resolution so the CPU index's older copies of shared packages do not downgrade the runtime | | Remote installer | After cloning, hand off to the tagged `install.sh` with the original arguments and remote venv path. The installer and its exports must come from the same revision, including when the tag predates the pinned installer | | `[vad]` extra | `onnxruntime` for Silero VAD | @@ -240,7 +241,7 @@ Site-only work: `web/AGENTS.md` (commands, layout map). Product claims: `web/PRO ## Runtime (any Linux dev machine) -- Create runtime/dev venvs with `--system-site-packages`. Build-only venvs in `scripts/build_reproducible_wheel.py` are the exception: exclude host packages to keep the release toolchain isolated +- Create runtime/dev venvs with `--system-site-packages`. Build-only venvs for `scripts/build_reproducible_wheel.py` and `just aur-gate` preparation are the exception: exclude host packages to keep the locked release tools isolated; never prune the developer environment to run a packaging gate - Kill the app by PID, never `pkill -f vocalinux` (matches editors, shells, and cwd paths) - Stale instance: `$XDG_DATA_HOME/vocalinux/instance.lock` (default `~/.local/share/vocalinux/instance.lock`) - Default dictation shortcut: hold Right Alt (push-to-talk). Existing `~/.config/vocalinux/config.json` wins diff --git a/docs/AUR.md b/docs/AUR.md index a1e6eef..cd9ee3b 100644 --- a/docs/AUR.md +++ b/docs/AUR.md @@ -3,7 +3,7 @@ ## Users ```bash -yay -S vocalinux # latest release tag +yay -S vocalinux # canonical release sdist, built against Arch dependencies yay -S vocalinux-git # community package; tracks main ``` @@ -11,13 +11,16 @@ yay -S vocalinux-git # community package; tracks main `packaging/aur/vocalinux-bin/` builds a `-bin` variant that installs the prebuilt AppImage from the release — no Python, engine, or GPU dependency -builds, since the bundle carries them (#817). The release workflow publishes -it alongside the source package: it first pins the per-arch AppImage digests -in the PKGBUILD, then pushes to AUR in the `Publish AUR -bin package` step. +builds, since the bundle carries them (#817). Its publication waits only for +release creation and both AppImages, not the source-package gate or other +packaging jobs. It first pins the per-arch AppImage digests in the PKGBUILD, +then pushes to AUR in the `Publish AUR -bin package` step. ## Maintainers (one-time setup) -Release tags auto-push `packaging/aur/vocalinux/PKGBUILD` when these secrets exist: +Release tags prepare and test the source PKGBUILD from +`packaging/aur/vocalinux/PKGBUILD`, then auto-push the verified file when these +secrets exist: | Secret | Value | |--------|--------| @@ -38,11 +41,67 @@ git clone ssh://aur@aur.archlinux.org/vocalinux.git Sources: `packaging/aur/vocalinux/PKGBUILD`. Community git package: https://aur.archlinux.org/packages/vocalinux-git -## CI gate +## Source archive and trust model -Every PR that changes the Python source or `packaging/aur/**` builds the PKGBUILD on `archlinux:latest` — the `Build AUR package (gate)` job in `unified-pipeline.yml`. Run it locally with `just aur-gate` (needs docker). +The source package consumes the **canonical sdist attached to the GitHub release**, +the same bytes published to PyPI, not GitHub's automatically generated tag archive. +The sdist is produced by the locked reproducible-wheel gate. Only wheels are +compared for reproducibility; sdists are built and validated, not compared. -The gate swaps the tag-archive `source=` for a `git archive` of the checkout, so it answers whether *this commit* builds on Arch — #757 reached users after the tag, when nothing could fail a build anymore (#736 reached Arch users too, but through `install.sh`; this package built fine). `python-pywhispercpp` (virtual, provided by the AUR backends) and `python-pynput` (AUR) cannot come from the repos, so the gate satisfies them with a stub for dependency resolution and pip-installs the pinned versions from `requirements/runtime.txt` for the import smoke. What AUR actually ships (python-pywhispercpp-cpu is still 1.4.x — see Troubleshooting) is a monitoring question, not the gate's. +`scripts/prepare_aur_source.py` verifies the downloaded sdist against the published +`SHA256SUMS` and runs `gh attestation verify`, requiring the VocaHQ/vocalinux +repository, `.github/workflows/release.yml` signer, exact tag ref, and expected +source commit. Missing hashes, mismatches, rejected provenance, or mismatched +tag/package versions stop preparation. It preserves the original archive bytes +and pins their digest in the prepared PKGBUILD. `_tag` retains the release tag; +`_version` is the PEP 440-normalized sdist version, including for prereleases. + +This verification is a **maintainer publication gate**. An AUR user's `makepkg` +checks the pinned SHA-256; it does not independently verify GitHub provenance. +Arch still owns the dependency/toolchain versions: this does not claim that AUR +builds use our Python lock or reproduce the PyPI wheel byte-for-byte. + +## CI gates + +### PR and local source gate + +Python/AUR changes and changes to the source manifest, README or license build the +canonical sdist from the current checkout's reproducible-wheel gate, then prepare +an explicitly `--local-unattested` fixture. The `Build AUR package (gate)` job in +`unified-pipeline.yml` builds and installs its PKGBUILD on `archlinux:latest`. +Run `just aur-gate` locally (needs uv and Docker); it includes the reproducible-wheel +build and uses a fresh temporary directory for the distributions, diagnostics, +tooling and prepared fixture. Existing `dist/`, `build/reproducible-wheel/` and +the developer environment are untouched, so other packaging commands can run +before or after it. Successful runs clean the temporary directory; failed runs +retain it and print its path for diagnosis. + +### Release gate + +After the release checksum/provenance job succeeds, `aur-source-test` downloads +the **published** sdist and manifest, verifies them, and prepares the PKGBUILD. +The same Arch gate makes `makepkg` download the actual source URL afresh +(`AUR_FETCH_SOURCE=1`) and verify its checksum, runs `namcap` on the recipe and +package, installs the package, checks the installed distribution version, and +runs the import smoke. It does not rewrite the source URL or disable source +checksum checks. The tested recipe and downloaded archive must match the +prepared PKGBUILD and provenance-verified sdist byte-for-byte. + +Only after this succeeds is `aur-source-pkgbuild` uploaded for `publish-aur`. +Publication downloads that exact file; there is no version rewrite or +`updpkgsums` afterwards. The gate runs even without AUR credentials; only the +final push is secret-gated. This intentionally makes source publication wait +for the release's checksums and attestations. `vocalinux-bin` uses a separate +AppImage publication path independent of this source gate and the global +checksum job; its PR smoke still uses a stub AppImage. + +Both gates deliberately use rolling `archlinux:latest`: #757 was Arch's +setuptools moving underneath us. `python-pywhispercpp` (virtual, provided by the +AUR backends) and `python-pynput` (AUR) cannot come from the repos, so the gate +satisfies them with a stub for dependency resolution and pip-installs their +pinned versions from `requirements/runtime.txt` for the import smoke. It does +not test those AUR providers or prove dependency completeness in a minimal host. +What those providers actually ship remains a separate monitoring question. ## Troubleshooting diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index f84beb0..f57a6fc 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -129,6 +129,24 @@ active run fails without changing the first run's outputs. Archives are copied t an ignored temporary sibling of `dist/` and then atomically renamed into place, so interrupted staging leaves only complete archives in `dist/` and can be retried. +### AUR source publication gate + +After `publish-checksums`, `aur-source-test` downloads the published canonical +sdist and `SHA256SUMS`. `scripts/prepare_aur_source.py` checks the digest and +cryptographically verifies provenance against this repository's release workflow, +tag ref and source commit before preparing the checksum-pinned PKGBUILD. +The exact prepared recipe is built, checked with namcap, installed and smoked on +current Arch. `publish-aur` downloads only that verified PKGBUILD artifact and +pushes it without further version/checksum rewrites (`updpkgsums: false`). + +This gate runs even without AUR publication credentials. Failure prevents the +AUR push, but does not unpublish GitHub/PyPI artifacts already released. It is +separate from PR/local `--local-unattested` fixtures, which exercise the same +preparation/build path but are never used for publication. See [AUR.md](AUR.md) +for the trust model and the existing AUR-provider smoke-test limitations. +`vocalinux-bin` publication waits only for release creation and both AppImages; +source-gate failures or unrelated checksum producers do not block it. + ## Detailed Release Steps ### Step 1: Determine Version Number @@ -183,7 +201,7 @@ When shipping bug fixes only (PATCH bump, same MINOR): - **Website changelog** (`web/src/app/changelog/page.tsx`) may list only the delta for the new entry; older entries stay historical. - **AppStream** (`packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml`): add a short `` note for the patch delta only; leave the long product feature `