feat(ci): run install.sh in the distro matrix instead of grepping it (#810)

This commit is contained in:
Sergey
2026-09-09 23:43:40 -07:00
committed by GitHub
parent 94c2eaac08
commit b45a26e1cd
7 changed files with 502 additions and 273 deletions
+40 -272
View File
@@ -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"
+1
View File
@@ -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
+11 -1
View File
@@ -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
+18
View File
@@ -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`
+194
View File
@@ -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)"
+6
View File
@@ -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
+232
View File
@@ -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"
)