From b45a26e1cdd0a21ccd5550a06d8bd3668e482625 Mon Sep 17 00:00:00 2001 From: Sergey Date: Thu, 10 Sep 2026 08:43:40 +0200 Subject: [PATCH] feat(ci): run install.sh in the distro matrix instead of grepping it (#810) --- .github/workflows/distro-test-matrix.yml | 312 +++-------------------- AGENTS.md | 1 + install.sh | 12 +- justfile | 18 ++ scripts/install-test.sh | 194 ++++++++++++++ tests/test_cross_distro_compatibility.py | 6 + tests/test_installer_gate.py | 232 +++++++++++++++++ 7 files changed, 502 insertions(+), 273 deletions(-) create mode 100755 scripts/install-test.sh create mode 100644 tests/test_installer_gate.py diff --git a/.github/workflows/distro-test-matrix.yml b/.github/workflows/distro-test-matrix.yml index 18a9b0d..dc2cede 100644 --- a/.github/workflows/distro-test-matrix.yml +++ b/.github/workflows/distro-test-matrix.yml @@ -4,27 +4,38 @@ on: push: branches: [ main, master ] paths: + - 'install.sh' - '**/*.sh' - 'scripts/**' - 'pyproject.toml' + - 'uv.lock' + - 'requirements/**' - 'src/vocalinux/version.py' + - 'vocalinux.desktop' - '.github/workflows/distro-test-matrix.yml' pull_request: branches: [ main, master ] paths: + - 'install.sh' - '**/*.sh' - 'scripts/**' - 'pyproject.toml' + - 'uv.lock' + - 'requirements/**' - 'src/vocalinux/version.py' + - 'vocalinux.desktop' - '.github/workflows/distro-test-matrix.yml' workflow_dispatch: +# Six containers a run, on a filter that any shell script matches: a second +# push should not leave the first one's matrix running to completion. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - # The repo's only `bash -n install.sh` used to live inside the matrix below, - # under continue-on-error, so a syntax error in a 4698-line installer could - # not fail a build anywhere. This job is the one thing here allowed to fail. - # It is not phase 2.4 — nothing here runs the installer yet — it just stops - # the cheapest check we have from being advisory. + # First, because there is no point spending six containers on a script that + # does not parse. syntax: name: Shell syntax runs-on: ubuntu-latest @@ -35,16 +46,21 @@ jobs: set -euo pipefail git ls-files -z '*.sh' | xargs -0 -n1 -t bash -n - test: - name: Test on ${{ matrix.container }} + install: + name: install.sh on ${{ matrix.container }} runs-on: ubuntu-latest - continue-on-error: true + needs: syntax + # A real install pulls the distro's whole GTK and build-toolchain closure + # before pip sees a single wheel. + timeout-minutes: 45 strategy: + # Every distro reports independently: one failing arm is a finding about + # that distro, not a reason to lose the other five results. fail-fast: false matrix: - # README.md advertises Arch and Tumbleweed as tested and neither was - # here; fedora:39 has been EOL since 2024 while the AppImage boot - # matrix moved to 42. This list is what the docs promise. + # Adding a container here means adding a bootstrap arm to + # scripts/install-test.sh; tests/test_installer_gate.py holds the two + # lists together. container: - ubuntu:24.04 - ubuntu:26.04 @@ -52,270 +68,22 @@ jobs: - fedora:42 - archlinux:latest - opensuse/tumbleweed - container: - image: ${{ matrix.container }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The gate takes the tree with `git archive HEAD`, so it needs the + # commit's objects. Shallow is enough; the history is not. + fetch-depth: 1 - - name: Install basic dependencies + # Not `container:` on the job: that would run every step inside the image + # as root, and install.sh refuses to run as root. Docker from the runner + # lets the gate create an unprivileged user with sudo instead. + - name: Run install.sh unattended in ${{ matrix.container }} run: | - set -e - # Install bash and basic tools - if command -v apt-get >/dev/null 2>&1; then - apt-get update - apt-get install -y bash curl python3 git - elif command -v dnf >/dev/null 2>&1; then - # Fedora needs cache update first in container environment - dnf clean all || true - dnf makecache || true - dnf install -y bash curl python3 git || { - echo "⚠ dnf install failed, trying with --nogpgcheck..." - dnf install -y --nogpgcheck bash curl python3 git - } - elif command -v pacman >/dev/null 2>&1; then - # -Syu, never -Sy: archlinux:latest lags the mirrors and a partial - # upgrade would be our breakage, not the installer's. - pacman -Syu --noconfirm bash curl python git - elif command -v zypper >/dev/null 2>&1; then - zypper --non-interactive --gpg-auto-import-keys refresh - zypper --non-interactive install bash curl python3 git - fi - - # ============================================================================ - # TEST 1: Verify /etc/os-release exists and distro detection works - # ============================================================================ - - name: Test distro detection - run: | - echo "=== Testing Distro Detection ===" - if [ -f /etc/os-release ]; then - echo "/etc/os-release exists: OK" - cat /etc/os-release - else - echo "/etc/os-release NOT found: FAIL" - exit 1 - fi - - # ============================================================================ - # TEST 2: Test the distro detection logic from install.sh - # ============================================================================ - # Note: We cannot source install.sh directly because it has a root check - # that fails in containers. Instead, we test the same logic independently. - - name: Test detect_distro() function - run: | - echo "=== Testing detect_distro() Function ===" - - # Verify the function exists in install.sh - if grep -q "detect_distro()" install.sh; then - echo "✓ detect_distro() function found in install.sh" - else - echo "✗ detect_distro() function not found" - exit 1 - fi - - # Test the distro detection logic (same as in install.sh) - . /etc/os-release - DISTRO_NAME="$NAME" - DISTRO_ID="$ID" - DISTRO_VERSION="$VERSION_ID" - DISTRO_FAMILY="unknown" - - if [[ "$ID" == "ubuntu" || "$ID_LIKE" == *"ubuntu"* || "$ID" == "pop" || "$ID" == "linuxmint" || "$ID" == "elementary" || "$ID" == "zorin" ]]; then - DISTRO_FAMILY="ubuntu" - elif [[ "$ID" == "debian" || "$ID_LIKE" == *"debian"* ]]; then - DISTRO_FAMILY="debian" - elif [[ "$ID" == "fedora" || "$ID_LIKE" == *"fedora"* || "$ID" == "rhel" || "$ID" == "centos" || "$ID" == "rocky" || "$ID" == "almalinux" ]]; then - DISTRO_FAMILY="fedora" - elif [[ "$ID" == "arch" || "$ID_LIKE" == *"arch"* || "$ID" == "manjaro" || "$ID" == "endeavouros" ]]; then - DISTRO_FAMILY="arch" - elif [[ "$ID" == "opensuse" || "$ID_LIKE" == *"suse"* ]]; then - DISTRO_FAMILY="suse" - elif [[ "$ID" == "gentoo" ]]; then - DISTRO_FAMILY="gentoo" - elif [[ "$ID" == "alpine" ]]; then - DISTRO_FAMILY="alpine" - elif [[ "$ID" == "void" ]]; then - DISTRO_FAMILY="void" - elif [[ "$ID" == "solus" ]]; then - DISTRO_FAMILY="solus" - elif [[ "$ID" == "mageia" ]]; then - DISTRO_FAMILY="mageia" - fi - - echo "Detected: $DISTRO_NAME $DISTRO_VERSION ($DISTRO_FAMILY family)" - echo "DISTRO_FAMILY=$DISTRO_FAMILY" - echo "DISTRO_ID=$DISTRO_ID" - - # Verify we detected a known family - if [[ "$DISTRO_FAMILY" == "unknown" ]]; then - echo "⚠ Warning: Distro family is 'unknown', this may indicate incomplete detection" - else - echo "✓ Distro detection successful" - fi - - # ============================================================================ - # TEST 3: Test detect_typelib_path() function - # ============================================================================ - - name: Test detect_typelib_path() function - run: | - echo "=== Testing detect_typelib_path() Function ===" - - # Install pkg-config first (needed for typelib detection) - if command -v apt-get >/dev/null 2>&1; then - apt-get install -y pkg-config libgirepository1.0-dev 2>/dev/null || true - elif command -v dnf >/dev/null 2>&1; then - dnf install -y pkg-config gobject-introspection-devel 2>/dev/null || true - elif command -v pacman >/dev/null 2>&1; then - pacman -S --needed --noconfirm pkgconf gobject-introspection 2>/dev/null || true - elif command -v zypper >/dev/null 2>&1; then - zypper --non-interactive install pkgconf-pkg-config gobject-introspection-devel 2>/dev/null || true - fi - - # Test typelib path detection - bash -c ' - # Function from install.sh - detect_typelib_path() { - # Try pkg-config first (most reliable) - if command -v pkg-config >/dev/null 2>&1; then - local path=$(pkg-config --variable=typelibdir gobject-introspection-1.0 2>/dev/null) - if [ -n "$path" ] && [ -d "$path" ]; then - echo "$path" - return 0 - fi - fi - - # Fallback to common distribution-specific paths - for path in \ - /usr/lib/x86_64-linux-gnu/girepository-1.0 \ - /usr/lib/aarch64-linux-gnu/girepository-1.0 \ - /usr/lib/arm-linux-gnueabihf/girepository-1.0 \ - /usr/lib/riscv64-linux-gnu/girepository-1.0 \ - /usr/lib/powerpc64le-linux-gnu/girepository-1.0 \ - /usr/lib/s390x-linux-gnu/girepository-1.0 \ - /usr/lib64/girepository-1.0 \ - /usr/lib/girepository-1.0 \ - /usr/local/lib/girepository-1.0 \ - /usr/local/lib64/girepository-1.0; do - if [ -d "$path" ]; then - echo "$path" - return 0 - fi - done - - # Ultimate fallback - echo "/usr/lib/girepository-1.0" - return 1 - } - - TYPOB_PATH=$(detect_typelib_path) - echo "Detected typelib path: $TYPOB_PATH" - - if [ -d "$TYPOB_PATH" ]; then - echo "✓ Typelib path exists: $TYPOB_PATH" - ls -la "$TYPOB_PATH" | head -5 || true - else - echo "⚠ Typelib path does not exist: $TYPOB_PATH (may be OK if pkg-config not available)" - fi - ' - - # ============================================================================ - # TEST 4: Check install.sh syntax - # ============================================================================ - - name: Check install.sh syntax - run: | - echo "=== Checking install.sh Syntax ===" - bash -n install.sh - echo "✓ install.sh: Syntax OK" - - # ============================================================================ - # TEST 5: Run the dependency checker - # ============================================================================ - - name: Run dependency checker - run: | - echo "=== Running Dependency Checker ===" - bash scripts/check-system-deps.sh || echo "⚠ Some dependencies missing (expected in container)" - continue-on-error: true - - # ============================================================================ - # TEST 6: Test installer help command - # ============================================================================ - - name: Test installer --help - run: | - echo "=== Testing Installer --help ===" - bash install.sh --help || true - - # ============================================================================ - # TEST 7: Test minimal install (venv setup only) - # ============================================================================ - - name: Test minimal installer (dry-run venv setup) - run: | - echo "=== Testing Minimal Installer (venv setup) ===" - - # Install minimal dependencies needed for venv creation - if command -v apt-get >/dev/null 2>&1; then - apt-get update -qq - apt-get install -y python3-venv python3-pip python3-dev 2>/dev/null || true - elif command -v dnf >/dev/null 2>&1; then - dnf install -y python3-virtualenv python3-pip python3-devel 2>/dev/null || true - elif command -v pacman >/dev/null 2>&1; then - pacman -S --needed --noconfirm python python-pip 2>/dev/null || true - elif command -v zypper >/dev/null 2>&1; then - zypper --non-interactive install python3-pip python3-devel 2>/dev/null || true - fi - - # Create a temporary venv to test the flow - python3 -m venv /tmp/test-venv || { - echo "⚠ Could not create venv (may need additional system deps)" - exit 0 - } - - echo "✓ Virtual environment created successfully" - ls -la /tmp/test-venv/bin/ - rm -rf /tmp/test-venv - - # ============================================================================ - # TEST 8: Test Python import paths - # ============================================================================ - - name: Test Python import paths - run: | - echo "=== Testing Python Import Paths ===" - python3 -c "import sys; sys.path.insert(0, 'src'); print('Python path configured')" - python3 --version - echo "✓ Python is available" - - # ============================================================================ - # SUMMARY: Aggregate results from all container tests - # ============================================================================ - summary: - name: Test Summary - runs-on: ubuntu-latest - needs: test - if: always() - steps: - - name: Check test results - run: | - echo "====================================" - echo "Distro Test Matrix Results" - echo "====================================" - echo "" - echo "All container tests have completed." - echo "Check individual job logs for details." - echo "" - echo "Expected behavior:" - echo " ✓ ubuntu:24.04 - Full support expected" - echo " ✓ ubuntu:26.04 - Full support expected" - echo " ✓ debian:12 - Full support expected" - echo " ⚠ fedora:42 - Good support, minor differences expected" - echo " ⚠ archlinux:latest - Rolling; also the AUR package's distro" - echo " ⚠ opensuse/tumbleweed - Rolling, least exercised" - echo "" - echo "Key tests performed:" - echo " 1. /etc/os-release availability" - echo " 2. Distro detection function" - echo " 3. Typelib path detection" - echo " 4. install.sh syntax validation (also gated in the syntax job)" - echo " 5. Dependency checker" - echo " 6. Installer --help command" - echo " 7. Virtual environment creation" - echo " 8. Python availability" + set -euo pipefail + docker run --rm \ + -v "$PWD:$PWD:ro" -e REPO="$PWD" \ + "${{ matrix.container }}" \ + bash "$PWD/scripts/install-test.sh" diff --git a/AGENTS.md b/AGENTS.md index f2fd6eb..78fe909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ just model-checksums # refresh pinned model digests after adding a model 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 aur-gate # build the AUR PKGBUILD on current Arch (needs docker) +just install-gate debian:12 # run install.sh unattended in a distro container just verify-release # check a published release as published (needs gh) just pre-commit # pre-commit run --all-files just run-debug # vocalinux --debug diff --git a/install.sh b/install.sh index 872aeb7..9c85b51 100755 --- a/install.sh +++ b/install.sh @@ -1762,7 +1762,17 @@ install_system_dependencies() { local APT_PACKAGES_DEBIAN_13_PLUS="$APT_PACKAGES_DEBIAN_BASE libgirepository-2.0-dev gir1.2-ayatanaappindicator3-0.1" local DNF_PACKAGES="python3-pip python3-gobject gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel python3-virtualenv pkg-config cmake wget curl unzip vulkan-tools vulkan-loader-devel glslc patchelf xclip xsel wl-clipboard" local PACMAN_PACKAGES="python-pip python-gobject gtk3 ibus gobject-introspection python-cairo portaudio python-virtualenv pkg-config cmake wget curl unzip base-devel vulkan-tools vulkan-headers shaderc patchelf xclip xsel wl-clipboard" - local ZYPPER_PACKAGES="gtk3 ibus-devel gobject-introspection-devel portaudio-devel pkg-config cmake wget curl unzip xclip xsel wl-clipboard typelib-1_0-Notify-0_7 libnotify4 patchelf" + # ibus + typelib-1_0-IBus-1_0, not ibus-devel: the headers are not needed + # (IBus is reached through GI at runtime), and requiring them pulls gtk-doc, + # which zypper cannot satisfy when awk resolves to busybox-gawk. The runtime + # package is needed: ibus_engine.py spawns `ibus-daemon -x -d -r` and shells + # out to `ibus engine`, and ibus-devel used to pull it in transitively + # (Requires: ibus = %version). The typelib is named as well as implied by + # ibus's own typelib(IBus) require, so the GI dependency stays visible here. + # gcc/gcc-c++/make: the counterpart of build-essential and base-devel above. + # python3-devel supplies headers, not a compiler, and evdev and pyaudio ship + # no wheels, so without these they have nothing to build with. + local ZYPPER_PACKAGES="gtk3 ibus typelib-1_0-IBus-1_0 gobject-introspection-devel portaudio-devel pkg-config cmake gcc gcc-c++ make wget curl unzip xclip xsel wl-clipboard typelib-1_0-Notify-0_7 libnotify4 patchelf" # Gentoo uses Portage and different package naming convention local EMERGE_PACKAGES="dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.11 pkgconf cmake media-libs/shaderc dev-util/patchelf x11-misc/xclip x11-misc/xsel gui-apps/wl-clipboard" # Alpine Linux uses apk and has musl libc diff --git a/justfile b/justfile index d5ca285..a92f431 100644 --- a/justfile +++ b/justfile @@ -122,6 +122,24 @@ aur-gate: fi docker run "${ARGS[@]}" archlinux:latest bash "$PWD/packaging/aur/build-test.sh" +# Run install.sh unattended in a distro container, as the CI gate does. Answers +# "does this commit install" (needs docker). +# +# The tree is taken via git archive of HEAD and extracted inside the container, +# so the venv local mode creates lands in the container's copy rather than in +# your working tree. Same worktree handling as aur-gate above. +# +# Usage: `just install-gate` for debian:12, or `just install-gate fedora:42` +install-gate distro="debian:12": + #!/usr/bin/env bash + set -euo pipefail + COMMON="$(cd "$(git rev-parse --git-common-dir)" && pwd)" + ARGS=(--rm -v "$PWD:$PWD:ro" -e REPO="$PWD") + if [ "$COMMON" != "$PWD/.git" ]; then + ARGS+=(-v "$COMMON:$COMMON:ro") + fi + docker run "${ARGS[@]}" {{distro}} bash "$PWD/scripts/install-test.sh" + # Check that a published release verifies as published: manifest, provenance, # notes and PyPI digests. Needs gh, downloads nothing. # Usage: `just verify-release` for the latest, or `just verify-release v0.16.2` diff --git a/scripts/install-test.sh b/scripts/install-test.sh new file mode 100755 index 0000000..f31f0b9 --- /dev/null +++ b/scripts/install-test.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Run install.sh unattended in a distro container and check what it installed. +# +# Usage (a read-only mount is enough; the tree is taken via git archive): +# docker run --rm -v "$PWD:/repo:ro" debian:12 bash /repo/scripts/install-test.sh +# or: just install-gate debian:12 +# +# Flags: +# --auto the only non-interactive mode; without it the script blocks +# on prompts that CI has no TTY to answer. +# --skip-models a model is 40-75 MB per container per run, and the download +# path is verified by checksum elsewhere. +# Not --skip-system-deps: the per-distro package installation is the point. +# +# install.sh refuses to run as root and calls sudo directly, so the container +# needs an unprivileged user with passwordless sudo. Running it as root would +# exercise a path no user takes. +# +# Local mode only: install.sh chooses it by finding a pyproject.toml naming +# vocalinux in the working directory, so extracting the tree and running from it +# tests this commit. Remote mode (curl | bash, which clones the latest tag) is +# not covered here. +# +# Failure mode to watch: the install pip-installs pywhispercpp against whatever +# CPython the distro ships. Wheels exist for manylinux_2_28, but when a rolling +# distro moves to a new interpreter before upstream publishes wheels for it, pip +# falls back to the sdist and this goes slow or red for an unrelated reason. +set -euo pipefail + +REPO="${REPO:-/repo}" +INSTALL_USER="${INSTALL_USER:-installer}" +INSTALL_HOME="/home/$INSTALL_USER" +# Local mode puts the venv inside the tree, which is why the checkout is copied +# rather than written to: a bind-mounted host tree would come back owned by a +# container uid with a venv in it. +TREE="$INSTALL_HOME/vocalinux" + +fail() { + echo "FAIL: $*" >&2 + dump_install_log + exit 1 +} + +# install.sh writes a full transcript under ~/.local/state/vocalinux/. It is the +# only place the per-distro package manager output survives. +dump_install_log() { + local log + log="$(ls -1t "$INSTALL_HOME"/.local/state/vocalinux/install-*.log 2>/dev/null | head -n1 || true)" + if [ -n "$log" ]; then + echo "== last 200 lines of $log ==" >&2 + tail -n 200 "$log" >&2 + else + echo "== no install log was written ==" >&2 + fi +} + +# A mirror going slow mid-transaction leaves a half-populated cache, so +# re-running the same transaction resumes rather than restarts. +retry() { + local attempt + for attempt in 1 2 3; do + "$@" && return 0 + echo " '$1' failed (attempt $attempt); retrying" >&2 + sleep 5 + done + return 1 +} + +[ -f "$REPO/install.sh" ] || fail "$REPO/install.sh not found; mount the checkout at \$REPO" + +. /etc/os-release +echo "== ${NAME:-unknown} ${VERSION_ID:-} ==" + +echo "== Bootstrap: sudo, git and an unprivileged user ==" +# The minimum for install.sh to start, and nothing it should install itself. +# git is for the archive below; local mode never calls it. +case "$ID" in + ubuntu | debian) + export DEBIAN_FRONTEND=noninteractive + retry apt-get update -qq + retry apt-get install -y -qq sudo git ca-certificates >/dev/null + ;; + fedora | rhel | centos) + retry dnf install -y -q sudo git shadow-utils >/dev/null + ;; + arch) + # -Syu, never -Sy: archlinux:latest lags the mirrors, and a partial upgrade + # would be this script's breakage rather than the installer's. + retry pacman -Syu --noconfirm --needed sudo git >/dev/null + ;; + opensuse-tumbleweed | opensuse-leap | sles) + retry zypper --non-interactive --gpg-auto-import-keys refresh >/dev/null + retry zypper --non-interactive install -y sudo git >/dev/null + ;; + *) + fail "no bootstrap arm for ID='$ID'; add one when adding the container" + ;; +esac + +id -u "$INSTALL_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$INSTALL_USER" +echo "$INSTALL_USER ALL=(ALL) NOPASSWD: ALL" >"/etc/sudoers.d/$INSTALL_USER" +chmod 0440 "/etc/sudoers.d/$INSTALL_USER" + +echo "== Extract this commit into $TREE ==" +# git archive, not cp: it takes exactly the tracked files a user gets from a +# clone, so a build artefact or stray venv in the working tree cannot make this +# pass. safe.directory because the mount is owned by another uid. +git config --global --add safe.directory '*' +git -C "$REPO" rev-parse --verify HEAD >/dev/null \ + || fail "$REPO is not a git checkout; git archive needs it" +install -d -o "$INSTALL_USER" -g "$INSTALL_USER" "$TREE" +git -C "$REPO" archive HEAD | tar -x -C "$TREE" +chown -R "$INSTALL_USER:$INSTALL_USER" "$TREE" + +# --skip-models below means install.sh never reads this manifest, so nothing in +# this run would notice it leaving the tree. #736 was the release-tag half of +# that shape: an installer verifying models against a manifest the tree it had +# just cloned did not carry. This is the half a local-mode gate can see. +[ -f "$TREE/src/vocalinux/utils/model_checksums.txt" ] \ + || fail "the tree ships no src/vocalinux/utils/model_checksums.txt; install.sh pins model downloads against it" + +echo "== Run install.sh --auto --skip-models as $INSTALL_USER ==" +# su -, so HOME and PATH are the user's own: install.sh writes wrappers into +# $HOME/.local/bin and reads $HOME throughout, and a run with root's HOME would +# install into the wrong place and still succeed. +START=$(date +%s) +if ! su - "$INSTALL_USER" -c "cd '$TREE' && bash install.sh --auto --skip-models"; then + fail "install.sh exited non-zero" +fi +echo " install took $(( $(date +%s) - START ))s" + +echo "== Smoke: is there an installation, and does it import ==" +VENV="$TREE/venv" +[ -x "$VENV/bin/python" ] || fail "no venv interpreter at $VENV/bin/python" +[ -x "$INSTALL_HOME/.local/bin/vocalinux" ] || fail "no wrapper at ~/.local/bin/vocalinux" +[ -f "$INSTALL_HOME/.local/share/applications/vocalinux.desktop" ] \ + || fail "no desktop entry; install_desktop_entry did not run" + +# The injection backends are reached through subprocess, not GI, so no typelib +# import can see one of these missing. These are the binaries every distro's +# package list promises. +for tool in xclip xsel wl-copy; do + command -v "$tool" >/dev/null 2>&1 || fail "$tool is not on PATH; install.sh lists it for every distro" +done + +# ibus-daemon and the ibus CLI are what ibus_engine.py actually spawns, and only +# the arch and suse lists carry the package that provides them. Fedora's +# ibus-devel and Ubuntu's gir1.2-ibus-1.0 pull the library and the typelib +# alone, so this records the split rather than hiding it: on the two distros +# that do promise the runtime, a swap to a typelib-only package fails here +# instead of on a user's first dictation. +case "$ID" in + arch | opensuse-tumbleweed | opensuse-leap | sles) + command -v ibus-daemon >/dev/null 2>&1 \ + || fail "no ibus-daemon: the package list ships the IBus typelib without the runtime ibus_engine.py spawns" + ;; +esac + +# Through the wrapper rather than the venv directly: the wrapper is what the +# desktop entry and the user's PATH invoke, and it carries the GI_TYPELIB_PATH +# and pywhispercpp library-path resolution the bare console script does not. +su - "$INSTALL_USER" -c "'$INSTALL_HOME/.local/bin/vocalinux' --version" \ + || fail "the installed wrapper cannot report a version" + +su - "$INSTALL_USER" -c "'$VENV/bin/python' - " <<'PY' || fail "the installation does not import" +import vocalinux.main +import vocalinux.ui.tray_indicator +import vocalinux.speech_recognition.recognition_manager +import vocalinux.text_injection.text_injector +from vocalinux.ui.keyboard_backends import EVDEV_AVAILABLE, PYNPUT_AVAILABLE +import gi + +# keyboard_backends imports each backend under `except ImportError`, so the +# import above stays clean when evdev and pynput both failed to build. That is +# the openSUSE failure this gate was extended for, so assert the flags rather +# than the import. +assert EVDEV_AVAILABLE or PYNPUT_AVAILABLE, ( + "no keyboard backend is importable: evdev and pynput both failed to install" +) + +# The typelibs the installer is responsible for putting on the system. Both are +# loaded lazily at runtime, so importing the modules above proves nothing about +# them: a missing one would surface on a user's first dictation instead. +gi.require_version("Gtk", "3.0") +gi.require_version("IBus", "1.0") +from gi.repository import Gtk, IBus # noqa: F401 + +print( + " main, tray, recognition, injection, Gtk and IBus import;" + f" keyboard backends: evdev={EVDEV_AVAILABLE} pynput={PYNPUT_AVAILABLE}" +) +PY + +echo "PASS: install.sh installs on ${NAME:-this distro} (this commit, unattended)" diff --git a/tests/test_cross_distro_compatibility.py b/tests/test_cross_distro_compatibility.py index 891afa7..ee99ea8 100644 --- a/tests/test_cross_distro_compatibility.py +++ b/tests/test_cross_distro_compatibility.py @@ -142,6 +142,12 @@ class TestCrossDistroCompatibility: zypper_line = next( line for line in install_sh_content.splitlines() if "local ZYPPER_PACKAGES=" in line ) + zypper_packages = zypper_line.split('"')[1].split() + # ibus, not the typelib alone: ibus_engine.py spawns `ibus-daemon -x -d -r` + # and shells out to `ibus engine`, neither of which libibus provides. + assert "ibus" in zypper_packages + assert "typelib-1_0-IBus-1_0" in zypper_packages + assert "ibus-devel" not in zypper_packages assert "python3-devel" not in zypper_line assert "python3-virtualenv" not in zypper_line assert "libappindicator-gtk3" not in zypper_line diff --git a/tests/test_installer_gate.py b/tests/test_installer_gate.py new file mode 100644 index 0000000..2e54df2 --- /dev/null +++ b/tests/test_installer_gate.py @@ -0,0 +1,232 @@ +"""Regression guards for the install.sh build gate. + +That the matrix job runs the installer, that it is allowed to fail a build, and +that the two lists which have to agree (the matrix's containers and the gate's +bootstrap arms) actually do. + +These match the constructs themselves rather than bare substrings: every path +and flag worth guarding also appears in a comment or in a `fail` message in the +same file, so `"…" in text` passes on a script that no longer does the thing. +""" + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTALLER = REPO_ROOT / "install.sh" +GATE_SH = REPO_ROOT / "scripts" / "install-test.sh" +MATRIX = REPO_ROOT / ".github" / "workflows" / "distro-test-matrix.yml" +JUSTFILE = REPO_ROOT / "justfile" + +#: Container image -> the ID its /etc/os-release reports, which is what the +#: gate's bootstrap `case "$ID"` switches on. A new container needs an entry +#: here and an arm there. +CONTAINER_OS_IDS = { + "ubuntu:24.04": "ubuntu", + "ubuntu:26.04": "ubuntu", + "debian:12": "debian", + "fedora:42": "fedora", + "archlinux:latest": "arch", + "opensuse/tumbleweed": "opensuse-tumbleweed", +} + + +def _matrix_text() -> str: + return MATRIX.read_text(encoding="utf-8") + + +def _gate_text() -> str: + return GATE_SH.read_text(encoding="utf-8") + + +def _without_comments(text: str) -> str: + """Drop whole-line comments, so a guard cannot be satisfied by prose.""" + return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#")) + + +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 :]: + if re.match(r"^ \S", line): + break + block.append(line) + return "".join(block) + + +def _bootstrap_case() -> str: + """The gate's bootstrap `case "$ID"`, which is not the only case on $ID.""" + blocks = re.findall(r'^case "\$ID" in\n(.*?)^esac', _gate_text(), re.M | re.S) + bootstrap = [block for block in blocks if "apt-get" in block] + assert len(bootstrap) == 1, 'no single `case "$ID"` installs the bootstrap packages' + return bootstrap[0] + + +def _matrix_containers() -> list: + """The container images the matrix runs, one per line by convention.""" + block = _job_block(_matrix_text(), "install") + listing = re.search(r"^ container:\n((?:\s+- \S+\n)+)", block, re.M) + assert listing, "the install job has no container list" + return [line.strip()[2:] for line in listing.group(1).splitlines()] + + +def _paths_filters() -> list: + """Every quoted entry under a `paths:` filter, push and pull_request alike.""" + return re.findall(r"^\s+- '([^']+)'$", _matrix_text(), re.M) + + +def test_the_matrix_runs_the_installer(): + """Checking for a function name or creating a venv by hand tests the + container, not the installer.""" + block = _job_block(_matrix_text(), "install") + assert re.search(r"^\s*docker run", block, re.M), "the matrix job must run a container" + assert re.search( + r'^\s*bash "\$PWD/scripts/install-test\.sh"$', block, re.M + ), "the matrix job no longer runs the gate script" + assert re.search(r"&& bash install\.sh --auto", _gate_text()), ( + "the gate no longer invokes install.sh; it would be testing the" + " container rather than the installer again" + ) + + +def test_the_matrix_can_fail_a_build(): + """`continue-on-error: true` makes the whole matrix advisory.""" + block = _job_block(_matrix_text(), "install") + assert not re.search(r"^\s*continue-on-error:\s*true", block, re.M), ( + "the install matrix is advisory again; a gate that cannot fail a build" + " is documentation, not a gate" + ) + + +def test_every_matrix_container_has_a_bootstrap_arm(): + """install.sh needs sudo and a non-root user before it will start, and the + gate provides them per package manager. A container with no arm fails at the + `case "$ID"` fallthrough, 40 minutes into a CI run rather than here.""" + bootstrap = _bootstrap_case() + for container in _matrix_containers(): + assert container in CONTAINER_OS_IDS, ( + f"{container} is in the matrix but not in CONTAINER_OS_IDS; add its" + " /etc/os-release ID so this guard can check the gate handles it" + ) + os_id = CONTAINER_OS_IDS[container] + assert re.search(rf"^ [\w |-]*\b{re.escape(os_id)}\b[\w |-]*\)", bootstrap, re.M), ( + f"{container} reports ID={os_id}, which scripts/install-test.sh has" + " no bootstrap arm for" + ) + + +def test_the_gate_installs_unattended_without_skipping_system_deps(): + """--auto because there is no TTY, --skip-models because a model is 40-75 MB + per container per run and the download path is checksum-verified elsewhere. + Never --skip-system-deps: the per-distro package installation is the point.""" + gate = _without_comments(_gate_text()) + assert re.search(r"bash install\.sh --auto --skip-models", gate) + assert "--skip-system-deps" not in gate, ( + "the gate skips system dependency installation, which is the one part" + " of install.sh the old matrix already failed to exercise" + ) + + +def test_the_gate_checks_the_model_manifest_ships(): + """--skip-models means install.sh never reads model_checksums.txt here, so + the manifest leaving the tree would pass every other check in the gate. That + is the shape of #736, minus the release-tag skew a local-mode gate cannot + reach.""" + assert re.search( + r'\[ -f "\$TREE/src/vocalinux/utils/model_checksums\.txt" \]', _gate_text() + ), "the gate no longer checks the model manifest ships" + assert (REPO_ROOT / "src" / "vocalinux" / "utils" / "model_checksums.txt").is_file() + + +def test_the_gate_runs_as_an_unprivileged_user(): + """install.sh refuses to run as root and calls sudo directly. Running the + gate as root would test a path no user takes; without sudo it would not get + past the first package install.""" + gate = _without_comments(_gate_text()) + assert "useradd" in gate + assert re.search(r'su - "\$INSTALL_USER"', gate) + assert "NOPASSWD" in gate + + +def test_the_installer_still_refuses_root(): + """The assumption the gate's user setup exists for. If install.sh starts + allowing root, that setup is dead weight and should go deliberately.""" + assert re.search(r"\$EUID.{0,12}-eq 0", INSTALLER.read_text(encoding="utf-8")), ( + "install.sh no longer refuses to run as root; scripts/install-test.sh" + " creates an unprivileged user only because of that check" + ) + + +def test_the_gate_takes_this_commit_from_git_not_the_working_directory(): + """git archive rather than cp: a stray venv, build artefact or uncommitted + fix must not be able to make the gate pass, and it is why the checkout can be + mounted read-only while local mode writes a venv. Running from the extracted + tree is what puts install.sh in local mode, and so what makes the gate answer + for this commit rather than for the last tag.""" + assert re.search( + r'git -C "\$REPO" archive HEAD \| tar -x', _gate_text() + ), "the gate no longer extracts the tree from git" + installer = INSTALLER.read_text(encoding="utf-8").splitlines() + assert any( + "grep" in line and "pyproject.toml" in line and "vocalinux" in line for line in installer + ), ( + "local mode is no longer detected from pyproject.toml in the working" + " directory; the gate may now be testing a downloaded tag instead of" + " this commit" + ) + + +def test_installer_changes_reach_the_gate(): + """A path that matches no filter starts no job at all, and the push and + pull_request filters have to carry it independently.""" + filters = _paths_filters() + for needed in ("install.sh", "scripts/**", "pyproject.toml"): + assert ( + filters.count(needed) == 2 + ), f"{needed} is missing from a paths filter; a change there starts no gate" + + +def test_the_gate_is_runnable_locally(): + """CI and the local entry point must run the same script; a gate only CI + can run is one nobody iterates on.""" + justfile = JUSTFILE.read_text(encoding="utf-8") + assert re.search(r"^install-gate distro=", justfile, re.M) + assert "scripts/install-test.sh" in justfile + + +def test_the_smoke_checks_what_the_installer_claims_to_have_created(): + """Asserting exit 0 alone would pass on an installer that silently + installed nothing. These are the artefacts install.sh reports creating.""" + gate = _gate_text() + for construct in ( + r'\[ -x "\$VENV/bin/python" \]', + r'\[ -x "\$INSTALL_HOME/\.local/bin/vocalinux" \]', + r'\[ -f "\$INSTALL_HOME/\.local/share/applications/vocalinux\.desktop" \]', + r"'\$INSTALL_HOME/\.local/bin/vocalinux' --version", + ): + assert re.search(construct, gate), f"the smoke no longer asserts {construct}" + + +def test_the_smoke_checks_what_an_import_walk_cannot_see(): + """Two blind spots of a Python-only smoke: the injection backends are + reached through subprocess, and keyboard_backends imports evdev and pynput + under `except ImportError`, so both stay green when the binaries are absent + or neither backend built.""" + gate = _gate_text() + assert re.search( + r"^for tool in xclip xsel wl-copy; do", gate, re.M + ), "the smoke no longer checks the injection binaries install.sh promises" + assert re.search(r"^\s*command -v ibus-daemon\b", gate, re.M), ( + "the smoke no longer checks the IBus runtime, so trading the openSUSE" + " or arch package for a typelib-only one would pass again" + ) + assert re.search(r"^assert EVDEV_AVAILABLE or PYNPUT_AVAILABLE", gate, re.M), ( + "the smoke imports keyboard_backends without asserting a backend is" + " available, which is the failure the suse compiler packages fix" + )