feat(packaging): AUR -bin package publishing with per-arch digests (#879)

* feat(aur): add vocalinux-bin PKGBUILD shipping the AppImage

Closes #817

* fix(aur): hard-dep injection tools, pin real digests, gate the -bin package

* fix(aur): pin both-arch -bin digests and build the binpkg as builder

updpkgsums in the publish action only regenerates the x86_64 sum array, leaving aarch64 stale after every tag; release.yml now pins both AppImage digests itself and publishes with updpkgsums off. The AUR gate also created $WORKDIR/binpkg as root after handing WORKDIR to builder, so makepkg could not write there. AUR.md now matches the published -bin package.

* fix(ci): wait for aarch64 AppImage before publishing vocalinux-bin

publish-aur only needed build-and-release, so on a tag the digest-pinning
step could curl the aarch64 AppImage before build-appimage-arm64 attached
it — a 404 that failed the job between the source and -bin publishes.

Split the -bin bump/pin/publish steps into publish-aur-bin, which needs
[publish-aur, build-appimage-arm64]: the source package still ships as
early as before while the binary package only runs once both AppImages
exist on the release.

* fix(ci): ship -bin local sources and wait for the aarch64 asset on AUR publish

Split the -bin bump/pin/publish into publish-aur-bin needing
[publish-aur, build-appimage-arm64] so the digest-pinning step can no
longer curl a 404 for the not-yet-attached aarch64 AppImage mid-release,
and publish with asset_dir so the PKGBUILD's local sources
(vocalinux.desktop, vocalinux.svg, LICENSE) reach the AUR repo instead
of a lone PKGBUILD users cannot build.

* style(tests): apply black formatting to aur packaging test

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-09-30 00:27:28 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 5c031e64fb
commit b4babcaeca
8 changed files with 982 additions and 5 deletions
+127
View File
@@ -12,6 +12,8 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
AUR = REPO_ROOT / "packaging" / "aur"
PKGBUILD = AUR / "vocalinux" / "PKGBUILD"
BIN_DIR = AUR / "vocalinux-bin"
BIN_PKGBUILD = BIN_DIR / "PKGBUILD"
GATE_SH = AUR / "build-test.sh"
WORKFLOWS = REPO_ROOT / ".github" / "workflows"
PIPELINE = WORKFLOWS / "unified-pipeline.yml"
@@ -319,3 +321,128 @@ def test_every_selectable_engine_reaches_optdepends() -> None:
"these optional engines have no optdepends line, so an AUR user who"
f" selects them gets an ImportError: {', '.join(missing)}"
)
def _bin_pkgbuild() -> str:
return BIN_PKGBUILD.read_text(encoding="utf-8")
def test_bin_package_ships_the_release_appimage_per_arch() -> None:
"""#817: the -bin package is the prebuilt AppImage, fetched per release
architecture — x86_64 and aarch64, never 'any', because a binary is not
arch-independent the way the build-from-source package is."""
text = _bin_pkgbuild()
assert "arch=('x86_64' 'aarch64')" in text
for arch in ("x86_64", "aarch64"):
asset = f"Vocalinux-${{_tag}}-{arch}.AppImage"
assert (
f'"{asset}::https://github.com/VocaHQ/vocalinux/releases/download/'
f"v${{_tag}}/{asset}" in text
), f"source_{arch} does not fetch {asset} from the v${{_tag}} release"
def test_bin_package_installs_the_appimage_intact_and_runnable() -> None:
"""options=('!strip') keeps pacman from rewriting the self-mounting ELF;
fuse2 is the one real dependency — the type-2 runtime mounts its squashfs
through FUSE. The binary lands under /opt and is symlinked into PATH."""
text = _bin_pkgbuild()
assert "options=('!strip')" in text
assert "'fuse2'" in text
assert 'install -Dm755 "Vocalinux-${_tag}-${CARCH}.AppImage"' in text
assert '"/opt/${pkgname}/' in text
assert "ln -s" in text and '"${pkgdir}/usr/bin/vocalinux"' in text
def test_bin_package_conflicts_with_the_source_and_git_packages() -> None:
"""All three packages ship /usr/bin/vocalinux; provides= lets a depends on
'vocalinux' resolve to the -bin install."""
text = _bin_pkgbuild()
assert "provides=('vocalinux')" in text
conflicts = re.search(r"conflicts=\(([^)]*)\)", text)
assert conflicts, "vocalinux-bin has no conflicts=()"
for name in ("'vocalinux'", "'vocalinux-git'"):
assert name in conflicts.group(1)
def test_bin_pkgbuild_bundled_files_match_the_repo_copies() -> None:
"""The launcher entry, icon and license ship next to the PKGBUILD —
copies, not the AppImage's own .desktop (its Exec= is AppRun, meaningless
outside the bundle). Guarded byte-for-byte so a fix to a canonical file
cannot leave the -bin package shipping the stale one."""
for name, canonical in (
("vocalinux.desktop", REPO_ROOT / "vocalinux.desktop"),
("vocalinux.svg", REPO_ROOT / "resources" / "icons" / "scalable" / "vocalinux.svg"),
("LICENSE", REPO_ROOT / "LICENSE"),
):
bundled = BIN_DIR / name
assert bundled.is_file(), f"{bundled} missing"
assert bundled.read_bytes() == canonical.read_bytes(), (
f"{name} drifted from {canonical.relative_to(REPO_ROOT)};" " sync the bundled copy"
)
assert f"'{name}'" in _bin_pkgbuild(), f"{name} not in source=()"
desktop = (BIN_DIR / "vocalinux.desktop").read_text(encoding="utf-8")
# The names package() links into /usr/bin and installs under hicolor.
assert re.search(r"^Exec=vocalinux$", desktop, re.M)
assert re.search(r"^Icon=vocalinux$", desktop, re.M)
def test_bin_package_asks_the_host_only_for_what_the_bundle_lacks() -> None:
"""The AppImage bundles CPython, GTK and the speech engines; what it
cannot ship is the host's text-injection tools and Vulkan loader
(docs/INSTALL.md). The required injection tools are hard depends — on
X11 without active IBus the injector needs xdotool and on Wayland a host
tool — while the optional fallbacks stay in optdepends. None of the
source package's python-* depends belong here, or pacman would install a
second Python stack next to the bundled one."""
text = _bin_pkgbuild()
depends = re.search(r"depends=\(\n([^)]*)\)", text)
assert depends and "python-" not in depends.group(1)
assert "'xdotool'" in depends.group(1)
assert "'wtype'" in depends.group(1)
lines = text.splitlines()
start = next((i for i, line in enumerate(lines) if line.startswith("optdepends=(")), None)
assert start is not None, "vocalinux-bin has no optdepends=()"
end = next(i for i in range(start + 1, len(lines)) if lines[i].strip() == ")")
optdepends = "\n".join(lines[start + 1 : end])
for tool in ("ibus", "xclip", "wl-clipboard", "ydotool"):
assert f"'{tool}" in optdepends
assert "vulkan-icd-loader" in optdepends
def test_bin_pkgbuild_tracks_the_release_tag_like_the_source_one() -> None:
"""Same pkgver/_tag split as packaging/aur/vocalinux, real digests for
the published AppImages, and the release workflow must bump and publish
both — a -bin left on the last tag would serve the previous release's
AppImage."""
text = _bin_pkgbuild()
assert re.search(r"^pkgver=\d", text, re.M)
assert re.search(r"^_tag=\d", text, re.M)
# Local builds must verify payloads against real sums, not SKIP.
assert "'SKIP'" not in text
release = RELEASE.read_text(encoding="utf-8")
assert "packaging/aur/vocalinux-bin/PKGBUILD" in release, (
"release.yml bumps the source PKGBUILD only; the -bin package would"
" keep installing the previous release's AppImage"
)
# The publish step's own updpkgsums only refreshes the x86_64 array, so
# release.yml pins both per-arch digests itself right before publishing.
assert re.search(r"for arch in x86_64 aarch64[\s\S]*sha256sums_\$\{arch\}", release), (
"release.yml must pin per-arch AppImage digests for vocalinux-bin — "
"the publish action's updpkgsums only covers the runner's arch"
)
# The aarch64 AppImage lands in a later job; the -bin publish must wait
# for it or the digest-pin step curls a 404 mid-release.
assert re.search(
r"publish-aur-bin:[\s\S]*?needs:\s*\[[^\]]*build-appimage-arm64", release
), "publish-aur-bin must need build-appimage-arm64 so the aarch64 asset exists"
# The -bin PKGBUILD names local sources; a lone-PKGBUILD publish leaves
# users unable to build. asset_dir mirrors the whole directory to AUR.
assert "asset_dir: packaging/aur/vocalinux-bin" in release
def test_the_gate_builds_the_bin_package_too() -> None:
"""The -bin package needs build validation as much as the source one:
the gate stubs the release AppImage and runs makepkg on it."""
script = GATE_SH.read_text(encoding="utf-8")
assert "vocalinux-bin" in script
assert "--skipchecksums" in script