* 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
175 lines
7.6 KiB
Python
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
|