Files
voculinux/tests/test_aur_packaging.py
T
SergeyandGitHub e61133e246 feat(ci): gate AUR PKGBUILD builds on every PR (#772)
* feat(ci): gate AUR PKGBUILD builds on every PR

Nothing ever ran makepkg on the PKGBUILD before a tag pushed it to AUR
users, and the channel broke twice that way (#736, #757) — a PKGBUILD
change did not even start a CI job, because no paths filter matched
packaging/aur/**.

The gate (packaging/aur/build-test.sh, run in archlinux:latest — the
AUR is a rolling channel, so the image stays deliberately unpinned):

- swaps the tag-archive source= for a git archive of the checkout under
  the same filename and prefix, so build()/package() run unchanged and
  the gate answers "does this commit build on Arch"
- installs depends/makedepends from the repos; python-pywhispercpp
  (virtual, provided by the AUR backends, #579) and python-pynput (AUR)
  are satisfied by a stub so makepkg still verifies the full depends
  set, and pip-installed from the hash-pinned requirements/runtime.txt
  for the import smoke
- runs namcap on the PKGBUILD and the built package, failing on errors
  only (its warnings are noise around lazy imports and virtual depends)
- installs the package and imports the whole UI/recognition stack, plus
  the vocalinux --version entry point

Part of #701 (phase 6).

* fix(ci): stop partial-upgrading in the AUR gate and correct the #736 attribution
2026-09-04 23:25:09 -07:00

175 lines
7.6 KiB
Python

"""Regression guards for the AUR packaging and its build gate.
#757 reached AUR users because no CI ever ran makepkg on the PKGBUILD before
a tag pushed it to them — and a PKGBUILD change triggered zero jobs in the
first place, because no paths filter matched packaging/aur/**.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
AUR = REPO_ROOT / "packaging" / "aur"
PKGBUILD = AUR / "vocalinux" / "PKGBUILD"
GATE_SH = AUR / "build-test.sh"
WORKFLOWS = REPO_ROOT / ".github" / "workflows"
PIPELINE = WORKFLOWS / "unified-pipeline.yml"
RELEASE = WORKFLOWS / "release.yml"
RUNTIME_EXPORT = REPO_ROOT / "requirements" / "runtime.txt"
#: depends entries no Arch repo can provide: python-pywhispercpp is a virtual
#: name the AUR -cpu/-cuda/-rocm backends provide (#579), python-pynput lives
#: in the AUR. The gate satisfies them with a stub package; adding another
#: AUR-resolved name to the PKGBUILD means adding it here too.
AUR_RESOLVED = ("python-pywhispercpp", "python-pynput")
def _pipeline_text() -> str:
return PIPELINE.read_text(encoding="utf-8")
def _job_block(workflow_text: str, job: str) -> str:
"""The job's lines, from its key to the next key at the same indent."""
lines = workflow_text.splitlines(keepends=True)
start = next(
(i for i, line in enumerate(lines) if line.rstrip("\n") == f" {job}:"),
None,
)
assert start is not None, f"no {job} job in the workflow"
block = [lines[start]]
for line in lines[start + 1 :]:
# The next job starts at two-space indent; deeper lines belong to us.
if re.match(r"^ \S", line):
break
block.append(line)
return "".join(block)
def _aur_depends() -> set:
"""The depends=() entries of the PKGBUILD, one per line by convention."""
lines = PKGBUILD.read_text(encoding="utf-8").splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith("depends=("))
names = set()
for line in lines[start + 1 :]:
if line.strip() == ")":
break
match = re.match(r"\s*'([^']+)'", line)
assert match, f"depends=() entry '{line}' is not one-per-line quoted"
names.add(match.group(1))
return names
def test_aur_changes_reach_ci():
"""The bug that let the channel break silently: packaging/aur/** matched
no paths filter, so a PKGBUILD change started zero jobs."""
text = _pipeline_text()
assert re.search(r"^ aur:\n", text, re.M), (
"unified-pipeline.yml has no aur filter; PKGBUILD changes again start" " zero jobs"
)
aur_block = re.search(r"^ aur:\n((?:\s+- '[^']+'\n)+)", text, re.M)
assert "packaging/aur/**" in aur_block.group(1)
# The filter's output has to be consumed, or the job never runs.
assert "aur: ${{ steps.filter.outputs.aur }}" in text
def test_the_gate_runs_on_python_changes_and_aur_changes():
"""depends=() mirrors pyproject.toml, so every python change must reach
the AUR gate; the dedicated aur filter exists so a PKGBUILD-only change
runs it without dragging the whole python matrix along."""
block = _job_block(_pipeline_text(), "aur-build-test")
assert "needs.changes.outputs.python == 'true'" in block
assert "needs.changes.outputs.aur == 'true'" in block
assert "packaging/aur/build-test.sh" in block, "the job must run the gate"
assert "archlinux" in block, "the gate builds on Arch, not the runner"
def test_the_gate_builds_this_commit_not_the_last_tag():
"""The published source= points at the tag tarball, which does not exist
until the tag is pushed — the reason nothing could run the PKGBUILD on a
PR. The gate swaps it for a git archive of HEAD under the same filename
and the same directory prefix build()/package() cd into."""
script = GATE_SH.read_text(encoding="utf-8")
pkgbuild = PKGBUILD.read_text(encoding="utf-8")
assert "git archive" in script and '--prefix="${pkgname}-${_tag}/"' in script
assert re.search(r'cd "\$\{pkgname\}-\$\{_tag\}"', pkgbuild), (
"build()/package() no longer cd into ${pkgname}-${_tag}; the gate's"
" archive prefix and the PKGBUILD must agree"
)
# The substitution itself.
assert re.search(r"s\|\^source=.*\|source=", script)
# And the published PKGBUILD still fetches the tag archive — if that
# changes, updpkgsums at publish time and this gate both need a relook.
assert "archive/refs/tags/v${_tag}.tar.gz" in pkgbuild
assert "sha256sums=('SKIP')" in pkgbuild
assert "updpkgsums: 'true'" in RELEASE.read_text(encoding="utf-8"), (
"release.yml no longer computes the digest at publish time; the"
" sha256sums=('SKIP') in the repo would ship to users"
)
def test_the_gate_uses_a_non_root_build_user():
"""makepkg refuses to run as root; the gate must drop privileges."""
script = GATE_SH.read_text(encoding="utf-8")
assert "useradd" in script
assert re.search(r"runuser -u \w+ -- makepkg", script)
def test_namcap_covers_both_the_pkgbuild_and_the_package_and_fails_on_errors():
"""namcap is noisy about lazy imports and virtual depends, so the gate
fails on E tags only — but it must run on both targets to be worth its
output."""
script = GATE_SH.read_text(encoding="utf-8")
assert 'check_namcap "$BUILD/PKGBUILD"' in script
assert 'check_namcap "$BUILD"/*.pkg.tar.*' in script
assert "grep -q ' E: '" in script
def test_the_stub_matches_the_aur_resolved_depends():
"""makepkg verifies every depends entry, so the gate's stub has to
provide exactly the names no repo can. A name the stub still provides
that the PKGBUILD dropped, or a new AUR-resolved dep the stub does not
know, fails the gate at pacman -Sy — loudly, but far from the cause."""
script = GATE_SH.read_text(encoding="utf-8")
names = _aur_depends()
for name in AUR_RESOLVED:
assert name in names, (
f"{name} is satisfied by the gate's stub but no longer depended"
" on; update AUR_RESOLVED in both the script and this test"
)
assert f"'{name}'" in script, (
f"{name} is AUR-resolved but the gate's stub does not provide it;"
" makepkg will fail on dependency verification"
)
def test_the_smoke_walks_the_pinned_deps():
"""The import smoke runs the two AUR-resolved deps from the hash-pinned
export (under their PyPI names), not from whatever AUR ships, so the pin
has to exist there."""
script = GATE_SH.read_text(encoding="utf-8")
export = RUNTIME_EXPORT.read_text(encoding="utf-8")
assert "requirements/runtime.txt" in script
assert "--require-hashes" in script
assert "pacman -U" in script and "vocalinux --version" in script
# Arch name -> PyPI name for the export the gate installs from.
pypi_names = {"python-pywhispercpp": "pywhispercpp", "python-pynput": "pynput"}
for arch_name, pypi_name in pypi_names.items():
assert arch_name in AUR_RESOLVED or arch_name in _aur_depends()
assert re.search(rf"^{pypi_name}==\S+ \\", export, re.M), (
f"{pypi_name} is not pinned in requirements/runtime.txt; the smoke"
" would have nothing versioned to install"
)
def test_the_pkgbuild_builds_without_isolation_on_distro_setuptools():
"""#757: the PKGBUILD builds --no-isolation against Arch's setuptools
(84 at the time), which is why the setuptools upper cap had to come off
pyproject.toml. Switching to an isolated build is a decision that
changes what the gate proves, not a slip to make in passing."""
pkgbuild = PKGBUILD.read_text(encoding="utf-8")
assert "--no-isolation" in pkgbuild