feat(ci): run install.sh in the distro matrix instead of grepping it (#810)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
Executable
+194
@@ -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)"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user