chore: import vocalinux project
Import the vocalinux source tree (tray app, Next.js site in web/, packaging, installers, docs and tests) on top of the Gitea template commit. The project README replaces the template README, and the template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
This commit is contained in:
Executable
+331
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build independent wheels with locked tooling and stage only verified distributions.
|
||||
|
||||
The source snapshot includes tracked working-tree edits and non-ignored new files,
|
||||
so local review exercises uncommitted changes too. Each build gets a separate copy
|
||||
and virtualenv; no build/ or egg-info from the developer's checkout is reused.
|
||||
Reproducibility is checked for the same Python/platform/toolchain, not across OSes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run(argv: list[str], cwd: Path, env: dict[str, str], log: Path) -> None:
|
||||
"""Run a build step, retaining its output even when the step fails."""
|
||||
with log.open("a", encoding="utf-8") as stream:
|
||||
print(f"$ {' '.join(argv)}", file=stream, flush=True)
|
||||
result = subprocess.run(argv, cwd=cwd, env=env, stdout=stream, stderr=stream)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"command failed ({result.returncode}): {' '.join(argv)}; see {log}")
|
||||
|
||||
|
||||
def snapshot(source: Path, target: Path) -> None:
|
||||
"""Copy Git-visible source files, excluding ignored build and venv state."""
|
||||
files = subprocess.check_output(
|
||||
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=source
|
||||
)
|
||||
for name in sorted(set(files.decode().split("\0")) - {""}):
|
||||
origin = source / name
|
||||
if not origin.exists() and not origin.is_symlink():
|
||||
continue # A tracked file deleted in the working tree.
|
||||
destination = target / name
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(origin, destination, follow_symlinks=False)
|
||||
|
||||
|
||||
def one_file(directory: Path, pattern: str) -> Path:
|
||||
"""Require one output, rather than accidentally comparing stale artifacts."""
|
||||
files = sorted(directory.glob(pattern))
|
||||
if len(files) != 1:
|
||||
raise RuntimeError(f"expected exactly one {pattern} in {directory}, found {len(files)}")
|
||||
return files[0]
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
"""Return the SHA-256 of the original archive bytes."""
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def compare_wheels(first: Path, second: Path, report: Path) -> None:
|
||||
"""Reject any byte difference, including ZIP metadata, and explain the mismatch."""
|
||||
lines = [f"{digest(path)} {path}" for path in (first, second)]
|
||||
# Compare bytes, not re-packed/normalized ZIPs: metadata is part of the wheel.
|
||||
equal = first.name == second.name and first.read_bytes() == second.read_bytes()
|
||||
if not equal:
|
||||
lines.append("FAIL: wheel filenames or bytes differ")
|
||||
if first.name != second.name:
|
||||
lines.append(f"filenames: {first.name} != {second.name}")
|
||||
try:
|
||||
with zipfile.ZipFile(first) as left, zipfile.ZipFile(second) as right:
|
||||
if left.namelist() != right.namelist():
|
||||
lines.append("ZIP member names/order differ")
|
||||
for name in sorted(set(left.namelist()) | set(right.namelist())):
|
||||
if name not in left.namelist() or name not in right.namelist():
|
||||
lines.append(f"member present in only one wheel: {name}")
|
||||
elif left.read(name) != right.read(name):
|
||||
lines.append(f"member content differs: {name}")
|
||||
lines.append("Inspect ZIP timestamps, permissions, compression and headers too.")
|
||||
except zipfile.BadZipFile as error:
|
||||
lines.append(f"invalid wheel archive: {error}")
|
||||
else:
|
||||
lines.append("PASS: independently built wheels are byte-for-byte identical")
|
||||
report.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(report.read_text(encoding="utf-8"), end="")
|
||||
if not equal:
|
||||
raise RuntimeError(f"wheel reproducibility failed; see {report}")
|
||||
|
||||
|
||||
def prepare_outputs(output: Path, diagnostics: Path) -> None:
|
||||
"""Remove only recognizable gate outputs, validating everything before deletion.
|
||||
|
||||
The retained canonical archives identify staged files by name AND digest.
|
||||
Never recursively wipe build/ or dist/: unrelated/modified files require the
|
||||
user to move them aside. Failed builds can be retried even with partial logs
|
||||
and archives, provided no unexpected files are present.
|
||||
"""
|
||||
diagnostic_files: list[Path] = []
|
||||
staged_files: list[Path] = []
|
||||
directories: list[Path] = []
|
||||
archives: dict[str, Path] = {}
|
||||
resolved_output = output.resolve()
|
||||
resolved_diagnostics = diagnostics.resolve()
|
||||
if (
|
||||
resolved_output == resolved_diagnostics
|
||||
or resolved_output in resolved_diagnostics.parents
|
||||
or resolved_diagnostics in resolved_output.parents
|
||||
):
|
||||
raise RuntimeError("output and diagnostics directories must not overlap")
|
||||
for directory in (output, diagnostics):
|
||||
for component in (directory, *directory.parents):
|
||||
if component.is_symlink():
|
||||
raise RuntimeError(f"refusing to clean through symlink: {component}")
|
||||
if directory.exists() and not directory.is_dir():
|
||||
raise RuntimeError(f"refusing to clean non-directory: {directory}")
|
||||
if diagnostics.exists():
|
||||
for entry in diagnostics.iterdir():
|
||||
if entry.is_symlink():
|
||||
raise RuntimeError(f"refusing to clean symlink: {entry}")
|
||||
if entry.name in {"canonical", "independent"} and entry.is_dir():
|
||||
for artifact in entry.iterdir():
|
||||
if (
|
||||
artifact.is_symlink()
|
||||
or not artifact.is_file()
|
||||
or not artifact.name.endswith((".whl", ".tar.gz"))
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"unrecognized diagnostic output; move aside: {artifact}"
|
||||
)
|
||||
diagnostic_files.append(artifact)
|
||||
if entry.name == "canonical":
|
||||
archives[artifact.name] = artifact
|
||||
directories.append(entry)
|
||||
elif entry.is_file() and entry.name in {
|
||||
"inputs.log",
|
||||
"canonical.log",
|
||||
"independent.log",
|
||||
"comparison.txt",
|
||||
}:
|
||||
diagnostic_files.append(entry)
|
||||
else:
|
||||
raise RuntimeError(f"unrecognized diagnostic output; move aside: {entry}")
|
||||
if output.exists():
|
||||
for artifact in output.iterdir():
|
||||
canonical = archives.get(artifact.name)
|
||||
if (
|
||||
artifact.is_symlink()
|
||||
or not artifact.is_file()
|
||||
or canonical is None
|
||||
or digest(artifact) != digest(canonical)
|
||||
):
|
||||
raise RuntimeError(f"unrecognized or modified output; move aside: {artifact}")
|
||||
staged_files.append(artifact)
|
||||
# Delete staged copies first. Their canonical originals are the ownership
|
||||
# evidence needed by the next run if cleanup is interrupted midway through.
|
||||
files = staged_files + diagnostic_files
|
||||
if files:
|
||||
print(f"Cleaning {len(files)} previous reproducible-wheel output files")
|
||||
for path in files:
|
||||
path.unlink()
|
||||
for directory in directories:
|
||||
directory.rmdir()
|
||||
diagnostics.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def checkout_lock() -> Iterator[None]:
|
||||
"""Reject overlapping builds in this checkout before touching their outputs."""
|
||||
# Keep the inode after releasing the lock: unlinking it could let another
|
||||
# process lock a new inode while a contender still holds the original one.
|
||||
with (ROOT / ".reproducible-wheel.lock").open("a") as stream:
|
||||
try:
|
||||
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as error:
|
||||
raise RuntimeError(
|
||||
"another reproducible-wheel build is active in this checkout"
|
||||
) from error
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(stream, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def stage_distributions(artifacts: tuple[Path, ...], output: Path) -> None:
|
||||
"""Copy outside dist/, then atomically publish each complete archive."""
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
# A sibling stays on the output filesystem for atomic renames. If killed
|
||||
# during copying, only an ignored temporary directory can contain partials.
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=".reproducible-wheel-stage-", dir=output.parent
|
||||
) as temporary:
|
||||
staging = Path(temporary)
|
||||
for artifact in artifacts:
|
||||
shutil.copy2(artifact, staging / artifact.name)
|
||||
for artifact in artifacts:
|
||||
(staging / artifact.name).replace(output / artifact.name)
|
||||
|
||||
|
||||
def build(python: str, output: Path, diagnostics: Path) -> None:
|
||||
"""Build twice and expose the canonical wheel/sdist only after verification."""
|
||||
with checkout_lock():
|
||||
_build(python, output, diagnostics)
|
||||
|
||||
|
||||
def _build(python: str, output: Path, diagnostics: Path) -> None:
|
||||
"""Run the gate while holding the checkout lock through final staging."""
|
||||
prepare_outputs(output, diagnostics)
|
||||
# Always derive the timestamp from this commit, not an inherited shell value.
|
||||
epoch = subprocess.check_output(
|
||||
["git", "log", "-1", "--format=%ct", "HEAD"], cwd=ROOT, text=True
|
||||
).strip()
|
||||
env = dict(os.environ)
|
||||
for name in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"):
|
||||
env.pop(name, None)
|
||||
# Override .python-version (the dev interpreter) for the offline lock/export
|
||||
# checks too. A clean release runner only needs the selected build Python.
|
||||
env.update(
|
||||
SOURCE_DATE_EPOCH=epoch,
|
||||
PYTHONNOUSERSITE="1",
|
||||
UV_PYTHON=python,
|
||||
TZ="UTC",
|
||||
LC_ALL="C.UTF-8",
|
||||
)
|
||||
run(["uv", "lock", "--check", "--offline"], ROOT, env, diagnostics / "inputs.log")
|
||||
run(
|
||||
[sys.executable, str(ROOT / "scripts/check_exports.py")],
|
||||
ROOT,
|
||||
env,
|
||||
diagnostics / "inputs.log",
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="vocalinux-wheel-") as temporary:
|
||||
scratch = Path(temporary)
|
||||
source = scratch / "snapshot"
|
||||
source.mkdir()
|
||||
snapshot(ROOT, source)
|
||||
for label, seed in (("canonical", "1"), ("independent", "2")):
|
||||
tree = scratch / label / "source"
|
||||
shutil.copytree(source, tree, symlinks=True)
|
||||
venv = scratch / label / "venv"
|
||||
dist = diagnostics / label
|
||||
dist.mkdir()
|
||||
log = diagnostics / f"{label}.log"
|
||||
build_env = {**env, "PYTHONHASHSEED": seed}
|
||||
run(
|
||||
[
|
||||
"uv",
|
||||
"venv",
|
||||
"--no-config",
|
||||
"--python",
|
||||
python,
|
||||
str(venv),
|
||||
],
|
||||
scratch,
|
||||
build_env,
|
||||
log,
|
||||
)
|
||||
interpreter = str(venv / "bin/python")
|
||||
# Force every exported tool into the venv, even if the system already
|
||||
# has that version. Only wheels, verified by the committed hashes.
|
||||
run(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--no-config",
|
||||
"--python",
|
||||
interpreter,
|
||||
"--require-hashes",
|
||||
"--no-deps",
|
||||
"--only-binary=:all:",
|
||||
"--reinstall",
|
||||
"-r",
|
||||
str(source / "requirements/release-build.txt"),
|
||||
],
|
||||
scratch,
|
||||
build_env,
|
||||
log,
|
||||
)
|
||||
run(
|
||||
[interpreter, "-I", "-m", "build", "--no-isolation", "--outdir", str(dist)],
|
||||
tree,
|
||||
build_env,
|
||||
log,
|
||||
)
|
||||
run(
|
||||
[
|
||||
interpreter,
|
||||
"-I",
|
||||
"-m",
|
||||
"twine",
|
||||
"check",
|
||||
"--strict",
|
||||
str(one_file(dist, "*.whl")),
|
||||
str(one_file(dist, "*.tar.gz")),
|
||||
],
|
||||
tree,
|
||||
build_env,
|
||||
log,
|
||||
)
|
||||
canonical = one_file(diagnostics / "canonical", "*.whl")
|
||||
compare_wheels(
|
||||
canonical,
|
||||
one_file(diagnostics / "independent", "*.whl"),
|
||||
diagnostics / "comparison.txt",
|
||||
)
|
||||
stage_distributions((canonical, one_file(diagnostics / "canonical", "*.tar.gz")), output)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the local and CI release gate."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--python", default=sys.executable, help="build interpreter (CI: Python 3.11)"
|
||||
)
|
||||
parser.add_argument("--output", type=Path, default=ROOT / "dist")
|
||||
parser.add_argument("--diagnostics", type=Path, default=ROOT / "build/reproducible-wheel")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
build(args.python, args.output.absolute(), args.diagnostics.absolute())
|
||||
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/bin/bash
|
||||
# Check for system dependencies regardless of distro
|
||||
# Returns helpful error messages with install commands
|
||||
|
||||
check_deps() {
|
||||
MISSING=0
|
||||
WARNINGS=0
|
||||
|
||||
echo "Checking Vocalinux system dependencies..."
|
||||
echo ""
|
||||
|
||||
# Check for Python 3.11+ (requires-python in pyproject.toml)
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PY_VERSION=$(python3 -c "import sys; print('.'.join(map(str, sys.version_info[:2])))")
|
||||
echo "✓ Python $PY_VERSION"
|
||||
|
||||
if [ "$(echo "$PY_VERSION" | cut -d. -f1)" -lt 3 ] || \
|
||||
[ "$(echo "$PY_VERSION" | cut -d. -f2)" -lt 11 ]; then
|
||||
echo " ✗ Python 3.11+ required, found $PY_VERSION"
|
||||
MISSING=1
|
||||
fi
|
||||
else
|
||||
echo "✗ Python 3 not found"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
# Check for pip
|
||||
if command -v pip3 >/dev/null 2>&1; then
|
||||
echo "✓ pip3"
|
||||
else
|
||||
echo "✗ pip3 not found"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
# Check for pkg-config
|
||||
if command -v pkg-config >/dev/null 2>&1; then
|
||||
echo "✓ pkg-config"
|
||||
else
|
||||
echo "✗ pkg-config not found"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
# Check for GTK3
|
||||
if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists gtk+-3.0 2>/dev/null; then
|
||||
GTK_VERSION=$(pkg-config --modversion gtk+-3.0 2>/dev/null)
|
||||
echo "✓ GTK3 $GTK_VERSION"
|
||||
else
|
||||
echo "✗ GTK3 development files not found"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
# Check for PyGObject
|
||||
if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists gobject-introspection-1.0 2>/dev/null; then
|
||||
echo "✓ GObject Introspection"
|
||||
GI_TYPELIB_PATH=$(pkg-config --variable=typelibdir gobject-introspection-1.0 2>/dev/null)
|
||||
echo " Typelib path: $GI_TYPELIB_PATH"
|
||||
else
|
||||
echo "✗ GObject Introspection not found"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
# Check for AppIndicator/Ayatana AppIndicator GI runtime
|
||||
if python3 - <<'PY' >/dev/null 2>&1
|
||||
import importlib
|
||||
import gi
|
||||
|
||||
for namespace in ("AppIndicator3", "AyatanaAppIndicator3", "AyatanaAppindicator3"):
|
||||
try:
|
||||
gi.require_version(namespace, "0.1")
|
||||
importlib.import_module(f"gi.repository.{namespace}")
|
||||
raise SystemExit(0)
|
||||
except (ImportError, ValueError):
|
||||
pass
|
||||
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
echo "✓ AppIndicator/Ayatana GI runtime"
|
||||
else
|
||||
echo "✗ AppIndicator/Ayatana GI runtime not found"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
# Check for PortAudio
|
||||
if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists portaudio-2.0 2>/dev/null; then
|
||||
PA_VERSION=$(pkg-config --modversion portaudio-2.0 2>/dev/null)
|
||||
echo "✓ PortAudio $PA_VERSION"
|
||||
else
|
||||
echo "⚠ PortAudio development files not found (required for audio)"
|
||||
MISSING=1
|
||||
fi
|
||||
|
||||
# Check for text injection tools
|
||||
echo ""
|
||||
echo "Text injection tools (at least one required):"
|
||||
|
||||
if command -v xdotool >/dev/null 2>&1; then
|
||||
echo "✓ xdotool (X11)"
|
||||
else
|
||||
echo " xdotool not found (X11)"
|
||||
fi
|
||||
|
||||
if command -v wtype >/dev/null 2>&1; then
|
||||
echo "✓ wtype (Wayland)"
|
||||
else
|
||||
echo " wtype not found (Wayland)"
|
||||
fi
|
||||
|
||||
if command -v ydotool >/dev/null 2>&1; then
|
||||
echo "✓ ydotool (Wayland universal)"
|
||||
else
|
||||
echo " ydotool not found (Wayland)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Clipboard tools (recommended for Wayland layout-independent paste fallback):"
|
||||
|
||||
if command -v wl-copy >/dev/null 2>&1; then
|
||||
echo "✓ wl-copy (Wayland clipboard)"
|
||||
else
|
||||
echo " wl-copy not found (Wayland clipboard)"
|
||||
fi
|
||||
|
||||
if command -v xclip >/dev/null 2>&1; then
|
||||
echo "✓ xclip (X11/XWayland clipboard)"
|
||||
else
|
||||
echo " xclip not found (X11/XWayland clipboard)"
|
||||
fi
|
||||
|
||||
if command -v xsel >/dev/null 2>&1; then
|
||||
echo "✓ xsel (X11/XWayland clipboard fallback)"
|
||||
else
|
||||
echo " xsel not found (X11/XWayland clipboard fallback)"
|
||||
fi
|
||||
|
||||
# Detect distribution for install hints
|
||||
echo ""
|
||||
DETECTED_DISTRO="unknown"
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
DETECTED_DISTRO="$ID"
|
||||
DETECTED_DISTRO_LIKE="$ID_LIKE"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "===================================="
|
||||
if [ $MISSING -eq 0 ]; then
|
||||
echo "✓ All critical dependencies found!"
|
||||
return 0
|
||||
else
|
||||
echo "✗ Some dependencies are missing"
|
||||
echo ""
|
||||
echo "Install based on your distribution:"
|
||||
echo ""
|
||||
|
||||
# Determine distro family for install hints
|
||||
if [[ "$DETECTED_DISTRO" == "ubuntu" || "$DETECTED_DISTRO_LIKE" == *"ubuntu"* || "$DETECTED_DISTRO" == "pop" || "$DETECTED_DISTRO" == "linuxmint" || "$DETECTED_DISTRO" == "elementary" || "$DETECTED_DISTRO" == "zorin" ]]; then
|
||||
echo "Ubuntu/Debian-based:"
|
||||
echo " sudo apt update"
|
||||
echo " sudo apt install -y python3-gi gir1.2-gtk-3.0 gir1.2-gdkpixbuf-2.0 portaudio19-dev python3-dev python3-venv pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
elif [[ "$DETECTED_DISTRO" == "fedora" || "$DETECTED_DISTRO_LIKE" == *"fedora"* || "$DETECTED_DISTRO" == "rhel" || "$DETECTED_DISTRO" == "centos" || "$DETECTED_DISTRO" == "rocky" || "$DETECTED_DISTRO" == "almalinux" ]]; then
|
||||
echo "Fedora/RHEL-based:"
|
||||
echo " sudo dnf install -y python3-gobject gtk3-devel libayatana-appindicator-gtk3 gobject-introspection-devel portaudio-devel python3-devel python3-virtualenv pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
elif [[ "$DETECTED_DISTRO" == "arch" || "$DETECTED_DISTRO_LIKE" == *"arch"* || "$DETECTED_DISTRO" == "manjaro" || "$DETECTED_DISTRO" == "endeavouros" ]]; then
|
||||
echo "Arch Linux-based:"
|
||||
echo " sudo pacman -S --needed python-gobject gtk3 libayatana-appindicator gobject-introspection portaudio python pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
elif [[ "$DETECTED_DISTRO" == "opensuse" || "$DETECTED_DISTRO_LIKE" == *"suse"* ]]; then
|
||||
echo "openSUSE:"
|
||||
echo " PYVER=\$(python3 -c 'import sys; print(f\"python{sys.version_info.major}{sys.version_info.minor}\")')"
|
||||
echo " sudo zypper install -y \"\${PYVER}-gobject\" \"\${PYVER}-gobject-cairo\" gtk3 typelib-1_0-AyatanaAppIndicator3-0_1 libayatana-appindicator3-1 typelib-1_0-Notify-0_7 libnotify4 gobject-introspection-devel portaudio-devel \"\${PYVER}-devel\" \"\${PYVER}-virtualenv\" pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
echo " If \"\${PYVER}-virtualenv\" is unavailable, try \"\${PYVER}-venv\"."
|
||||
elif [[ "$DETECTED_DISTRO" == "gentoo" ]]; then
|
||||
echo "Gentoo:"
|
||||
echo " sudo emerge dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.11 xdotool wtype"
|
||||
elif [[ "$DETECTED_DISTRO" == "alpine" ]]; then
|
||||
echo "Alpine Linux:"
|
||||
echo " sudo apk add py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev py3-virtualenv pkgconf xdotool wtype"
|
||||
elif [[ "$DETECTED_DISTRO" == "void" ]]; then
|
||||
echo "Void Linux:"
|
||||
echo " sudo xbps-install -Sy python3-pip python3-gobject gtk+3 libappindicator gobject-introspection portaudio-devel python3-devel pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
elif [[ "$DETECTED_DISTRO" == "solus" ]]; then
|
||||
echo "Solus:"
|
||||
echo " sudo eopkg install python3-pip python3-gobject gtk3 libappindicator gobject-introspection-devel portaudio-devel python3-virtualenv pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
elif [[ "$DETECTED_DISTRO" == "mageia" ]]; then
|
||||
echo "Mageia:"
|
||||
if command -v dnf >/dev/null 2>&1; then
|
||||
echo " sudo dnf install -y python3-gobject gtk3-devel gobject-introspection-devel portaudio-devel python3-devel python3-virtualenv pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
else
|
||||
echo " sudo urpmi -y python3-gobject gtk3-devel gobject-introspection-devel portaudio-devel python3-devel python3-virtualenv pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
fi
|
||||
else
|
||||
echo "Ubuntu/Debian-based:"
|
||||
echo " sudo apt install -y python3-gi gir1.2-gtk-3.0 portaudio19-dev python3-dev pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
echo ""
|
||||
echo "Fedora/RHEL-based:"
|
||||
echo " sudo dnf install -y python3-gobject gtk3-devel libayatana-appindicator-gtk3 portaudio-devel python3-devel pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
echo ""
|
||||
echo "Arch Linux:"
|
||||
echo " sudo pacman -S python-gobject gtk3 libayatana-appindicator portaudio python pkg-config xdotool wtype wl-clipboard xclip xsel"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "After installing system dependencies, run the installer again:"
|
||||
echo " ./install.sh"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_deps
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail fast when the native headers `just deps` needs are missing.
|
||||
|
||||
`uv sync` builds pyaudio, pycairo and PyGObject from source on Linux. Without
|
||||
their development headers it fails deep inside a C compiler invocation, which
|
||||
reads as a uv or dependency problem even though the fix is a distro package.
|
||||
This check runs before the sync and names what is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VENV_PYTHON = ROOT / ".venv" / "bin" / "python"
|
||||
|
||||
# pkg-config modules the source builds query. Labels explain the module to a
|
||||
# contributor who does not know which package owns it.
|
||||
BUILD_MODULES = (("portaudio-2.0", "PortAudio headers (pyaudio)"),)
|
||||
# PyGObject and its pycairo dependency are selected only on Linux by the
|
||||
# sys_platform marker in pyproject.toml. They must not gate macOS tooling.
|
||||
PYGOBJECT_BUILD_MODULES = (
|
||||
("cairo", "cairo headers (pycairo)"),
|
||||
("cairo-gobject", "cairo-gobject headers (PyGObject)"),
|
||||
("girepository-2.0", "GObject Introspection headers (glib 2.80+)"),
|
||||
("libffi", "libffi headers (PyGObject)"),
|
||||
)
|
||||
MIN_GIREPOSITORY_VERSION = "2.80"
|
||||
|
||||
APT_HINT = [
|
||||
"Ubuntu 24.04+ / Debian 13+:",
|
||||
" sudo apt install -y build-essential pkg-config libcairo2-dev portaudio19-dev \\",
|
||||
" libgirepository-2.0-dev libffi-dev",
|
||||
]
|
||||
|
||||
DEBIAN_12_NOTE = [
|
||||
"",
|
||||
"Debian 12 cannot build PyGObject 3.56 (glib is too old). Use ./install.sh --dev",
|
||||
"there and run the suite with venv/bin/pytest.",
|
||||
]
|
||||
|
||||
DNF_HINT = [
|
||||
"Fedora 40+ / RHEL-based systems with glib 2.80+:",
|
||||
" sudo dnf install -y gcc gcc-c++ make pkg-config cairo-devel \\",
|
||||
" cairo-gobject-devel portaudio-devel glib2-devel \\",
|
||||
" gobject-introspection-devel libffi-devel",
|
||||
"If your system's glib is older than 2.80, use ./install.sh --dev instead.",
|
||||
]
|
||||
|
||||
FEDORA_OLD_NOTE = [
|
||||
"Fedora 39 and older cannot build PyGObject 3.56 (glib is too old).",
|
||||
"Use ./install.sh --dev there and run the suite with venv/bin/pytest.",
|
||||
]
|
||||
|
||||
PACMAN_HINT = [
|
||||
"Arch / Manjaro:",
|
||||
" sudo pacman -S --needed base-devel pkgconf cairo portaudio gobject-introspection",
|
||||
]
|
||||
|
||||
ZYPPER_HINT = [
|
||||
"openSUSE:",
|
||||
" sudo zypper install -y gcc gcc-c++ make pkg-config cairo-devel \\",
|
||||
" portaudio-devel gobject-introspection-devel libffi-devel",
|
||||
]
|
||||
|
||||
GENERIC_HINT = [
|
||||
"Install the development packages for PortAudio, cairo, GObject Introspection",
|
||||
"(glib 2.80+) and libffi, plus a C compiler and pkg-config.",
|
||||
]
|
||||
|
||||
MACOS_HINT = [
|
||||
"macOS (Homebrew):",
|
||||
" brew install portaudio pkg-config",
|
||||
"Install the Xcode Command Line Tools if a C compiler is missing:",
|
||||
" xcode-select --install",
|
||||
]
|
||||
|
||||
|
||||
def read_os_release(path: Path = Path("/etc/os-release")) -> dict[str, str]:
|
||||
"""Parse os-release without executing it as shell."""
|
||||
values: dict[str, str] = {}
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return values
|
||||
|
||||
for line in lines:
|
||||
key, separator, value = line.partition("=")
|
||||
if not separator or not key or key.startswith("#"):
|
||||
continue
|
||||
values[key] = value.strip().strip('"').strip("'")
|
||||
return values
|
||||
|
||||
|
||||
def install_hint(release: dict[str, str]) -> list[str]:
|
||||
"""Return distro-specific install commands for the missing build headers."""
|
||||
distro = release.get("ID", "").lower()
|
||||
like = release.get("ID_LIKE", "").lower()
|
||||
|
||||
if distro == "ubuntu" or "ubuntu" in like:
|
||||
return APT_HINT
|
||||
|
||||
if distro == "debian" or "debian" in like:
|
||||
return [*APT_HINT, *DEBIAN_12_NOTE]
|
||||
|
||||
if distro in {"fedora", "rhel", "centos", "rocky", "almalinux"} or any(
|
||||
family in like for family in ("fedora", "rhel")
|
||||
):
|
||||
if distro == "fedora" and release.get("VERSION_ID", "").isdigit():
|
||||
if int(release["VERSION_ID"]) < 40:
|
||||
return FEDORA_OLD_NOTE
|
||||
return DNF_HINT
|
||||
|
||||
if distro in {"arch", "manjaro", "endeavouros"} or "arch" in like:
|
||||
return PACMAN_HINT
|
||||
|
||||
if distro == "opensuse" or distro.startswith("opensuse-") or "suse" in like:
|
||||
return ZYPPER_HINT
|
||||
|
||||
return GENERIC_HINT
|
||||
|
||||
|
||||
def venv_has_compiled_packages() -> bool:
|
||||
"""True when `.venv` has the native packages selected for this platform."""
|
||||
if not VENV_PYTHON.is_file():
|
||||
return False
|
||||
packages = ("pyaudio",)
|
||||
if sys.platform == "linux":
|
||||
packages += ("pycairo", "pygobject")
|
||||
check = "import importlib.metadata as metadata; " + "; ".join(
|
||||
f"metadata.version({package!r})" for package in packages
|
||||
)
|
||||
result = subprocess.run(
|
||||
[str(VENV_PYTHON), "-c", check],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def missing_build_dependencies() -> list[str]:
|
||||
"""Return the compiler or pkg-config modules uv cannot find, if any."""
|
||||
missing: list[str] = []
|
||||
|
||||
if shutil.which("pkg-config") is None:
|
||||
missing.append("pkg-config")
|
||||
if not any(shutil.which(compiler) for compiler in ("cc", "gcc", "clang")):
|
||||
missing.append("a C compiler")
|
||||
|
||||
# Module probes need pkg-config; report the missing tool instead of a list
|
||||
# of modules the user cannot check yet.
|
||||
if missing:
|
||||
return missing
|
||||
|
||||
modules = BUILD_MODULES
|
||||
if sys.platform == "linux":
|
||||
modules += PYGOBJECT_BUILD_MODULES
|
||||
for module, description in modules:
|
||||
# PyGObject 3.56 requires girepository-2.0 >= 2.80; finding an older
|
||||
# .pc file is not enough to build it successfully.
|
||||
args = ["pkg-config", "--exists", module]
|
||||
if module == "girepository-2.0":
|
||||
args = ["pkg-config", f"--atleast-version={MIN_GIREPOSITORY_VERSION}", module]
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
missing.append(description)
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Print an actionable dependency report when the build cannot succeed."""
|
||||
if venv_has_compiled_packages():
|
||||
return 0
|
||||
|
||||
missing = missing_build_dependencies()
|
||||
if not missing:
|
||||
return 0
|
||||
|
||||
print("error: missing native build dependencies for .venv/", file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
print(
|
||||
"`just deps` builds native Python packages from source, so uv needs their",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("development headers before it can create the environment.", file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
print("Missing:", file=sys.stderr)
|
||||
for item in missing:
|
||||
print(f" - {item}", file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
print("Install them for your system:", file=sys.stderr)
|
||||
hints = MACOS_HINT if sys.platform == "darwin" else install_hint(read_os_release())
|
||||
for line in hints:
|
||||
print(line, file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
print(
|
||||
"See the Manual setup section in CONTRIBUTING.md for the full package list.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail if any `requirements/*.txt` export is behind `uv.lock`.
|
||||
|
||||
`just lock` writes the exports and nothing ever re-ran it, so a committed export
|
||||
stays whatever it was on the day it was written. `just lock-check` and
|
||||
`just flatpak-deps-check` are both in the justfile and neither is wired to a
|
||||
workflow, which is how three engine exports could be added with nothing
|
||||
comparing them to the lock they came from. The lock itself is covered:
|
||||
`uv sync --locked` and `uv run --locked` in the pipeline fail when it drifts
|
||||
from pyproject.toml. Nothing covered the step after that one.
|
||||
|
||||
The export lines are read out of the `lock` recipe rather than listed here, so
|
||||
an export added there is checked without this file changing.
|
||||
|
||||
`uv export` reads `uv.lock` and nothing else, so this runs under UV_OFFLINE=1
|
||||
and reproduces every file byte for byte on a cold cache. The env var rather than
|
||||
`--offline`: uv echoes its own argv into the header of the file it writes, and
|
||||
the flag would change the bytes this exists to compare.
|
||||
|
||||
`uv pip compile` lines stay out of scope. requirements/whisper.txt and the
|
||||
AppImage files resolve against live indexes on purpose (see
|
||||
requirements/whisper.in), so they are neither reproducible offline nor derived
|
||||
from the lock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
JUSTFILE = REPO_ROOT / "justfile"
|
||||
|
||||
|
||||
def _lock_recipe() -> list[str]:
|
||||
"""The body of the `lock` recipe, one command per entry.
|
||||
|
||||
Continuations are joined. The recipe already wraps its `uv pip compile`
|
||||
lines, so a wrapped `uv export` is a matter of time, and without this it
|
||||
would reach `shlex.split` with a dangling backslash: that raises `ValueError:
|
||||
No escaped character` instead of naming a file, and the `-o` sitting on the
|
||||
next physical line would be invisible either way.
|
||||
"""
|
||||
body: list[str] = []
|
||||
inside = False
|
||||
for line in JUSTFILE.read_text(encoding="utf-8").splitlines():
|
||||
if re.match(r"^lock:\s*$", line):
|
||||
inside = True
|
||||
continue
|
||||
if not inside:
|
||||
continue
|
||||
if line.strip() and not line.startswith((" ", "\t")):
|
||||
break
|
||||
command = line.strip()
|
||||
if body and body[-1].endswith("\\"):
|
||||
body[-1] = body[-1][:-1].rstrip() + " " + command
|
||||
else:
|
||||
body.append(command)
|
||||
return body
|
||||
|
||||
|
||||
def export_commands() -> list[tuple[list[str], Path]]:
|
||||
"""Every `uv export` line in `just lock`, with the file it writes.
|
||||
|
||||
A line with no `-o` writes to stdout, which no export in the recipe does and
|
||||
which this could not compare: reported rather than skipped, because skipping
|
||||
it is the failure this whole script is for.
|
||||
"""
|
||||
commands = []
|
||||
for line in _lock_recipe():
|
||||
if not line.startswith("uv export"):
|
||||
continue
|
||||
argv = shlex.split(line)
|
||||
if "-o" not in argv:
|
||||
raise SystemExit(f"`just lock` exports to stdout, so nothing can check it:\n {line}")
|
||||
commands.append((argv, REPO_ROOT / argv[argv.index("-o") + 1]))
|
||||
return commands
|
||||
|
||||
|
||||
def regenerate(argv: list[str], target: Path, scratch: Path) -> str:
|
||||
"""Re-run one export into `scratch` and return what it would have written.
|
||||
|
||||
uv records the command it ran in the file's header, so the scratch path is
|
||||
put back to the declared one before comparing: the header is part of the
|
||||
export and worth checking, just not the part that says where it went.
|
||||
"""
|
||||
rerun = list(argv)
|
||||
rerun[rerun.index("-o") + 1] = str(scratch)
|
||||
result = subprocess.run(
|
||||
rerun,
|
||||
cwd=REPO_ROOT,
|
||||
env={**os.environ, "UV_OFFLINE": "1"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"{shlex.join(rerun)}\n{result.stderr.strip()}")
|
||||
written = scratch.read_text(encoding="utf-8")
|
||||
return written.replace(str(scratch), target.relative_to(REPO_ROOT).as_posix())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Check every lock-generated export and report whether any are stale."""
|
||||
commands = export_commands()
|
||||
if not commands:
|
||||
print("no `uv export` lines in the lock recipe; nothing was checked", file=sys.stderr)
|
||||
return 1
|
||||
stale = []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for argv, target in commands:
|
||||
expected = regenerate(argv, target, Path(tmp) / target.name)
|
||||
name = target.relative_to(REPO_ROOT)
|
||||
if not target.is_file():
|
||||
stale.append(f"{name} is named in `just lock` but is not committed")
|
||||
elif target.read_text(encoding="utf-8") != expected:
|
||||
stale.append(f"{name} is behind uv.lock")
|
||||
if stale:
|
||||
print("\n".join(stale), file=sys.stderr)
|
||||
print("re-run `just lock` and commit the result", file=sys.stderr)
|
||||
return 1
|
||||
print(f"{len(commands)} exports match uv.lock")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
# Authoritative distro package inventory for install.sh.
|
||||
#
|
||||
# Edit this file, then run `just distro-packages`. The installer consumes the
|
||||
# generated install.d/package_map.sh and never needs a YAML parser at runtime.
|
||||
# The remote bootstrap's git package is the one deliberate exception: it must
|
||||
# be installed before the tagged repository and this generated map are present.
|
||||
schema_version: 1
|
||||
|
||||
distributions:
|
||||
ubuntu:
|
||||
# libssl/autotools/libtool/patchelf support pywhispercpp source builds.
|
||||
system:
|
||||
- python3-pip
|
||||
- python3-gi
|
||||
- python3-gi-cairo
|
||||
- gir1.2-gtk-3.0
|
||||
- gir1.2-ibus-1.0
|
||||
- libcairo2-dev
|
||||
- cmake
|
||||
- python3-dev
|
||||
- build-essential
|
||||
- portaudio19-dev
|
||||
- python3-venv
|
||||
- pkg-config
|
||||
- wget
|
||||
- curl
|
||||
- unzip
|
||||
- vulkan-tools
|
||||
- libvulkan-dev
|
||||
- xclip
|
||||
- xsel
|
||||
- wl-clipboard
|
||||
- libssl-dev
|
||||
- autoconf
|
||||
- automake
|
||||
- libtool
|
||||
- patchelf
|
||||
appindicator: [gir1.2-appindicator3-0.1, gir1.2-ayatanaappindicator3-0.1]
|
||||
gi_development: [libgirepository-2.0-dev, libgirepository1.0-dev]
|
||||
shader_compiler: [glslc, glslang-tools]
|
||||
optional_system: [util-linux-extra]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
|
||||
debian_12:
|
||||
system:
|
||||
- python3-pip
|
||||
- python3-gi
|
||||
- python3-gi-cairo
|
||||
- gir1.2-gtk-3.0
|
||||
- gir1.2-ibus-1.0
|
||||
- libcairo2-dev
|
||||
- cmake
|
||||
- python3-dev
|
||||
- build-essential
|
||||
- portaudio19-dev
|
||||
- python3-venv
|
||||
- pkg-config
|
||||
- wget
|
||||
- curl
|
||||
- unzip
|
||||
- vulkan-tools
|
||||
- libvulkan-dev
|
||||
- xclip
|
||||
- xsel
|
||||
- wl-clipboard
|
||||
- libssl-dev
|
||||
- autoconf
|
||||
- automake
|
||||
- libtool
|
||||
- patchelf
|
||||
- libgirepository1.0-dev
|
||||
- gir1.2-ayatanaappindicator3-0.1
|
||||
shader_compiler: [glslc, glslang-tools]
|
||||
optional_system: [util-linux-extra]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
|
||||
debian_13_plus:
|
||||
# Used only to distinguish Debian 13-based derivatives whose own
|
||||
# VERSION_ID does not match the Debian base release.
|
||||
selection_probe: libgirepository-2.0-dev
|
||||
system:
|
||||
- python3-pip
|
||||
- python3-gi
|
||||
- python3-gi-cairo
|
||||
- gir1.2-gtk-3.0
|
||||
- gir1.2-ibus-1.0
|
||||
- libcairo2-dev
|
||||
- cmake
|
||||
- python3-dev
|
||||
- build-essential
|
||||
- portaudio19-dev
|
||||
- python3-venv
|
||||
- pkg-config
|
||||
- wget
|
||||
- curl
|
||||
- unzip
|
||||
- vulkan-tools
|
||||
- libvulkan-dev
|
||||
- xclip
|
||||
- xsel
|
||||
- wl-clipboard
|
||||
- libssl-dev
|
||||
- autoconf
|
||||
- automake
|
||||
- libtool
|
||||
- patchelf
|
||||
- libgirepository-2.0-dev
|
||||
- gir1.2-ayatanaappindicator3-0.1
|
||||
shader_compiler: [glslc, glslang-tools]
|
||||
optional_system: [util-linux-extra]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
|
||||
fedora:
|
||||
# cairo-devel is needed because the pinned pycairo builds from source.
|
||||
# gcc-c++/make compile the pywhispercpp sdist (the wheel SIGILLs on old CPUs)
|
||||
# and the evdev/PyAudio sdists; python3-devel only guarantees gcc.
|
||||
system: [python3-pip, python3-gobject, gtk3, ibus-devel, gobject-introspection-devel, python3-devel, portaudio-devel, cairo-devel, python3-virtualenv, pkg-config, cmake, gcc-c++, make, wget, curl, unzip, vulkan-tools, vulkan-loader-devel, glslc, patchelf, xclip, xsel, wl-clipboard]
|
||||
appindicator: [libayatana-appindicator-gtk3, libappindicator-gtk3]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
|
||||
arch:
|
||||
system: [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]
|
||||
appindicator: [libayatana-appindicator, libappindicator-gtk3]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
|
||||
suse:
|
||||
# IBus is a runtime dependency; ibus-devel drags in an unsatisfiable gtk-doc
|
||||
# chain on Tumbleweed. The compilers build evdev and PyAudio from sdists.
|
||||
# cairo-devel is needed because the pinned pycairo builds from source.
|
||||
system: [gtk3, ibus, typelib-1_0-IBus-1_0, gobject-introspection-devel, portaudio-devel, cairo-devel, pkg-config, cmake, gcc, gcc-c++, make, wget, curl, unzip, xclip, xsel, wl-clipboard, typelib-1_0-Notify-0_7, libnotify4, patchelf]
|
||||
appindicator: [typelib-1_0-AyatanaAppIndicator3-0_1, typelib-1_0-AppIndicator3-0_1, typelib-1_0-AyatanaAppIndicator-0_1, libayatana-appindicator3-1, libappindicator3-1, libappindicator-gtk3]
|
||||
python_packages:
|
||||
pip: pip
|
||||
gobject: gobject
|
||||
gobject_cairo: gobject-cairo
|
||||
devel: devel
|
||||
virtualenv: virtualenv
|
||||
venv: venv
|
||||
vulkan: [vulkan-tools, vulkan-devel]
|
||||
shader_compiler: [shaderc, glslang-devel, glslang]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
|
||||
gentoo:
|
||||
system: [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]
|
||||
text_input:
|
||||
xdotool: [x11-misc/xdotool]
|
||||
wtype: [x11-misc/wtype]
|
||||
ydotool: [app-misc/ydotool]
|
||||
|
||||
alpine:
|
||||
# cairo-dev is needed because the pinned pycairo builds from source.
|
||||
# build-base compiles the pywhispercpp sdist and the evdev/PyAudio sdists.
|
||||
system: [py3-gobject3, py3-pip, gtk+3.0, py3-cairo, portaudio-dev, cairo-dev, py3-virtualenv, pkgconf, cmake, build-base, wget, curl, unzip, shaderc, patchelf, vulkan-tools, xclip, xsel, wl-clipboard]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
|
||||
void:
|
||||
# cairo-devel is needed because the pinned pycairo builds from source.
|
||||
# base-devel compiles the pywhispercpp sdist and the evdev/PyAudio sdists.
|
||||
system: [python3-pip, python3-gobject, gtk+3, libappindicator-gtk3, gobject-introspection, portaudio-devel, cairo-devel, python3-devel, pkg-config, cmake, base-devel, wget, curl, unzip, shaderc, patchelf, Vulkan-Tools, xclip, xsel, wl-clipboard]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
|
||||
solus:
|
||||
# cairo-devel is needed because the pinned pycairo builds from source.
|
||||
# gcc/g++/make compile the pywhispercpp sdist and the evdev/PyAudio sdists.
|
||||
system: [python3-pip, python3-gobject, gtk3, libappindicator, gobject-introspection-devel, portaudio-devel, cairo-devel, python3-virtualenv, pkg-config, cmake, gcc, g++, make, wget, curl, unzip, shaderc, patchelf, vulkan-tools, xclip, xsel, wl-clipboard]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
|
||||
mageia:
|
||||
# The existing installer deliberately follows Fedora's package inventory,
|
||||
# including gcc-c++/make for the pywhispercpp sdist compile.
|
||||
system: [python3-pip, python3-gobject, gtk3, ibus-devel, gobject-introspection-devel, python3-devel, portaudio-devel, cairo-devel, python3-virtualenv, pkg-config, cmake, gcc-c++, make, wget, curl, unzip, vulkan-tools, vulkan-loader-devel, glslc, patchelf, xclip, xsel, wl-clipboard]
|
||||
text_input:
|
||||
xdotool: [xdotool]
|
||||
wtype: [wtype]
|
||||
ydotool: [ydotool]
|
||||
Executable
+377
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate src/vocalinux/utils/model_checksums.txt from upstream metadata.
|
||||
|
||||
Vocalinux downloads 40MB-2GB speech models at install time and on first run,
|
||||
then hands them to native code (whisper.cpp loads its ggml files through
|
||||
``ctypes.cdll``). Nothing about that is safe unless we know what we received,
|
||||
so every downloader checks the file against a hash pinned *in this repository*
|
||||
rather than one fetched alongside the download.
|
||||
|
||||
Where that hash comes from differs by registry, and so does what generating it
|
||||
costs:
|
||||
|
||||
* whisper.cpp ships on Hugging Face, whose API exposes the sha256 of every LFS
|
||||
blob (``lfs.oid``) plus the commit the listing was taken at, so nothing is
|
||||
downloaded for it. That commit is pinned too -- the download URLs use it
|
||||
instead of ``main``, so upstream retagging a file cannot turn every user's
|
||||
install into a checksum failure.
|
||||
* VOSK is different: ``model-list.json`` carries an md5 per model, but that is a
|
||||
metadata field from the same host we are pinning against, so copying it pins
|
||||
nothing an attacker could not change alongside the file. Those models are
|
||||
downloaded here and pinned by the sha256 of the bytes we actually received;
|
||||
the published md5 is checked against them on the way through, so a listing
|
||||
that disagrees with its own file is caught at generation time. Downloading is
|
||||
incremental -- a model already pinned by sha256 is not fetched again, so only
|
||||
new models cost bandwidth. A first run, or any run with --refresh, therefore
|
||||
pulls every VOSK zip: ~21.9GB, roughly half an hour on a fast connection.
|
||||
|
||||
The model names come from the package itself, so a model added to
|
||||
vosk_model_info.py, whispercpp_model_info.py, parakeet_model_info.py or
|
||||
faster_whisper_model_info.py is picked up on the next run and a model that
|
||||
upstream never published fails the generation loudly.
|
||||
|
||||
Usage: just model-checksums (or: python scripts/generate-model-checksums.py)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Iterable, NamedTuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = REPO_ROOT / "src"
|
||||
MANIFEST = SRC / "vocalinux" / "utils" / "model_checksums.txt"
|
||||
|
||||
HF_REPO = "ggerganov/whisper.cpp"
|
||||
HF_API = f"https://huggingface.co/api/models/{HF_REPO}?blobs=true"
|
||||
VOSK_MODEL_LIST = "https://alphacephei.com/vosk/models/model-list.json"
|
||||
|
||||
TIMEOUT = 60
|
||||
|
||||
|
||||
class Entry(NamedTuple):
|
||||
"""One verifiable artifact: the file name is the manifest's lookup key."""
|
||||
|
||||
filename: str
|
||||
algo: str
|
||||
digest: str
|
||||
size: int
|
||||
|
||||
|
||||
def _fetch_json(url: str):
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "vocalinux-checksum-generator"})
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def _existing_sha256_entries() -> dict:
|
||||
"""sha256 entries already in the manifest, so a rerun re-downloads nothing."""
|
||||
if not MANIFEST.exists():
|
||||
return {}
|
||||
|
||||
entries = {}
|
||||
for line in MANIFEST.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("#") or not line.strip():
|
||||
continue
|
||||
fields = line.split()
|
||||
if len(fields) == 4 and fields[1] == "sha256":
|
||||
entries[fields[0]] = Entry(fields[0], "sha256", fields[2], int(fields[3]))
|
||||
return entries
|
||||
|
||||
|
||||
def _download_and_hash(url: str, expected_md5: str) -> tuple[str, int]:
|
||||
"""Stream ``url``, returning the sha256 of the bytes and their length.
|
||||
|
||||
The published md5 is verified on the way through: it is not what we pin, but
|
||||
a mismatch means the listing and the file disagree, which is worth stopping
|
||||
for rather than pinning either one.
|
||||
"""
|
||||
sha256, md5, size = hashlib.sha256(), hashlib.md5(), 0
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "vocalinux-checksum-generator"})
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
|
||||
for chunk in iter(lambda: response.read(1024 * 1024), b""):
|
||||
sha256.update(chunk)
|
||||
md5.update(chunk)
|
||||
size += len(chunk)
|
||||
|
||||
if expected_md5 and md5.hexdigest() != expected_md5:
|
||||
raise SystemExit(
|
||||
f"{url}\n"
|
||||
f" published md5: {expected_md5}\n"
|
||||
f" actual md5: {md5.hexdigest()}\n"
|
||||
"The listing does not describe the file it points at. Refusing to pin either."
|
||||
)
|
||||
return sha256.hexdigest(), size
|
||||
|
||||
|
||||
def _model_names() -> tuple[list[str], list[str]]:
|
||||
"""Model names the application can actually download, straight from the package."""
|
||||
sys.path.insert(0, str(SRC))
|
||||
from vocalinux.utils.vosk_model_info import VOSK_MODEL_INFO
|
||||
from vocalinux.utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO
|
||||
|
||||
vosk = {
|
||||
name for info in VOSK_MODEL_INFO.values() for name in info["languages"].values() if name
|
||||
}
|
||||
return sorted(WHISPERCPP_MODEL_INFO), sorted(vosk)
|
||||
|
||||
|
||||
def _parakeet_entries() -> list[Entry]:
|
||||
"""Digests for every file of every Parakeet bundle, at its pinned revision.
|
||||
|
||||
The ONNX files are LFS, so Hugging Face publishes their sha256; tokens.txt
|
||||
is a plain blob and is hashed from the bytes this script downloads, the same
|
||||
way VOSK archives are.
|
||||
"""
|
||||
sys.path.insert(0, str(SRC))
|
||||
from vocalinux.utils.parakeet_model_info import (
|
||||
PARAKEET_MODEL_INFO,
|
||||
get_model_file_url,
|
||||
manifest_key,
|
||||
model_files,
|
||||
)
|
||||
|
||||
entries = []
|
||||
for model_name, info in PARAKEET_MODEL_INFO.items():
|
||||
repo, revision = info["repo"], info["revision"]
|
||||
payload = _fetch_json(
|
||||
f"https://huggingface.co/api/models/{repo}/revision/{revision}?blobs=true"
|
||||
)
|
||||
published = {}
|
||||
for sibling in payload.get("siblings", []):
|
||||
lfs = sibling.get("lfs") or {}
|
||||
digest = lfs.get("oid") or lfs.get("sha256")
|
||||
if digest:
|
||||
published[sibling["rfilename"]] = (digest, int(lfs.get("size", 0)))
|
||||
|
||||
for filename in model_files(model_name):
|
||||
key = manifest_key(model_name, filename)
|
||||
remote_name = "/".join(part for part in (info.get("subdir", ""), filename) if part)
|
||||
if remote_name in published:
|
||||
digest, size = published[remote_name]
|
||||
else:
|
||||
url = get_model_file_url(model_name, filename)
|
||||
print(f" {key} (not LFS, hashing bytes)...", flush=True)
|
||||
digest, size = _download_and_hash(url, "")
|
||||
entries.append(Entry(key, "sha256", digest, size))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _faster_whisper_entries() -> list[Entry]:
|
||||
"""Digests for every file of every faster-whisper bundle, at its pinned revision.
|
||||
|
||||
``model.bin`` is LFS (Hugging Face publishes sha256 on these repos); config,
|
||||
tokenizer and vocabulary files are plain blobs hashed from the bytes this
|
||||
script downloads, the same way Parakeet's tokens.txt is.
|
||||
"""
|
||||
sys.path.insert(0, str(SRC))
|
||||
from vocalinux.utils.faster_whisper_model_info import (
|
||||
FASTER_WHISPER_MODEL_INFO,
|
||||
manifest_key,
|
||||
model_files,
|
||||
)
|
||||
|
||||
entries = []
|
||||
for model_name, info in FASTER_WHISPER_MODEL_INFO.items():
|
||||
repo, revision = info["repo"], info["revision"]
|
||||
payload = _fetch_json(
|
||||
f"https://huggingface.co/api/models/{repo}/revision/{revision}?blobs=true"
|
||||
)
|
||||
published = {}
|
||||
for sibling in payload.get("siblings", []):
|
||||
lfs = sibling.get("lfs") or {}
|
||||
digest = lfs.get("sha256") or lfs.get("oid")
|
||||
if digest:
|
||||
published[sibling["rfilename"]] = (digest, int(lfs.get("size", 0)))
|
||||
|
||||
for filename in model_files(model_name):
|
||||
key = manifest_key(model_name, filename)
|
||||
if filename in published:
|
||||
digest, size = published[filename]
|
||||
else:
|
||||
url = f"https://huggingface.co/{repo}/resolve/{revision}/{filename}?download=true"
|
||||
print(f" {key} (not LFS, hashing bytes)...", flush=True)
|
||||
digest, size = _download_and_hash(url, "")
|
||||
entries.append(Entry(key, "sha256", digest, size))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _hf_lfs_digests(repo: str, revision: str) -> dict[str, tuple[str, int]]:
|
||||
"""sha256/size per LFS file in ``repo`` at ``revision``, from the HF API."""
|
||||
payload = _fetch_json(
|
||||
f"https://huggingface.co/api/models/{repo}/revision/{revision}?blobs=true"
|
||||
)
|
||||
published = {}
|
||||
for sibling in payload.get("siblings", []):
|
||||
lfs = sibling.get("lfs") or {}
|
||||
digest = lfs.get("sha256") or lfs.get("oid")
|
||||
if digest:
|
||||
published[sibling["rfilename"]] = (digest, int(lfs.get("size", 0)))
|
||||
return published
|
||||
|
||||
|
||||
def _whispercpp_entries(wanted: Iterable[str]) -> tuple[list[Entry], str]:
|
||||
sys.path.insert(0, str(SRC))
|
||||
from vocalinux.utils.whispercpp_model_info import (
|
||||
whispercpp_model_file,
|
||||
whispercpp_model_source,
|
||||
)
|
||||
|
||||
payload = _fetch_json(HF_API)
|
||||
revision = payload.get("sha")
|
||||
if not revision:
|
||||
raise SystemExit(f"{HF_API} returned no commit sha; refusing to pin an unknown revision")
|
||||
|
||||
by_name = {}
|
||||
for sibling in payload.get("siblings", []):
|
||||
lfs = sibling.get("lfs") or {}
|
||||
digest = lfs.get("oid") or lfs.get("sha256")
|
||||
if digest:
|
||||
by_name[sibling["rfilename"]] = (digest, int(lfs.get("size", 0)))
|
||||
|
||||
# Catalog entries hosted outside the main repository pin their own commit
|
||||
# in whispercpp_model_info.py (same contract parakeet bundles follow).
|
||||
side_repo_digests: dict[str, dict[str, tuple[str, int]]] = {}
|
||||
|
||||
entries, missing = [], []
|
||||
for model_name in wanted:
|
||||
filename = whispercpp_model_file(model_name)
|
||||
repo, pinned_revision = whispercpp_model_source(model_name)
|
||||
if repo != HF_REPO:
|
||||
if repo not in side_repo_digests:
|
||||
side_repo_digests[repo] = _hf_lfs_digests(repo, pinned_revision)
|
||||
found = side_repo_digests[repo].get(filename)
|
||||
if found is None:
|
||||
missing.append(f"{filename} ({repo})")
|
||||
continue
|
||||
digest, size = found
|
||||
elif filename not in by_name:
|
||||
missing.append(filename)
|
||||
continue
|
||||
else:
|
||||
digest, size = by_name[filename]
|
||||
entries.append(Entry(filename, "sha256", digest, size))
|
||||
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
f"Hugging Face publishes no sha256 for: {', '.join(missing)}.\n"
|
||||
"Either the model was renamed upstream or whispercpp_model_info.py lists "
|
||||
"a file that does not exist."
|
||||
)
|
||||
entries.sort(key=lambda entry: entry.filename)
|
||||
return entries, revision
|
||||
|
||||
|
||||
def _vosk_entries(wanted: Iterable[str], refresh: bool) -> list[Entry]:
|
||||
payload = _fetch_json(VOSK_MODEL_LIST)
|
||||
by_name = {item["name"]: item for item in payload}
|
||||
existing = {} if refresh else _existing_sha256_entries()
|
||||
|
||||
missing = [name for name in wanted if name not in by_name]
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
f"{VOSK_MODEL_LIST} does not list: {', '.join(missing)}.\n"
|
||||
"Either the model was withdrawn upstream or vosk_model_info.py names "
|
||||
"a model Alphacephei never shipped."
|
||||
)
|
||||
|
||||
todo = [name for name in wanted if f"{name}.zip" not in existing]
|
||||
if todo:
|
||||
total = sum(int(by_name[name].get("size", 0)) for name in todo)
|
||||
print(f" downloading {len(todo)} VOSK model(s), {total / 1e9:.1f} GB, to hash the bytes")
|
||||
|
||||
entries = []
|
||||
for name in wanted:
|
||||
filename = f"{name}.zip"
|
||||
if filename in existing:
|
||||
entries.append(existing[filename])
|
||||
continue
|
||||
|
||||
item = by_name[name]
|
||||
print(f" {name} ({int(item.get('size', 0)) / 1e6:.0f} MB)...", flush=True)
|
||||
digest, size = _download_and_hash(item["url"], item.get("md5", ""))
|
||||
entries.append(Entry(filename, "sha256", digest, size))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _render(entries: list[Entry], revision: str) -> str:
|
||||
width = max(len(entry.filename) for entry in entries)
|
||||
lines = [
|
||||
"# Checksums for every speech model Vocalinux downloads.",
|
||||
"#",
|
||||
"# Generated by scripts/generate-model-checksums.py -- do not edit by hand.",
|
||||
"# Regenerate with `just model-checksums` after adding a model to",
|
||||
"# vosk_model_info.py, whispercpp_model_info.py, parakeet_model_info.py",
|
||||
"# or faster_whisper_model_info.py.",
|
||||
"#",
|
||||
"# Read by src/vocalinux/utils/model_checksums.py and by install.sh; keep the",
|
||||
"# format (filename, algorithm, digest, size in bytes) parseable by awk.",
|
||||
"#",
|
||||
"# whisper.cpp models are pinned to this Hugging Face commit so upstream",
|
||||
"# replacing a file cannot invalidate the digests below.",
|
||||
f"# whispercpp-revision: {revision}",
|
||||
"#",
|
||||
"# OpenAI Whisper (.pt) models are absent on purpose: their download URLs embed",
|
||||
"# the sha256 as a path segment, so the expected digest travels with the URL.",
|
||||
"#",
|
||||
"# VOSK digests are the sha256 of bytes this script downloaded, not the md5",
|
||||
"# Alphacephei publishes; that md5 is only checked against them at that point.",
|
||||
"#",
|
||||
"# Parakeet and faster-whisper bundles are pinned per model in their",
|
||||
"# model_info modules, so their keys carry the model name: several",
|
||||
"# bundles ship the same file names.",
|
||||
"",
|
||||
]
|
||||
for entry in entries:
|
||||
lines.append(
|
||||
f"{entry.filename.ljust(width)} {entry.algo:<6} {entry.digest} {entry.size}"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="re-download every VOSK model instead of reusing pinned sha256 entries",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
whispercpp_files, vosk_names = _model_names()
|
||||
print(f"Resolving {len(whispercpp_files)} whisper.cpp and {len(vosk_names)} VOSK models...")
|
||||
|
||||
whispercpp_entries, revision = _whispercpp_entries(whispercpp_files)
|
||||
print(f" whisper.cpp: {len(whispercpp_entries)} sha256 digests at {HF_REPO}@{revision[:12]}")
|
||||
|
||||
vosk_entries = _vosk_entries(vosk_names, args.refresh)
|
||||
print(f" VOSK: {len(vosk_entries)} sha256 digests")
|
||||
|
||||
parakeet_entries = _parakeet_entries()
|
||||
print(f" Parakeet: {len(parakeet_entries)} sha256 digests")
|
||||
|
||||
faster_whisper_entries = _faster_whisper_entries()
|
||||
print(f" faster-whisper: {len(faster_whisper_entries)} sha256 digests")
|
||||
|
||||
MANIFEST.write_text(
|
||||
_render(
|
||||
whispercpp_entries + vosk_entries + parakeet_entries + faster_whisper_entries,
|
||||
revision,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Wrote {MANIFEST.relative_to(REPO_ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the installer's shell package map from its YAML source."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "scripts" / "distro-package-map.yaml"
|
||||
TARGET = ROOT / "install.d" / "package_map.sh"
|
||||
REQUIRED_DISTRIBUTIONS = {
|
||||
"alpine",
|
||||
"arch",
|
||||
"debian_12",
|
||||
"debian_13_plus",
|
||||
"fedora",
|
||||
"gentoo",
|
||||
"mageia",
|
||||
"solus",
|
||||
"suse",
|
||||
"ubuntu",
|
||||
"void",
|
||||
}
|
||||
LIST_FIELDS = {
|
||||
"appindicator",
|
||||
"gi_development",
|
||||
"optional_system",
|
||||
"shader_compiler",
|
||||
"system",
|
||||
"vulkan",
|
||||
}
|
||||
SCALAR_FIELDS = {"selection_probe"}
|
||||
TEXT_TOOLS = {"wtype", "xdotool", "ydotool"}
|
||||
SUSE_PYTHON_ROLES = {"pip", "gobject", "gobject_cairo", "devel", "virtualenv", "venv"}
|
||||
|
||||
|
||||
class UniqueKeyLoader(yaml.SafeLoader):
|
||||
"""Reject duplicate mapping keys instead of silently dropping inventory."""
|
||||
|
||||
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict:
|
||||
self.flatten_mapping(node)
|
||||
result = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = self.construct_object(key_node, deep=deep)
|
||||
if not isinstance(key, str):
|
||||
raise ValueError("package map keys must be strings")
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate YAML key: {key}")
|
||||
result[key] = self.construct_object(value_node, deep=deep)
|
||||
return result
|
||||
|
||||
|
||||
def _package_list(value: Any, location: str, *, required: bool = False) -> list[str]:
|
||||
"""Validate and return one package list."""
|
||||
if value is None and not required:
|
||||
return []
|
||||
if not isinstance(value, list) or (required and not value):
|
||||
raise ValueError(f"{location} must be a non-empty list")
|
||||
if any(not isinstance(item, str) or not item for item in value):
|
||||
raise ValueError(f"{location} must contain non-empty strings")
|
||||
if len(value) != len(set(value)):
|
||||
raise ValueError(f"{location} contains duplicate packages")
|
||||
return value
|
||||
|
||||
|
||||
def load_map() -> dict[str, dict[str, Any]]:
|
||||
"""Load and validate the authoritative YAML package map."""
|
||||
document = yaml.load(SOURCE.read_text(encoding="utf-8"), Loader=UniqueKeyLoader)
|
||||
if not isinstance(document, dict) or document.get("schema_version") != 1:
|
||||
raise ValueError("distro package map must use schema_version: 1")
|
||||
distributions = document.get("distributions")
|
||||
if not isinstance(distributions, dict):
|
||||
raise ValueError("distro package map must define a distributions mapping")
|
||||
names = set(distributions)
|
||||
if names != REQUIRED_DISTRIBUTIONS:
|
||||
missing = ", ".join(sorted(REQUIRED_DISTRIBUTIONS - names)) or "none"
|
||||
extra = ", ".join(sorted(names - REQUIRED_DISTRIBUTIONS)) or "none"
|
||||
raise ValueError(f"distribution keys differ (missing: {missing}; extra: {extra})")
|
||||
|
||||
for distro, config in distributions.items():
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError(f"distributions.{distro} must be a mapping")
|
||||
unknown = set(config) - LIST_FIELDS - SCALAR_FIELDS - {"python_packages", "text_input"}
|
||||
if unknown:
|
||||
raise ValueError(f"distributions.{distro} has unknown fields: {sorted(unknown)}")
|
||||
_package_list(config.get("system"), f"distributions.{distro}.system", required=True)
|
||||
for field in LIST_FIELDS - {"system"}:
|
||||
_package_list(config.get(field), f"distributions.{distro}.{field}")
|
||||
selection_probe = config.get("selection_probe")
|
||||
if selection_probe is not None and (
|
||||
distro != "debian_13_plus"
|
||||
or not isinstance(selection_probe, str)
|
||||
or not selection_probe
|
||||
):
|
||||
raise ValueError("selection_probe must be a non-empty string on debian_13_plus only")
|
||||
python_packages = config.get("python_packages")
|
||||
if python_packages is not None:
|
||||
if distro != "suse" or not isinstance(python_packages, dict):
|
||||
raise ValueError("python_packages must be a mapping on suse only")
|
||||
if set(python_packages) != SUSE_PYTHON_ROLES:
|
||||
raise ValueError(
|
||||
f"distributions.suse.python_packages must define {sorted(SUSE_PYTHON_ROLES)}"
|
||||
)
|
||||
if any(not isinstance(value, str) or not value for value in python_packages.values()):
|
||||
raise ValueError("distributions.suse.python_packages values must be strings")
|
||||
text_input = config.get("text_input")
|
||||
if not isinstance(text_input, dict) or set(text_input) != TEXT_TOOLS:
|
||||
raise ValueError(f"distributions.{distro}.text_input must define {sorted(TEXT_TOOLS)}")
|
||||
for tool in TEXT_TOOLS:
|
||||
_package_list(
|
||||
text_input.get(tool),
|
||||
f"distributions.{distro}.text_input.{tool}",
|
||||
required=True,
|
||||
)
|
||||
if len(text_input[tool]) != 1:
|
||||
raise ValueError(
|
||||
f"distributions.{distro}.text_input.{tool} needs exactly one package"
|
||||
)
|
||||
|
||||
for distro in ("ubuntu", "fedora", "arch"):
|
||||
if len(distributions[distro].get("appindicator") or []) != 2:
|
||||
raise ValueError(f"distributions.{distro}.appindicator must define two fallbacks")
|
||||
if len(distributions["suse"].get("appindicator") or []) < 4:
|
||||
raise ValueError("distributions.suse.appindicator must define at least four alternatives")
|
||||
if not distributions["debian_13_plus"].get("selection_probe"):
|
||||
raise ValueError("distributions.debian_13_plus.selection_probe is required")
|
||||
if len(distributions["ubuntu"].get("gi_development") or []) != 2:
|
||||
raise ValueError("distributions.ubuntu.gi_development must define modern and legacy")
|
||||
for distro in ("ubuntu", "debian_12", "debian_13_plus"):
|
||||
if len(distributions[distro].get("shader_compiler") or []) < 2:
|
||||
raise ValueError(f"distributions.{distro}.shader_compiler needs a fallback")
|
||||
if "python_packages" not in distributions["suse"]:
|
||||
raise ValueError("distributions.suse.python_packages is required")
|
||||
for field in ("vulkan", "shader_compiler"):
|
||||
_package_list(distributions["suse"].get(field), f"suse.{field}", required=True)
|
||||
return distributions
|
||||
|
||||
|
||||
def _array_assignment(name: str, values: list[str], indent: str = " ") -> str:
|
||||
quoted = " ".join(shlex.quote(value) for value in values)
|
||||
return f"{indent}{name}=({quoted})"
|
||||
|
||||
|
||||
def render(distributions: dict[str, dict[str, Any]]) -> str:
|
||||
"""Render a deterministic Bash module from the validated map."""
|
||||
debian_13_probe = distributions["debian_13_plus"]["selection_probe"]
|
||||
lines = [
|
||||
"#!/bin/bash",
|
||||
"# Generated by scripts/generate_distro_package_map.py -- do not edit.",
|
||||
"# Regenerate with `just distro-packages` after editing",
|
||||
"# scripts/distro-package-map.yaml.",
|
||||
"",
|
||||
f"DEBIAN_13_PLUS_PROBE_PACKAGE={shlex.quote(debian_13_probe)}",
|
||||
"",
|
||||
"load_distro_package_map() {",
|
||||
' local package_map_key="$1"',
|
||||
"",
|
||||
" SYSTEM_PACKAGES=()",
|
||||
" APPINDICATOR_PACKAGES=()",
|
||||
" GI_DEVELOPMENT_PACKAGES=()",
|
||||
" OPTIONAL_SYSTEM_PACKAGES=()",
|
||||
' PYTHON_PIP_SUFFIX=""',
|
||||
' PYTHON_GOBJECT_SUFFIX=""',
|
||||
' PYTHON_GOBJECT_CAIRO_SUFFIX=""',
|
||||
' PYTHON_DEVEL_SUFFIX=""',
|
||||
' PYTHON_VIRTUALENV_SUFFIX=""',
|
||||
' PYTHON_VENV_SUFFIX=""',
|
||||
" SHADER_COMPILER_PACKAGES=()",
|
||||
" VULKAN_PACKAGES=()",
|
||||
" XDOTOOL_PACKAGES=()",
|
||||
" WTYPE_PACKAGES=()",
|
||||
" YDOTOOL_PACKAGES=()",
|
||||
"",
|
||||
' case "$package_map_key" in',
|
||||
]
|
||||
names = {
|
||||
"system": "SYSTEM_PACKAGES",
|
||||
"appindicator": "APPINDICATOR_PACKAGES",
|
||||
"gi_development": "GI_DEVELOPMENT_PACKAGES",
|
||||
"optional_system": "OPTIONAL_SYSTEM_PACKAGES",
|
||||
"shader_compiler": "SHADER_COMPILER_PACKAGES",
|
||||
"vulkan": "VULKAN_PACKAGES",
|
||||
}
|
||||
for distro, config in distributions.items():
|
||||
lines.append(f" {distro})")
|
||||
for field, variable in names.items():
|
||||
values = _package_list(config.get(field), f"{distro}.{field}")
|
||||
if values:
|
||||
lines.append(_array_assignment(variable, values))
|
||||
python_packages = config.get("python_packages")
|
||||
if python_packages:
|
||||
for role in sorted(SUSE_PYTHON_ROLES):
|
||||
variable = f"PYTHON_{role.upper()}_SUFFIX"
|
||||
lines.append(f" {variable}={shlex.quote(python_packages[role])}")
|
||||
text_input = config["text_input"]
|
||||
lines.append(_array_assignment("XDOTOOL_PACKAGES", text_input["xdotool"]))
|
||||
lines.append(_array_assignment("WTYPE_PACKAGES", text_input["wtype"]))
|
||||
lines.append(_array_assignment("YDOTOOL_PACKAGES", text_input["ydotool"]))
|
||||
lines.extend([" ;;", ""])
|
||||
lines.extend(
|
||||
[
|
||||
" *)",
|
||||
' echo "Unknown distro package map key: $package_map_key" >&2',
|
||||
" return 1",
|
||||
" ;;",
|
||||
" esac",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Generate the map, or check that the committed output is current."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="fail if the output is stale")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
generated = render(load_map())
|
||||
except (OSError, ValueError, yaml.YAMLError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.check:
|
||||
current = TARGET.read_text(encoding="utf-8") if TARGET.exists() else ""
|
||||
if current != generated:
|
||||
print(
|
||||
f"{TARGET.relative_to(ROOT)} is stale; run `just distro-packages`",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
TARGET.write_text(generated, encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Vocalinux notification WAVs.
|
||||
|
||||
Writes 16-bit PCM mono files at 44.1 kHz into resources/sounds/:
|
||||
|
||||
- start_recording.wav / stop_recording.wav / error.wav: the original Linux
|
||||
pair. Kept on disk; not used as the implicit default anymore.
|
||||
|
||||
Family catalog pairs ({id}_start.wav / {id}_stop.wav) are downloaded preview
|
||||
bytes. This script never synthesizes or overwrites them.
|
||||
|
||||
Usage:
|
||||
python scripts/generate_sounds.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import wave
|
||||
|
||||
SAMPLE_RATE = 44100
|
||||
|
||||
# Named pitches used by the family catalog.
|
||||
G2 = 98.00
|
||||
C3 = 130.81
|
||||
D3 = 146.83
|
||||
F3 = 174.61
|
||||
G3 = 196.00
|
||||
C4 = 261.63
|
||||
E4 = 329.63
|
||||
F4 = 349.23
|
||||
G4 = 392.00
|
||||
A4 = 440.00
|
||||
C5 = 523.25
|
||||
A5 = 880.00
|
||||
C6 = 1046.50
|
||||
E7 = 2637.02
|
||||
C7 = 2093.00
|
||||
|
||||
|
||||
def _smoothstep(progress: float) -> float:
|
||||
return progress * progress * (3.0 - 2.0 * progress)
|
||||
|
||||
|
||||
def _sine_envelope(progress: float) -> float:
|
||||
return (math.sin(math.pi * progress) ** 2) * math.exp(-0.5 * progress)
|
||||
|
||||
|
||||
def generate_glide_tone(
|
||||
filename: str,
|
||||
freq_start: float,
|
||||
freq_end: float,
|
||||
duration: float = 0.6,
|
||||
amplitude: float = 0.16,
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
) -> None:
|
||||
"""Generate a smooth pitch glide tone (original Linux pair recipe)."""
|
||||
num_samples = int(sample_rate * duration)
|
||||
samples = []
|
||||
|
||||
for i in range(num_samples):
|
||||
t = i / sample_rate
|
||||
progress = i / num_samples
|
||||
envelope = _sine_envelope(progress)
|
||||
glide = _smoothstep(progress)
|
||||
freq_current = freq_start + (freq_end - freq_start) * glide
|
||||
phase = 2 * math.pi * freq_current * t
|
||||
samples.append(amplitude * envelope * math.sin(phase))
|
||||
|
||||
_write_wav(filename, samples, sample_rate)
|
||||
|
||||
|
||||
def _write_wav(filename: str, samples: list[float], sample_rate: int = SAMPLE_RATE) -> None:
|
||||
wav_samples = [int(max(-1.0, min(1.0, sample)) * 32767) for sample in samples]
|
||||
os.makedirs(os.path.dirname(filename) or ".", exist_ok=True)
|
||||
with wave.open(filename, "w") as wav_file:
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(sample_rate)
|
||||
wav_file.writeframes(struct.pack("<" + "h" * len(wav_samples), *wav_samples))
|
||||
print(f"Generated: {filename}")
|
||||
|
||||
|
||||
def _tone_burst(
|
||||
freq: float,
|
||||
duration: float,
|
||||
amplitude: float = 0.12,
|
||||
decay: float = 8.0,
|
||||
) -> list[float]:
|
||||
n = int(SAMPLE_RATE * duration)
|
||||
samples = []
|
||||
for i in range(n):
|
||||
t = i / SAMPLE_RATE
|
||||
progress = i / max(n - 1, 1)
|
||||
attack = min(1.0, i / max(int(0.004 * SAMPLE_RATE), 1))
|
||||
env = attack * math.exp(-decay * t) * (1.0 - 0.15 * progress)
|
||||
samples.append(amplitude * env * math.sin(2 * math.pi * freq * t))
|
||||
return samples
|
||||
|
||||
|
||||
def _silence(duration: float) -> list[float]:
|
||||
return [0.0] * int(SAMPLE_RATE * duration)
|
||||
|
||||
|
||||
def generate_ticks(
|
||||
filename: str,
|
||||
freqs: tuple[float, ...],
|
||||
tick_duration: float = 0.07,
|
||||
gap: float = 0.045,
|
||||
amplitude: float = 0.11,
|
||||
decay: float = 14.0,
|
||||
) -> None:
|
||||
samples: list[float] = []
|
||||
for index, freq in enumerate(freqs):
|
||||
if index:
|
||||
samples.extend(_silence(gap))
|
||||
samples.extend(_tone_burst(freq, tick_duration, amplitude, decay))
|
||||
_write_wav(filename, samples)
|
||||
|
||||
|
||||
def generate_fifth_swell(
|
||||
filename: str,
|
||||
low: float,
|
||||
high: float,
|
||||
duration: float = 0.42,
|
||||
amplitude: float = 0.13,
|
||||
opening: bool = True,
|
||||
) -> None:
|
||||
"""Open-fifth swell. Start opens C4→G4; stop closes G4→C4."""
|
||||
n = int(SAMPLE_RATE * duration)
|
||||
samples = []
|
||||
phase_low = 0.0
|
||||
phase_high = 0.0
|
||||
dt = 1.0 / SAMPLE_RATE
|
||||
for i in range(n):
|
||||
progress = i / max(n - 1, 1)
|
||||
env = math.sin(math.pi * progress) ** 2
|
||||
blend = _smoothstep(progress)
|
||||
if opening:
|
||||
low_amt = 1.0
|
||||
high_amt = blend
|
||||
else:
|
||||
low_amt = 1.0
|
||||
high_amt = 1.0 - blend
|
||||
sample = (
|
||||
amplitude
|
||||
* env
|
||||
* (low_amt * math.sin(phase_low) + 0.85 * high_amt * math.sin(phase_high))
|
||||
)
|
||||
samples.append(sample)
|
||||
phase_low += 2 * math.pi * low * dt
|
||||
phase_high += 2 * math.pi * high * dt
|
||||
_write_wav(filename, samples)
|
||||
|
||||
|
||||
def generate_ping(
|
||||
filename: str, freq: float, duration: float = 0.32, amplitude: float = 0.12
|
||||
) -> None:
|
||||
n = int(SAMPLE_RATE * duration)
|
||||
samples = []
|
||||
for i in range(n):
|
||||
t = i / SAMPLE_RATE
|
||||
env = math.exp(-14.0 * t)
|
||||
samples.append(amplitude * env * math.sin(2 * math.pi * freq * t))
|
||||
_write_wav(filename, samples)
|
||||
|
||||
|
||||
def generate_scale(
|
||||
filename: str,
|
||||
freqs: tuple[float, ...],
|
||||
note_duration: float = 0.085,
|
||||
gap: float = 0.02,
|
||||
amplitude: float = 0.12,
|
||||
) -> None:
|
||||
generate_ticks(filename, freqs, note_duration, gap, amplitude, decay=9.0)
|
||||
|
||||
|
||||
def _maybe_write_original(path: str, writer) -> None:
|
||||
if os.path.exists(path):
|
||||
print(f"Keeping existing: {path}")
|
||||
return
|
||||
writer()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
repo_root = os.path.dirname(script_dir)
|
||||
sounds_dir = os.path.join(repo_root, "resources", "sounds")
|
||||
os.makedirs(sounds_dir, exist_ok=True)
|
||||
print(f"Generating sounds in: {sounds_dir}\n")
|
||||
|
||||
start_path = os.path.join(sounds_dir, "start_recording.wav")
|
||||
stop_path = os.path.join(sounds_dir, "stop_recording.wav")
|
||||
error_path = os.path.join(sounds_dir, "error.wav")
|
||||
|
||||
_maybe_write_original(
|
||||
start_path,
|
||||
lambda: generate_glide_tone(start_path, F4, A4, duration=0.6, amplitude=0.16),
|
||||
)
|
||||
_maybe_write_original(
|
||||
stop_path,
|
||||
lambda: generate_glide_tone(stop_path, A4, F4, duration=0.6, amplitude=0.16),
|
||||
)
|
||||
_maybe_write_original(
|
||||
error_path,
|
||||
lambda: generate_glide_tone(error_path, E4, C4, duration=0.7, amplitude=0.14),
|
||||
)
|
||||
|
||||
# Family catalog pairs are downloaded preview bytes. Never synthesize or
|
||||
# overwrite them.
|
||||
family_ids = (
|
||||
"lift",
|
||||
"flick",
|
||||
"ember",
|
||||
"step",
|
||||
"voca",
|
||||
"soft",
|
||||
"chirp",
|
||||
"scale",
|
||||
"drop",
|
||||
"glass",
|
||||
)
|
||||
family_names = {f"{tone_id}_{kind}.wav" for tone_id in family_ids for kind in ("start", "stop")}
|
||||
for name in sorted(family_names):
|
||||
path = os.path.join(sounds_dir, name)
|
||||
if os.path.exists(path):
|
||||
print(f"Keeping downloaded catalog: {path}")
|
||||
else:
|
||||
print(f"Missing catalog WAV (not synthesizing): {path}")
|
||||
|
||||
print("\nOriginal start/stop/error files were left in place.")
|
||||
print("Family catalog WAVs were not written.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+327
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run install.sh's remote path end to end in a distro container and check what
|
||||
# it installed.
|
||||
#
|
||||
# Usage (a read-only mount is enough; the tag is published from git, not the
|
||||
# tree):
|
||||
# docker run --rm -v "$PWD:/repo:ro" debian:12 bash /repo/scripts/install-remote-test.sh
|
||||
# or: just remote-install-gate debian:12
|
||||
#
|
||||
# The path under test is the one a `curl | bash` user takes and
|
||||
# scripts/install-test.sh never reaches: bootstrap outside a checkout, --tag
|
||||
# selection, clone of the selected tag, handoff to the tagged installer with
|
||||
# the original arguments plus the remote venv path, then a second run's
|
||||
# fetch+reset over the existing clone.
|
||||
#
|
||||
# The remote is a local mirror so the gate answers for *this commit*: the tag
|
||||
# is published from HEAD's tree and VOCALINUX_REPO_URL points the bootstrap at
|
||||
# it. A gate that cloned the public repo would install the last release, not
|
||||
# the checkout under test. The tag's own install.sh is the bootstrap: the #701
|
||||
# guardrail allows the bootstrap to come from main, and this commit's copy is
|
||||
# what a merged PR would serve anyway.
|
||||
#
|
||||
# Flags the installer sees: --auto, --skip-models (a model is 40-75 MB
|
||||
# per run and the download path is checksum-verified elsewhere), --tag (the
|
||||
# selection under test). Not --skip-system-deps: the per-distro package
|
||||
# installation is the point. Run 1 is headless; run 2 pipes the bootstrap in
|
||||
# under a `script` pty so the real `curl | bash` fd-0 shape stays covered.
|
||||
#
|
||||
# Fail closed: after the install, the clone's tree must equal HEAD's tree —
|
||||
# the #701 guardrail that installer, sourced modules and requirement exports
|
||||
# all come from the selected tag is only as strong as that equality.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${REPO:-/repo}"
|
||||
INSTALL_USER="${INSTALL_USER:-installer}"
|
||||
INSTALL_HOME="/home/$INSTALL_USER"
|
||||
# Opaque on purpose: a name the public repository cannot already carry, so a
|
||||
# silent fallback to GitHub fails loudly instead of installing the wrong ref.
|
||||
GATE_TAG="${GATE_TAG:-v0.0.0-remote-gate}"
|
||||
MIRROR="/srv/vocalinux-remote.git"
|
||||
BOOTSTRAP="/tmp/vl.sh"
|
||||
CLONE_DIR="$INSTALL_HOME/.local/share/vocalinux-install"
|
||||
REMOTE_VENV="$INSTALL_HOME/.local/share/vocalinux/venv"
|
||||
|
||||
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 mirror below; the remote path ensures it again for real users.
|
||||
# Same arms as scripts/install-test.sh so `just remote-install-gate <distro>`
|
||||
# works everywhere the local gate does.
|
||||
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 "== Publish HEAD's tree as tag $GATE_TAG in a local mirror =="
|
||||
# safe.directory: the mounted checkout and, for a linked worktree, its mounted
|
||||
# common gitdir are owned by another uid.
|
||||
git config --global --add safe.directory '*'
|
||||
# CI checks out with fetch-depth 1, and a shallow boundary commit can neither
|
||||
# be pushed nor fetched into a ref ("shallow update not allowed"). The tag
|
||||
# therefore carries an orphan commit built from HEAD's tree: a root commit is
|
||||
# not a shallow root, and tree equality below still binds the gate to this
|
||||
# commit's exact contents.
|
||||
SOURCE_TREE="$(git -C "$REPO" rev-parse 'HEAD^{tree}')" \
|
||||
|| fail "$REPO is not a git checkout; the gate publishes the tag from git"
|
||||
git clone --quiet --depth 1 "file://$REPO" /tmp/gate-source
|
||||
# commit-tree needs an identity the bare container does not carry.
|
||||
EXPECTED_SHA="$(GIT_AUTHOR_NAME=gate GIT_AUTHOR_EMAIL=gate@local \
|
||||
GIT_COMMITTER_NAME=gate GIT_COMMITTER_EMAIL=gate@local \
|
||||
git -C /tmp/gate-source commit-tree "$SOURCE_TREE" -m "remote-install gate")"
|
||||
git init --bare "$MIRROR" >/dev/null
|
||||
git -C /tmp/gate-source push "$MIRROR" "$EXPECTED_SHA:refs/tags/$GATE_TAG" >/dev/null
|
||||
[ "$(git -C "$MIRROR" rev-parse "$GATE_TAG^{tree}")" = "$SOURCE_TREE" ] \
|
||||
|| fail "the mirror's $GATE_TAG does not carry HEAD's tree"
|
||||
# The installer clones as $INSTALL_USER, and git refuses a root-owned remote
|
||||
# for anyone else as "dubious ownership".
|
||||
chown -R "$INSTALL_USER:$INSTALL_USER" "$MIRROR"
|
||||
|
||||
# A remote user downloads install.sh over HTTPS; the gate takes the same file
|
||||
# out of the tag instead. The tagged copy is this commit's bootstrap, and the
|
||||
# handoff target is identical either way.
|
||||
git -C "$MIRROR" show "$GATE_TAG:install.sh" >"$BOOTSTRAP"
|
||||
chmod 0644 "$BOOTSTRAP"
|
||||
|
||||
run_remote_install() {
|
||||
# cd to $INSTALL_HOME so the bootstrap sees no checkout: local mode is chosen
|
||||
# by a pyproject.toml naming vocalinux in the working directory, and the
|
||||
# remote path is the one under test.
|
||||
su - "$INSTALL_USER" -c "cd '$INSTALL_HOME' && VOCALINUX_REPO_URL='file://$MIRROR' bash '$BOOTSTRAP' --auto --skip-models '--tag=$GATE_TAG'"
|
||||
}
|
||||
|
||||
# The user's literal `curl | bash`: installer code reaches bash on a pipe while
|
||||
# a controlling terminal exists. `script` allocates that pty — a saved file on
|
||||
# stdin or a headless pipe takes the headless --auto branch instead, which is
|
||||
# exactly how the fd-0 redirect regression in #933 slipped through. bsdutils /
|
||||
# util-linux `script` is in the base package set of every gated distro.
|
||||
command -v script >/dev/null 2>&1 || fail "script(1) not found; needed to give the piped run a terminal"
|
||||
#
|
||||
# The producer half matters too: a `cat` of the whole file finishes inside the
|
||||
# pipe buffer long before bash reaches the tagged-installer handoff, so it can
|
||||
# never be killed by a consumer that abandons the pipe the way a still-
|
||||
# streaming curl can (the error 23 the #933 fix still had to drain for). The
|
||||
# helper below trickles the bootstrap so it is mid-write at the handoff, and
|
||||
# pipefail makes its exit status count — a consumer that closes the pipe early
|
||||
# kills it with SIGPIPE and fails the run instead of hiding behind bash's own
|
||||
# exit status.
|
||||
PIPED_FEED=/tmp/vl-piped-feed.sh
|
||||
cat >"$PIPED_FEED" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Usage: vl-piped-feed.sh BOOTSTRAP [installer args...]
|
||||
set -o pipefail
|
||||
bootstrap=$1; shift
|
||||
# ~16 KiB/s: still writing (and soon blocked on the full pipe buffer) when the
|
||||
# bootstrap reaches the tagged-installer handoff, the way curl is mid-download.
|
||||
drip() {
|
||||
local file=$1 chunk rc=0
|
||||
split -b 8192 "$file" /tmp/vl-feed-
|
||||
for chunk in /tmp/vl-feed-*; do
|
||||
# A cat killed by SIGPIPE means the consumer closed the pipe early — the
|
||||
# download failure this arm exists to catch. Keep the status through the
|
||||
# cleanup so pipefail above sees it.
|
||||
cat "$chunk" || { rc=$?; break; }
|
||||
sleep 0.5
|
||||
done
|
||||
rm -f /tmp/vl-feed-*
|
||||
return "$rc"
|
||||
}
|
||||
drip "$bootstrap" | bash -s -- "$@"
|
||||
EOF
|
||||
chmod 0755 "$PIPED_FEED"
|
||||
run_remote_install_piped() {
|
||||
su - "$INSTALL_USER" -c "cd '$INSTALL_HOME' && VOCALINUX_REPO_URL='file://$MIRROR' script -qec \"'$PIPED_FEED' '$BOOTSTRAP' --auto --skip-models '--tag=$GATE_TAG'\" /dev/null"
|
||||
}
|
||||
|
||||
assert_remote_install() {
|
||||
echo "== Assert: the clone is exactly the selected tag =="
|
||||
[ -d "$CLONE_DIR/.git" ] || fail "remote install left no clone at $CLONE_DIR"
|
||||
local cloned_sha cloned_tree
|
||||
cloned_sha="$(git -C "$CLONE_DIR" rev-parse HEAD)" \
|
||||
|| fail "the clone at $CLONE_DIR is not a git checkout"
|
||||
[ "$cloned_sha" = "$EXPECTED_SHA" ] || fail \
|
||||
"installed from the wrong revision: clone HEAD is $cloned_sha, $GATE_TAG is $EXPECTED_SHA"
|
||||
[ "$(git -C "$CLONE_DIR" rev-parse "$GATE_TAG^{commit}" 2>/dev/null || true)" = "$EXPECTED_SHA" ] \
|
||||
|| fail "the clone does not carry the selected tag ref $GATE_TAG"
|
||||
# The tag's commit is an orphan; tree equality is the check that installer,
|
||||
# modules and exports are this commit's files and not a neighbour revision's.
|
||||
cloned_tree="$(git -C "$CLONE_DIR" rev-parse 'HEAD^{tree}')"
|
||||
[ "$cloned_tree" = "$SOURCE_TREE" ] || fail \
|
||||
"the clone's tree $cloned_tree is not HEAD's tree $SOURCE_TREE"
|
||||
|
||||
# The artifacts the #701 guardrail names: the tagged installer, its sourced
|
||||
# modules, and the exports pip installs with --require-hashes.
|
||||
[ -f "$CLONE_DIR/install.d/system_dependencies.sh" ] \
|
||||
|| fail "the clone ships no install.d/system_dependencies.sh for the tagged installer to source"
|
||||
[ -f "$CLONE_DIR/requirements/runtime.txt" ] \
|
||||
|| fail "the clone ships no requirements/runtime.txt for the tagged installer to install"
|
||||
# Same shape as the local gate's #736 check, now reachable through the remote
|
||||
# path: --skip-models means the run itself never reads the manifest.
|
||||
[ -f "$CLONE_DIR/src/vocalinux/utils/model_checksums.txt" ] \
|
||||
|| fail "the tag ships no src/vocalinux/utils/model_checksums.txt; install.sh pins model downloads against it"
|
||||
|
||||
echo "== Assert: the handoff carried the original arguments =="
|
||||
# handoff_to_tagged_installer appends --venv-dir; a venv anywhere else means
|
||||
# the appended argument was dropped.
|
||||
[ -x "$REMOTE_VENV/bin/python" ] \
|
||||
|| fail "no remote venv at $REMOTE_VENV; the handoff's --venv-dir did not reach the tagged installer"
|
||||
# The remote marker relocates the activation helper to ~/.local/bin only when
|
||||
# VOCALINUX_REMOTE_INSTALL survives the exec.
|
||||
[ -f "$INSTALL_HOME/.local/bin/activate-vocalinux.sh" ] \
|
||||
|| fail "no ~/.local/bin/activate-vocalinux.sh; the remote marker did not survive the handoff"
|
||||
# --skip-models forwarded: a model in the data dir means the tagged installer
|
||||
# re-parsed arguments the handoff did not deliver.
|
||||
if [ -d "$INSTALL_HOME/.local/share/vocalinux/models" ]; then
|
||||
[ -z "$(find "$INSTALL_HOME/.local/share/vocalinux/models" -type f -print -quit)" ] \
|
||||
|| fail "models were downloaded; --skip-models did not survive the handoff"
|
||||
fi
|
||||
|
||||
echo "== Assert: the installed app is there and answers =="
|
||||
[ -x "$INSTALL_HOME/.local/bin/vocalinux" ] || fail "no wrapper at ~/.local/bin/vocalinux"
|
||||
[ -x "$INSTALL_HOME/.local/bin/vocalinux-gui" ] || fail "no wrapper at ~/.local/bin/vocalinux-gui"
|
||||
[ -f "$INSTALL_HOME/.local/share/applications/vocalinux.desktop" ] \
|
||||
|| fail "no desktop entry; install_desktop_entry did not run"
|
||||
|
||||
# The version the wrapper reports must be the tag's own, not an assertion
|
||||
# about a version string composed locally.
|
||||
local expected_version version_output
|
||||
expected_version="$(grep -oP '__version__\s*=\s*"\K[^"]+' "$CLONE_DIR/src/vocalinux/version.py")" \
|
||||
|| fail "cannot read __version__ from the clone's version.py"
|
||||
version_output="$(su - "$INSTALL_USER" -c "'$INSTALL_HOME/.local/bin/vocalinux' --version")" \
|
||||
|| fail "the installed wrapper cannot report a version"
|
||||
case "$version_output" in
|
||||
*"$expected_version"*) ;;
|
||||
*) fail "wrapper reports '$version_output', expected the tag's $expected_version" ;;
|
||||
esac
|
||||
echo " vocalinux --version: $version_output"
|
||||
|
||||
# Same blind spots as the local gate: the injection binaries are reached
|
||||
# through subprocess, and the IBus runtime only where the package list
|
||||
# promises it.
|
||||
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
|
||||
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
|
||||
|
||||
su - "$INSTALL_USER" -c "'$REMOTE_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. 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 "== Run 1: remote install (fresh clone) as $INSTALL_USER =="
|
||||
START=$(date +%s)
|
||||
run_remote_install || fail "the remote install exited non-zero on a fresh clone"
|
||||
echo " install took $(( $(date +%s) - START ))s"
|
||||
assert_remote_install
|
||||
|
||||
echo "== Run 2: re-run over the existing clone (fetch + reset), piped under a terminal =="
|
||||
# The second bootstrap sees $CLONE_DIR/.git and updates it instead of cloning:
|
||||
# the arm a user re-running curl|bash takes on every release after the first —
|
||||
# served here literally through `cat | bash` on a pty so the terminal-backed
|
||||
# pipe keeps regression coverage (#933).
|
||||
# Move the clone off the tag and drop its local tag ref first: a run that only
|
||||
# had to notice the right commit was already checked out would pass even if
|
||||
# the fetch and reset were both no-ops.
|
||||
AWAY_SHA="$(su - "$INSTALL_USER" -c "cd '$CLONE_DIR' && \
|
||||
GIT_AUTHOR_NAME=gate GIT_AUTHOR_EMAIL=gate@local \
|
||||
GIT_COMMITTER_NAME=gate GIT_COMMITTER_EMAIL=gate@local \
|
||||
git commit-tree 'HEAD^{tree}' -p HEAD -m moved")"
|
||||
su - "$INSTALL_USER" -c "git -C '$CLONE_DIR' reset --hard '$AWAY_SHA' \
|
||||
&& git -C '$CLONE_DIR' update-ref -d 'refs/tags/$GATE_TAG'" \
|
||||
|| fail "could not move the clone off $GATE_TAG for the re-run arm"
|
||||
[ "$(git -C "$CLONE_DIR" rev-parse HEAD)" = "$AWAY_SHA" ] \
|
||||
|| fail "the clone did not move off $GATE_TAG; the re-run proves nothing"
|
||||
[ -z "$(git -C "$CLONE_DIR" tag -l "$GATE_TAG")" ] \
|
||||
|| fail "the clone kept refs/tags/$GATE_TAG; the re-run proves nothing"
|
||||
run_remote_install_piped || fail "the terminal-backed piped install exited non-zero over the existing clone"
|
||||
grep -l "Updating existing clone" "$INSTALL_HOME"/.local/state/vocalinux/install-*.log >/dev/null 2>&1 \
|
||||
|| fail "no install log shows 'Updating existing clone'; the fetch arm did not run"
|
||||
assert_remote_install
|
||||
|
||||
echo "PASS: the remote install path (bootstrap -> tag -> clone/fetch -> handoff) installs ${NAME:-this distro}'s app"
|
||||
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)"
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Select committed requirement blocks without changing pins, markers or hashes.
|
||||
|
||||
The installer uses complete exports for normal installs and just pywhispercpp's
|
||||
block for backend rebuilds. Missing exports or packages are errors, never an
|
||||
empty successful install. Only the standard library is needed before bootstrap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def select_requirements(source: Path, package: str | None = None) -> str:
|
||||
"""Return an export, optionally retaining only one distribution's blocks."""
|
||||
text = source.read_text(encoding="utf-8")
|
||||
blocks = re.split(r"(?=^[A-Za-z0-9_.-]+==)", text, flags=re.MULTILINE)
|
||||
requirements = blocks[1:]
|
||||
if not requirements:
|
||||
raise ValueError(f"No pinned requirements in {source}")
|
||||
if package is not None:
|
||||
normalize = lambda name: re.sub(r"[-_.]+", "-", name).lower()
|
||||
requirements = [
|
||||
block
|
||||
for block in requirements
|
||||
if normalize(block.split("==", 1)[0]) == normalize(package)
|
||||
]
|
||||
if not requirements:
|
||||
raise ValueError(f"No pin for {package} in {source}")
|
||||
return blocks[0] + "".join(requirements)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Write the selected export to the installer's scratch directory."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("destination", type=Path)
|
||||
parser.add_argument("--package")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
text = select_requirements(args.source, args.package)
|
||||
except (OSError, ValueError) as error:
|
||||
parser.exit(1, f"Cannot install pinned dependencies: {error}\n")
|
||||
args.destination.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare the exact AUR source inputs, verifying published release provenance first.
|
||||
|
||||
Run with the locked release-build dependency group (provides packaging). Release
|
||||
mode requires SHA256SUMS and the expected source commit. --local-unattested is
|
||||
only for PR/local fixtures built by the reproducible-wheel gate, never publishing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TEMPLATE = ROOT / "packaging/aur/vocalinux/PKGBUILD"
|
||||
REPOSITORY = "VocaHQ/vocalinux"
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
"""Hash the original archive bytes, without extracting or repacking them."""
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def sdist_version(sdist: Path) -> str:
|
||||
"""Require one canonical Vocalinux sdist with matching top-level metadata."""
|
||||
match = re.fullmatch(r"vocalinux-([0-9][A-Za-z0-9.+!]*)\.tar\.gz", sdist.name)
|
||||
if not match:
|
||||
raise ValueError("expected a canonical vocalinux-VERSION.tar.gz sdist")
|
||||
version = match[1]
|
||||
if str(Version(version)) != version:
|
||||
raise ValueError("sdist filename version is not canonical")
|
||||
root = f"vocalinux-{version}"
|
||||
with tarfile.open(sdist, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
if any(
|
||||
member.name.split("/", 1)[0] != root or ".." in member.name.split("/")
|
||||
for member in members
|
||||
):
|
||||
raise ValueError("sdist has an unexpected archive root or unsafe member path")
|
||||
metadata = [member for member in members if member.name == f"{root}/PKG-INFO"]
|
||||
if len(metadata) != 1 or not metadata[0].isfile():
|
||||
raise ValueError("sdist must contain exactly one regular top-level PKG-INFO")
|
||||
stream = archive.extractfile(metadata[0])
|
||||
assert stream is not None
|
||||
with stream:
|
||||
info = BytesParser().parse(stream)
|
||||
if info.get_all("Name") != ["vocalinux"] or info.get_all("Version") != [version]:
|
||||
raise ValueError("sdist metadata does not match its filename")
|
||||
return version
|
||||
|
||||
|
||||
def verify_release(sdist: Path, manifest: Path, tag: str, commit: str) -> str:
|
||||
"""Fail closed on missing/mismatched hashes or untrusted release provenance."""
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
raise ValueError("release verification requires a full source commit SHA")
|
||||
entries = []
|
||||
for line in manifest.read_text(encoding="utf-8").splitlines():
|
||||
match = re.fullmatch(r"([0-9a-f]{64}) (\S+)", line)
|
||||
if not match:
|
||||
raise ValueError("malformed SHA256SUMS entry")
|
||||
if match[2] == sdist.name:
|
||||
entries.append(match[1])
|
||||
if len(entries) != 1:
|
||||
raise ValueError("SHA256SUMS must pin the sdist exactly once")
|
||||
if digest(sdist) != entries[0]:
|
||||
raise ValueError("sdist checksum does not match SHA256SUMS")
|
||||
subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"attestation",
|
||||
"verify",
|
||||
str(sdist),
|
||||
"--repo",
|
||||
REPOSITORY,
|
||||
"--signer-workflow",
|
||||
f"{REPOSITORY}/.github/workflows/release.yml",
|
||||
"--source-ref",
|
||||
f"refs/tags/{tag}",
|
||||
"--source-digest",
|
||||
commit,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return entries[0]
|
||||
|
||||
|
||||
def prepare(
|
||||
sdist: Path,
|
||||
output: Path,
|
||||
tag: str | None = None,
|
||||
manifest: Path | None = None,
|
||||
commit: str | None = None,
|
||||
local_unattested: bool = False,
|
||||
) -> None:
|
||||
"""Stage an unchanged sdist and its fully pinned PKGBUILD only after validation."""
|
||||
version = sdist_version(sdist)
|
||||
tag = tag or (f"v{version}" if local_unattested else "")
|
||||
if not re.fullmatch(r"v[0-9][A-Za-z0-9.+!-]*", tag) or Version(tag[1:]) != Version(version):
|
||||
raise ValueError("release tag and sdist version do not match")
|
||||
if local_unattested:
|
||||
if manifest is not None or commit is not None:
|
||||
raise ValueError("local-unattested mode cannot take release verification inputs")
|
||||
print("UNATTESTED local/PR fixture: not authorized for AUR publication")
|
||||
checksum = digest(sdist)
|
||||
else:
|
||||
if manifest is None or commit is None:
|
||||
raise ValueError("release mode requires --manifest and --source-commit")
|
||||
checksum = verify_release(sdist, manifest, tag, commit)
|
||||
|
||||
text = TEMPLATE.read_text(encoding="utf-8")
|
||||
for key, value in {
|
||||
"pkgver": tag[1:].replace("-", ""),
|
||||
"_tag": tag[1:],
|
||||
"_version": version,
|
||||
"pkgrel": "1",
|
||||
"sha256sums": f"('{checksum}')",
|
||||
}.items():
|
||||
text, count = re.subn(rf"^{key}=.*$", f"{key}={value}", text, flags=re.MULTILINE)
|
||||
if count != 1:
|
||||
raise ValueError(f"PKGBUILD template must assign {key} exactly once")
|
||||
if output.exists() or output.is_symlink():
|
||||
raise ValueError(f"output already exists; choose a fresh directory: {output}")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=".aur-source-", dir=output.parent) as temporary:
|
||||
staging = Path(temporary) / "source"
|
||||
staging.mkdir()
|
||||
shutil.copyfile(sdist, staging / sdist.name)
|
||||
if digest(staging / sdist.name) != checksum:
|
||||
raise ValueError("sdist changed while staging")
|
||||
(staging / "PKGBUILD").write_text(text, encoding="utf-8")
|
||||
staging.rename(output)
|
||||
print(f"Prepared {tag}: {checksum} {sdist.name} in {output}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Prepare verified release inputs or an explicitly unattested local fixture."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sdist", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--tag", help="exact v* tag; defaults to the sdist version in local mode")
|
||||
parser.add_argument("--manifest", type=Path, help="published SHA256SUMS")
|
||||
parser.add_argument("--source-commit", help="expected full commit SHA of the release tag")
|
||||
parser.add_argument("--local-unattested", action="store_true", help="PR/local fixture only")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
prepare(
|
||||
args.sdist,
|
||||
args.output,
|
||||
args.tag,
|
||||
args.manifest,
|
||||
args.source_commit,
|
||||
args.local_unattested,
|
||||
)
|
||||
except (OSError, ValueError, tarfile.TarError, subprocess.CalledProcessError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+355
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Point the Flatpak's dependency manifest at the versions uv.lock resolved.
|
||||
|
||||
`packaging/flatpak/python3-dependencies.yaml` was produced once by
|
||||
flatpak-pip-generator, from a package list typed out by hand on the command
|
||||
line recorded in its first line, and then hand-edited. Nothing regenerated it
|
||||
afterwards and no test compared it to anything, so it drifted: by 2026-09-10 ten
|
||||
of its fifteen shared packages were behind `requirements/runtime.txt`, including
|
||||
pywhispercpp, which sat at 1.4.1 under the project's own `>=1.5.0` floor. The
|
||||
Flatpak installs the project with `pip3 install --no-deps`, so that constraint
|
||||
is never evaluated and nothing there could notice. It now reads
|
||||
`requirements/vad.txt`, the runtime dependencies plus the `[vad]` extra, so
|
||||
Silero VAD's onnxruntime ships too (#969).
|
||||
|
||||
This script closes the loop the other way round: it reads the hash-pinned export
|
||||
that `just lock` produces and rewrites every source in the manifest to the
|
||||
artifact whose digest uv already recorded. The bytes the Flatpak downloads are
|
||||
then the bytes in `uv.lock`, verified by hash rather than by convention. The
|
||||
`onnxruntime` sources are selected as CPython-ABI wheels for each Flatpak
|
||||
architecture because PyPI publishes no sdist for it.
|
||||
|
||||
It edits in place rather than regenerating, because two things in that file are
|
||||
hand-written and no generator produces them: pywhispercpp's Vulkan build with
|
||||
its version injection, and the symlinking of whisper.cpp's shared libraries onto
|
||||
the loader path. Keeping the structure keeps the diff to versions and digests.
|
||||
|
||||
Usage:
|
||||
python scripts/sync_flatpak_deps.py # rewrite the manifest
|
||||
python scripts/sync_flatpak_deps.py --check # report drift, change nothing
|
||||
|
||||
`tests/test_flatpak_packaging.py` asserts the same invariant offline, so CI
|
||||
catches drift without reaching PyPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.markers import Marker
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXPORT = REPO_ROOT / "requirements" / "vad.txt"
|
||||
DEPS_YAML = REPO_ROOT / "packaging" / "flatpak" / "python3-dependencies.yaml"
|
||||
MANIFEST = REPO_ROOT / "packaging" / "flatpak" / "com.vocalinux.Vocalinux.yml"
|
||||
|
||||
#: The environment the Flatpak build resolves for. GNOME 50 ships Python 3.13.
|
||||
#: numpy's lock entry splits at `python_full_version >= '3.12'`; onnxruntime's
|
||||
#: CPython wheel tag also uses this Python version. Bump it when the manifest's
|
||||
#: runtime-version moves.
|
||||
TARGET_ENV = {
|
||||
"sys_platform": "linux",
|
||||
"platform_system": "Linux",
|
||||
"platform_machine": "x86_64",
|
||||
"os_name": "posix",
|
||||
"python_version": "3.13",
|
||||
"python_full_version": "3.13.0",
|
||||
"implementation_name": "cpython",
|
||||
"platform_python_implementation": "CPython",
|
||||
"extra": "",
|
||||
}
|
||||
|
||||
#: org.gnome.Platform//50 ships these; a second copy under /app would shadow the
|
||||
#: runtime's own GI stack. PyGObject is already absent from the export, which
|
||||
#: `just lock` builds with --no-emit-package pygobject for the same reason.
|
||||
RUNTIME_PROVIDED = {"pycairo"}
|
||||
|
||||
#: onnxruntime has no sdist on PyPI, and building it from source is out of reach
|
||||
#: in the sandbox. These come as CPython-ABI wheels, one per Flatpak arch, tagged
|
||||
#: for TARGET_ENV's python_version; a runtime Python bump makes pip --no-index
|
||||
#: fail loudly at build time rather than silently.
|
||||
BINARY_ONLY = {"onnxruntime"}
|
||||
FLATPAK_ARCHES = ("x86_64", "aarch64")
|
||||
|
||||
#: Build backends for the sdists the manifest builds offline under
|
||||
#: --no-build-isolation. They are not runtime dependencies, so they never appear
|
||||
#: in requirements/vad.txt and stay pinned here by hand.
|
||||
BUILD_BACKENDS = {"meson-python", "pyproject-metadata"}
|
||||
|
||||
PYPI_JSON = "https://pypi.org/pypi/{name}/{version}/json"
|
||||
|
||||
|
||||
def parse_export(path: Path) -> dict[str, tuple[str, set[str]]]:
|
||||
"""Map every package the Flatpak target selects to its version and digests.
|
||||
|
||||
uv writes one `name==version [; marker]` line followed by indented
|
||||
`--hash=sha256:` lines, continued with backslashes. A package can appear
|
||||
more than once under mutually exclusive markers, which is how numpy is
|
||||
split across Python versions, so the marker decides rather than the order.
|
||||
"""
|
||||
selected: dict[str, tuple[str, set[str]]] = {}
|
||||
current: str | None = None
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip().rstrip("\\").strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
head = re.match(r"^([A-Za-z0-9._-]+)==([^ ;]+)\s*(?:;\s*(.*))?$", line)
|
||||
if head:
|
||||
name, version, marker = head.group(1), head.group(2), head.group(3)
|
||||
current = None
|
||||
if marker and not Marker(marker).evaluate(TARGET_ENV):
|
||||
continue
|
||||
key = canonicalize_name(name)
|
||||
if key in selected:
|
||||
raise SystemExit(
|
||||
f"{path.name}: {key} resolves twice for the Flatpak target "
|
||||
f"({selected[key][0]} and {version}); the markers overlap"
|
||||
)
|
||||
selected[key] = (version, set())
|
||||
current = key
|
||||
continue
|
||||
digest = re.match(r"^--hash=sha256:([0-9a-f]{64})$", line)
|
||||
if digest and current:
|
||||
selected[current][1].add(digest.group(1))
|
||||
return selected
|
||||
|
||||
|
||||
def pypi_artifacts(name: str, version: str) -> list[dict]:
|
||||
url = PYPI_JSON.format(name=name, version=version)
|
||||
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
|
||||
return json.load(response)["urls"]
|
||||
|
||||
|
||||
def choose_artifact(
|
||||
name: str, version: str, digests: set[str], arch: str | None = None
|
||||
) -> tuple[str, str]:
|
||||
"""The one file the Flatpak should download, as (url, sha256).
|
||||
|
||||
Binary-only packages use an architecture-specific CPython wheel. For every
|
||||
other package, prefer a universal wheel and fall back to the sdist so the
|
||||
sandbox builds compiled dependencies against the SDK's interpreter.
|
||||
"""
|
||||
artifacts = pypi_artifacts(name, version)
|
||||
if name in BINARY_ONLY:
|
||||
if arch not in FLATPAK_ARCHES:
|
||||
raise SystemExit(
|
||||
f"{name} {version}: expected one of the Flatpak architectures "
|
||||
f"{', '.join(FLATPAK_ARCHES)}, got {arch!r}"
|
||||
)
|
||||
python_tag = TARGET_ENV["python_version"].replace(".", "")
|
||||
wheel_pattern = re.compile(
|
||||
rf"-cp{python_tag}-cp{python_tag}-manylinux_.+_{re.escape(arch)}\.whl$"
|
||||
)
|
||||
candidates = [
|
||||
artifact
|
||||
for artifact in artifacts
|
||||
if artifact["packagetype"] == "bdist_wheel"
|
||||
and wheel_pattern.search(artifact["filename"])
|
||||
]
|
||||
if not candidates:
|
||||
raise SystemExit(
|
||||
f"{name} {version} publishes no CPython cp{python_tag} manylinux "
|
||||
f"wheel for {arch}; update the Flatpak Python runtime or package"
|
||||
)
|
||||
else:
|
||||
wheels = [
|
||||
artifact
|
||||
for artifact in artifacts
|
||||
if artifact["packagetype"] == "bdist_wheel"
|
||||
and artifact["filename"].endswith("-none-any.whl")
|
||||
]
|
||||
sdists = [artifact for artifact in artifacts if artifact["packagetype"] == "sdist"]
|
||||
candidates = wheels or sdists
|
||||
if not candidates:
|
||||
raise SystemExit(
|
||||
f"{name} {version} publishes neither a universal wheel nor an sdist; "
|
||||
"the Flatpak cannot build it without an ABI-tagged wheel"
|
||||
)
|
||||
# A release can publish more than one matching wheel; take the first whose
|
||||
# digest uv actually locked.
|
||||
picked = next((a for a in candidates if a["digests"]["sha256"] in digests), None)
|
||||
if picked is None:
|
||||
first = candidates[0]
|
||||
raise SystemExit(
|
||||
f"{name} {version}: PyPI serves {first['filename']} with digest "
|
||||
f"{first['digests']['sha256']}, which is not among the hashes uv "
|
||||
"locked. Re-run `just lock`."
|
||||
)
|
||||
return picked["url"], picked["digests"]["sha256"]
|
||||
|
||||
|
||||
def artifact_name(filename: str) -> str:
|
||||
if filename.endswith(".whl"):
|
||||
return canonicalize_name(filename.split("-")[0])
|
||||
base = re.sub(r"\.(tar\.gz|zip|tar\.bz2)$", "", filename)
|
||||
return canonicalize_name(base.rpartition("-")[0])
|
||||
|
||||
|
||||
def manifest_sources(text: str) -> list[tuple[int, str, str]]:
|
||||
"""Every PyPI source in the manifest, as (line index, filename, sha256)."""
|
||||
lines = text.splitlines()
|
||||
found = []
|
||||
for index, line in enumerate(lines):
|
||||
url = re.match(r"^\s*url: (https://files\.pythonhosted\.org/\S+/([^/\s]+))$", line)
|
||||
if not url:
|
||||
continue
|
||||
digest = re.match(r"^\s*sha256: ([0-9a-f]{64})\s*$", lines[index + 1])
|
||||
if not digest:
|
||||
raise SystemExit(
|
||||
f"{DEPS_YAML.name}:{index + 2}: a PyPI source with no sha256 beneath it"
|
||||
)
|
||||
found.append((index, url.group(2), digest.group(1)))
|
||||
return found
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="report what would change and exit non-zero, without writing",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
locked = parse_export(EXPORT)
|
||||
text = DEPS_YAML.read_text(encoding="utf-8")
|
||||
lines = text.splitlines(keepends=True)
|
||||
|
||||
sources = manifest_sources(text)
|
||||
in_manifest = {artifact_name(name) for _, name, _ in sources}
|
||||
stale = sorted(in_manifest - set(locked) - BUILD_BACKENDS)
|
||||
missing = sorted(set(locked) - in_manifest - RUNTIME_PROVIDED)
|
||||
binary_arches = {name: set() for name in BINARY_ONLY}
|
||||
for _, filename, _ in sources:
|
||||
name = artifact_name(filename)
|
||||
if name in BINARY_ONLY:
|
||||
arch_match = re.search(r"_(x86_64|aarch64)\.whl$", filename)
|
||||
if arch_match:
|
||||
binary_arches[name].add(arch_match.group(1))
|
||||
for name in BINARY_ONLY & set(locked):
|
||||
missing.extend(
|
||||
f"{name} ({arch})" for arch in FLATPAK_ARCHES if arch not in binary_arches[name]
|
||||
)
|
||||
missing.sort()
|
||||
if stale:
|
||||
print(
|
||||
f"{DEPS_YAML.name} builds packages the lock does not resolve: "
|
||||
f"{', '.join(stale)}\n"
|
||||
"Delete their modules and sources by hand: whether a package is a "
|
||||
"build backend the sandbox needs or a leftover this script cannot "
|
||||
"tell.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if missing:
|
||||
print(
|
||||
f"{DEPS_YAML.name} is missing packages the lock resolves: "
|
||||
f"{', '.join(missing)}\n"
|
||||
"The Flatpak installs with --no-deps, so a missing one fails at "
|
||||
"runtime rather than at build time. Add a module for each.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if stale or missing:
|
||||
return 1
|
||||
|
||||
changes: list[str] = []
|
||||
for index, filename, old_sha in sources:
|
||||
name = artifact_name(filename)
|
||||
if name in BUILD_BACKENDS:
|
||||
# Absent from the lock by definition: nothing imports them at
|
||||
# runtime, they only let the sandbox build the sdists offline.
|
||||
continue
|
||||
version, digests = locked[name]
|
||||
arch = None
|
||||
if name in BINARY_ONLY:
|
||||
arch_match = re.search(r"_(x86_64|aarch64)\.whl$", filename)
|
||||
if not arch_match:
|
||||
raise SystemExit(f"{name}: cannot determine a Flatpak architecture from {filename}")
|
||||
arch = arch_match.group(1)
|
||||
url, sha = choose_artifact(name, version, digests, arch)
|
||||
if sha == old_sha:
|
||||
continue
|
||||
indent = re.match(r"^(\s*)", lines[index]).group(1)
|
||||
lines[index] = f"{indent}url: {url}\n"
|
||||
lines[index + 1] = f"{indent}sha256: {sha}\n"
|
||||
changes.append(f" {name}: {filename} -> {url.rsplit('/', 1)[-1]}")
|
||||
|
||||
updated = "".join(lines)
|
||||
|
||||
# pywhispercpp's version is spelled out in its build commands: the sdist
|
||||
# carries no version.txt and setup() takes no version= argument, so the
|
||||
# module rewrites setup.py before building. setuptools-scm would otherwise
|
||||
# read a git tree that does not exist in the sandbox.
|
||||
pywhispercpp_version = locked["pywhispercpp"][0]
|
||||
updated, injected = re.subn(
|
||||
# The version appears as a tarball name, a directory name and a
|
||||
# literal inside the setup.py rewrite. Refuse to match rather than
|
||||
# match short: a partial match would leave the tail of the old version
|
||||
# glued to the new one and rename the directory tar just wrote.
|
||||
r"pywhispercpp-\d+(?:\.\d+)*(?![\d.]\d)",
|
||||
f"pywhispercpp-{pywhispercpp_version}",
|
||||
updated,
|
||||
)
|
||||
updated = re.sub(
|
||||
r"PYWHISPERCPP_VERSION=\d+(?:\.\d+)*",
|
||||
f"PYWHISPERCPP_VERSION={pywhispercpp_version}",
|
||||
updated,
|
||||
)
|
||||
updated = re.sub(
|
||||
r'(version=\\")\d+(?:\.\d+)*(\\")',
|
||||
rf"\g<1>{pywhispercpp_version}\g<2>",
|
||||
updated,
|
||||
)
|
||||
if injected and updated != text and not changes:
|
||||
changes.append(f" pywhispercpp: build commands -> {pywhispercpp_version}")
|
||||
|
||||
manifest_text = MANIFEST.read_text(encoding="utf-8")
|
||||
manifest_updated = re.sub(
|
||||
r'(PYWHISPERCPP_VERSION: ")\d+(?:\.\d+)*(")',
|
||||
rf"\g<1>{pywhispercpp_version}\g<2>",
|
||||
manifest_text,
|
||||
)
|
||||
|
||||
if updated == text and manifest_updated == manifest_text:
|
||||
print(
|
||||
"Flatpak dependencies already match requirements/vad.txt "
|
||||
"(runtime dependencies plus the [vad] extra)"
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.check:
|
||||
print(
|
||||
"Flatpak dependencies are behind requirements/vad.txt "
|
||||
"(runtime dependencies plus the [vad] extra):",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for change in changes:
|
||||
print(change, file=sys.stderr)
|
||||
if manifest_updated != manifest_text:
|
||||
print(
|
||||
f" {MANIFEST.name}: PYWHISPERCPP_VERSION -> {pywhispercpp_version}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("Run `just flatpak-deps` to update.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
DEPS_YAML.write_text(updated, encoding="utf-8")
|
||||
MANIFEST.write_text(manifest_updated, encoding="utf-8")
|
||||
print(f"Updated {DEPS_YAML.relative_to(REPO_ROOT)}:")
|
||||
for change in changes:
|
||||
print(change)
|
||||
if manifest_updated != manifest_text:
|
||||
print(
|
||||
f"Updated {MANIFEST.relative_to(REPO_ROOT)}: "
|
||||
f"PYWHISPERCPP_VERSION={pywhispercpp_version}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+219
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Vocalinux Remote API Test Server
|
||||
|
||||
A simple mock server for testing Vocalinux's remote API feature.
|
||||
Supports whisper.cpp (/inference) and OpenAI/FunASR-compatible
|
||||
(/v1/audio/transcriptions) formats.
|
||||
|
||||
Usage:
|
||||
python test_remote_server.py [--port PORT] [--delay SECONDS]
|
||||
|
||||
Examples:
|
||||
python test_remote_server.py # Default: port 8080, no delay
|
||||
python test_remote_server.py --port 9000 # Custom port
|
||||
python test_remote_server.py --delay 2 # Simulate 2s processing delay
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
class RemoteAPITestHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP request handler for testing Vocalinux remote API."""
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Custom log format with timestamp."""
|
||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{timestamp}] {args[0]}")
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET requests (health check and OpenAI models endpoint)."""
|
||||
if self.path == "/":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
response = {
|
||||
"status": "ok",
|
||||
"message": "Vocalinux Remote API Test Server",
|
||||
"endpoints": [
|
||||
"/inference (whisper.cpp format)",
|
||||
"/v1/audio/transcriptions (OpenAI/FunASR format)",
|
||||
"/v1/models (OpenAI models list)",
|
||||
],
|
||||
}
|
||||
self.wfile.write(json.dumps(response, indent=2).encode())
|
||||
|
||||
elif self.path == "/v1/models":
|
||||
# OpenAI-compatible models endpoint
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
response = {
|
||||
"data": [
|
||||
{
|
||||
"id": "whisper-1",
|
||||
"object": "model",
|
||||
"created": 1234567890,
|
||||
"owned_by": "openai",
|
||||
},
|
||||
{
|
||||
"id": "sensevoice",
|
||||
"object": "model",
|
||||
"created": 1767225600,
|
||||
"owned_by": "mock",
|
||||
},
|
||||
],
|
||||
"object": "list",
|
||||
}
|
||||
self.wfile.write(json.dumps(response, indent=2).encode())
|
||||
|
||||
elif self.path == "/inference":
|
||||
# whisper.cpp server health check
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
response = {"status": "ok", "model": "mock-whisper"}
|
||||
self.wfile.write(json.dumps(response, indent=2).encode())
|
||||
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
response = {"error": "Not found"}
|
||||
self.wfile.write(json.dumps(response, indent=2).encode())
|
||||
|
||||
def do_POST(self):
|
||||
"""Handle POST requests (transcription endpoints)."""
|
||||
# Simulate processing delay if configured
|
||||
if self.server.delay > 0:
|
||||
time.sleep(self.server.delay)
|
||||
|
||||
if self.path in ["/inference", "/v1/audio/transcriptions"]:
|
||||
# Parse multipart form data (simplified - just read the body)
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length)
|
||||
|
||||
# Extract filename if present
|
||||
filename = "audio.wav"
|
||||
if b"filename=" in body:
|
||||
# Simple extraction from multipart data
|
||||
try:
|
||||
start = body.index(b'filename="') + 10
|
||||
end = body.index(b'"', start)
|
||||
filename = body[start:end].decode("utf-8", errors="ignore")
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Generate mock transcription based on filename
|
||||
transcription = self._generate_mock_transcription(filename)
|
||||
|
||||
# Send response
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
|
||||
if self.path == "/inference":
|
||||
# whisper.cpp format
|
||||
response = {"text": transcription}
|
||||
else:
|
||||
# OpenAI/FunASR-compatible format
|
||||
response = {"text": transcription}
|
||||
|
||||
self.wfile.write(json.dumps(response, indent=2).encode())
|
||||
print(f" → Transcribed '{filename}': {transcription}")
|
||||
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
response = {"error": "Not found"}
|
||||
self.wfile.write(json.dumps(response, indent=2).encode())
|
||||
|
||||
def _generate_mock_transcription(self, filename: str) -> str:
|
||||
"""Generate a mock transcription based on the filename."""
|
||||
# Different responses based on filename to make testing more interesting
|
||||
if "test" in filename.lower():
|
||||
return "This is a test transcription from the mock server."
|
||||
elif "hello" in filename.lower():
|
||||
return "Hello, this is Vocalinux testing the remote API."
|
||||
elif "long" in filename.lower():
|
||||
return (
|
||||
"This is a longer transcription to test how the system handles "
|
||||
"multiple sentences and proper text injection into applications. "
|
||||
"The quick brown fox jumps over the lazy dog."
|
||||
)
|
||||
else:
|
||||
return "Mock transcription from test server."
|
||||
|
||||
|
||||
class DelayedHTTPServer(HTTPServer):
|
||||
"""HTTP Server with configurable delay."""
|
||||
|
||||
def __init__(self, server_address, RequestHandlerClass, delay=0):
|
||||
self.delay = delay
|
||||
super().__init__(server_address, RequestHandlerClass)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Vocalinux Remote API Test Server"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8080,
|
||||
help="Port to listen on (default: 8080)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay",
|
||||
type=float,
|
||||
default=0,
|
||||
help="Simulated processing delay in seconds (default: 0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
type=str,
|
||||
default="0.0.0.0",
|
||||
help="Host to bind to (default: 0.0.0.0)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("Vocalinux Remote API Test Server")
|
||||
print("=" * 60)
|
||||
print(f"Listening on: http://{args.host}:{args.port}")
|
||||
print(f"Processing delay: {args.delay}s")
|
||||
print()
|
||||
print("Endpoints:")
|
||||
print(" GET / - Server info")
|
||||
print(" GET /v1/models - OpenAI models list")
|
||||
print(" GET /inference - whisper.cpp health check")
|
||||
print(" POST /inference - whisper.cpp transcription")
|
||||
print(" POST /v1/audio/transcriptions - OpenAI/FunASR transcription")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
try:
|
||||
server = DelayedHTTPServer(
|
||||
(args.host, args.port), RemoteAPITestHandler, delay=args.delay
|
||||
)
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nServer stopped.")
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
if args.port < 1024:
|
||||
print("Note: Ports below 1024 require root privileges.")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
Executable
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that a published release verifies as published.
|
||||
|
||||
The guards in tests/ read workflow files, so none of them can see what a
|
||||
release looks like once its run has finished. This reads the release:
|
||||
|
||||
manifest SHA256SUMS is attached, lists every other asset, and its digests
|
||||
match the bytes GitHub stores
|
||||
provenance every artifact the manifest lists resolves in the attestations API
|
||||
notes the body still tells users how to check both
|
||||
pypi the wheel and the sdist on PyPI are the bytes on the release
|
||||
|
||||
Nothing is downloaded: GitHub reports a sha256 for every asset it stores, and an
|
||||
asset it reports none for fails rather than being skipped.
|
||||
|
||||
Usage: scripts/verify_release.py [tag] [--if-published]
|
||||
Default tag: the latest stable release. --if-published passes a tag that
|
||||
published nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
MANIFEST = "SHA256SUMS"
|
||||
PYPI_PROJECT = "vocalinux"
|
||||
PYPI_SUFFIXES = (".whl", ".tar.gz")
|
||||
PYPI_TIMEOUT = 30
|
||||
|
||||
#: Both commands the notes should hand the user, plus the file they act on.
|
||||
NOTES_MUST_MENTION = ("sha256sum -c", "gh attestation verify", MANIFEST)
|
||||
|
||||
#: What `gh attestation verify` asks for, so this asks for the same. Pre-encoded:
|
||||
#: `gh api` reads an unescaped `://` in a query as a protocol and refuses.
|
||||
SLSA_PROVENANCE = "https%3A%2F%2Fslsa.dev%2Fprovenance%2Fv1"
|
||||
|
||||
|
||||
class ReleaseNotFound(SystemExit):
|
||||
"""The API confirmed no release for the tag, with a 404.
|
||||
|
||||
Only this is forgivable. A rate limit or an auth failure stays a plain
|
||||
SystemExit, or --if-published would pass a release nothing has read.
|
||||
"""
|
||||
|
||||
|
||||
def _gh(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["gh", *args], capture_output=True, text=True)
|
||||
|
||||
|
||||
def _gh_ok(*args: str) -> str:
|
||||
done = _gh(*args)
|
||||
if done.returncode != 0:
|
||||
raise SystemExit(f"gh {' '.join(args)} failed:\n{done.stderr.strip()}")
|
||||
return done.stdout
|
||||
|
||||
|
||||
def repo_slug() -> str:
|
||||
slug = os.environ.get("GITHUB_REPOSITORY")
|
||||
return slug or json.loads(_gh_ok("repo", "view", "--json", "nameWithOwner"))["nameWithOwner"]
|
||||
|
||||
|
||||
def _release_from_api(payload: dict) -> dict:
|
||||
"""Map a Releases REST payload onto the field names the rest of this script uses."""
|
||||
return {
|
||||
"tagName": payload["tag_name"],
|
||||
"body": payload.get("body"),
|
||||
"isDraft": payload["draft"],
|
||||
"isPrerelease": payload["prerelease"],
|
||||
"assets": payload.get("assets") or [],
|
||||
}
|
||||
|
||||
|
||||
def fetch_release(tag: str | None) -> dict:
|
||||
"""The latest stable release, or the one named by tag. `0.16.2` is accepted
|
||||
as well as `v0.16.2`, since that is how the version is usually written.
|
||||
|
||||
Digests come from the Releases REST API (`gh api`), not `gh release view
|
||||
--json assets`. On gh 2.46 (common in distro packages, and what
|
||||
`just verify-release` often runs) view's asset objects have no digest
|
||||
field at all, so every asset would fail as undigested. The REST payload
|
||||
includes `digest` for assets GitHub stores a hash for.
|
||||
"""
|
||||
slug = repo_slug()
|
||||
candidates = [tag] + ([f"v{tag}"] if tag and tag[0].isdigit() else [])
|
||||
errors: list[str] = []
|
||||
absent = True
|
||||
for candidate in candidates:
|
||||
path = (
|
||||
f"/repos/{slug}/releases/tags/{candidate}"
|
||||
if candidate
|
||||
else f"/repos/{slug}/releases/latest"
|
||||
)
|
||||
done = _gh("api", path)
|
||||
if done.returncode == 0:
|
||||
return _release_from_api(json.loads(done.stdout))
|
||||
detail = (done.stderr or done.stdout or "").strip()
|
||||
absent = absent and "HTTP 404" in detail
|
||||
errors.append(f"{path}: {detail or f'exit {done.returncode}'}")
|
||||
named = " or ".join(c for c in candidates if c) or "the latest stable release"
|
||||
hint = "\n".join(errors)
|
||||
raise (ReleaseNotFound if absent else SystemExit)(
|
||||
f"no release found for {named}\n{hint}\nusage: verify_release.py [tag], e.g. v0.16.2"
|
||||
)
|
||||
|
||||
|
||||
def parse_manifest(text: str) -> dict[str, str]:
|
||||
"""`<digest> <name>` per line, as sha256sum writes and reads it."""
|
||||
entries = {}
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"{MANIFEST} line {number} is not `<digest> <name>`: {line!r}")
|
||||
# Binary mode marks the name with a leading star, which is not the name.
|
||||
entries[parts[1].strip().lstrip("*")] = parts[0].strip().lower()
|
||||
return entries
|
||||
|
||||
|
||||
def asset_digests(assets: list[dict]) -> tuple[dict[str, str], list[str]]:
|
||||
"""(name -> sha256, names GitHub reports no digest for)."""
|
||||
digests: dict[str, str] = {}
|
||||
undigested: list[str] = []
|
||||
for asset in assets:
|
||||
raw = (asset.get("digest") or "").strip().lower()
|
||||
if raw.startswith("sha256:"):
|
||||
digests[asset["name"]] = raw.split(":", 1)[1]
|
||||
else:
|
||||
undigested.append(asset["name"])
|
||||
return digests, undigested
|
||||
|
||||
|
||||
def check_manifest(stored: dict, manifest: dict, undigested: list) -> list[str]:
|
||||
"""Both directions: a missing line reads as tampering to anyone running
|
||||
`sha256sum -c`, and an extra one is a promise nothing keeps."""
|
||||
covered, listed = set(stored) - {MANIFEST}, set(manifest)
|
||||
return (
|
||||
[f"GitHub reports no digest for {name}" for name in undigested if name != MANIFEST]
|
||||
+ [f"{name} is published but absent from {MANIFEST}" for name in sorted(covered - listed)]
|
||||
+ [f"{MANIFEST} lists {name}, which is not an asset" for name in sorted(listed - covered)]
|
||||
+ [
|
||||
f"{name}: {MANIFEST} says {manifest[name]}, GitHub stores {stored[name]}"
|
||||
for name in sorted(covered & listed)
|
||||
if manifest[name] != stored[name]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def check_provenance(slug: str, manifest: dict[str, str]) -> list[str]:
|
||||
"""The manifest is the subject-checksums input, so it is not a subject itself."""
|
||||
problems = []
|
||||
for name, digest in sorted(manifest.items()):
|
||||
query = f"/repos/{slug}/attestations/sha256:{digest}"
|
||||
done = _gh("api", f"{query}?predicate_type={SLSA_PROVENANCE}")
|
||||
try:
|
||||
bundles = json.loads(done.stdout).get("attestations") if done.returncode == 0 else None
|
||||
except json.JSONDecodeError:
|
||||
bundles = None
|
||||
if not bundles:
|
||||
problems.append(f"{name} has no build provenance")
|
||||
return problems
|
||||
|
||||
|
||||
def check_notes(body: str | None) -> list[str]:
|
||||
# GitHub returns JSON null for an empty body; treat that as missing notes.
|
||||
text = body or ""
|
||||
return [f"the notes never mention `{p}`" for p in NOTES_MUST_MENTION if p not in text]
|
||||
|
||||
|
||||
def check_pypi(version: str, stored: dict[str, str]) -> list[str]:
|
||||
expected = {n: d for n, d in stored.items() if n.endswith(PYPI_SUFFIXES)}
|
||||
# Per suffix: a release that lost its sdist would otherwise pass on the wheel.
|
||||
problems = [
|
||||
f"the release carries no {suffix} to compare against PyPI"
|
||||
for suffix in PYPI_SUFFIXES
|
||||
if not any(name.endswith(suffix) for name in expected)
|
||||
]
|
||||
if not expected:
|
||||
return problems
|
||||
|
||||
# Past the early return, and not at module scope: urllib.error reaches
|
||||
# tempfile, which two tests here leave as a MagicMock in sys.modules.
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{version}/json"
|
||||
with urllib.request.urlopen(url, timeout=PYPI_TIMEOUT) as response:
|
||||
payload = json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 404:
|
||||
return problems + [f"PyPI has no {PYPI_PROJECT} {version}"]
|
||||
raise
|
||||
published = {i["filename"]: i["digests"]["sha256"].lower() for i in payload["urls"]}
|
||||
|
||||
for name, digest in sorted(expected.items()):
|
||||
if name not in published:
|
||||
problems.append(f"{name} is on the release but not on PyPI")
|
||||
elif published[name] != digest:
|
||||
problems.append(f"{name}: PyPI serves {published[name]}, the release serves {digest}")
|
||||
for name in sorted(published):
|
||||
if name.endswith(PYPI_SUFFIXES) and name not in expected:
|
||||
problems.append(f"{name} is on PyPI but not on the release")
|
||||
return problems
|
||||
|
||||
|
||||
def report(results: list[tuple[str, list[str]]]) -> bool:
|
||||
width = max(len(label) for label, _ in results)
|
||||
for label, problems in results:
|
||||
print(f" {label.ljust(width)} {'FAIL' if problems else 'PASS'}")
|
||||
for problem in problems:
|
||||
print(f" {' ' * width} {problem}")
|
||||
return not any(problems for _, problems in results)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = [arg for arg in argv[1:] if arg]
|
||||
if_published = "--if-published" in args
|
||||
positional = [arg for arg in args if arg != "--if-published"]
|
||||
tag = positional[0] if positional else None
|
||||
slug = repo_slug()
|
||||
try:
|
||||
release = fetch_release(tag)
|
||||
except ReleaseNotFound:
|
||||
if not if_published:
|
||||
raise
|
||||
# Nothing published, nothing to be wrong about, and the Release run
|
||||
# has already gone red.
|
||||
print(f"no release published for {tag or 'the latest stable tag'}, nothing to verify")
|
||||
return 0
|
||||
|
||||
if release["isDraft"] or release["isPrerelease"]:
|
||||
# Nightlies never go through publish-checksums and carry no manifest.
|
||||
print(f"{release['tagName']} is not a published stable release")
|
||||
return 1
|
||||
|
||||
tag = release["tagName"]
|
||||
stored, undigested = asset_digests(release["assets"])
|
||||
print(f"Verifying {slug} {tag}, {len(release['assets'])} assets\n")
|
||||
|
||||
if MANIFEST not in stored and MANIFEST not in undigested:
|
||||
results = [("manifest", [f"{tag} has no {MANIFEST} attached"])]
|
||||
else:
|
||||
text = _gh_ok("release", "download", tag, "--pattern", MANIFEST, "--output", "-")
|
||||
manifest = parse_manifest(text)
|
||||
results = [
|
||||
("manifest", check_manifest(stored, manifest, undigested)),
|
||||
("provenance", check_provenance(slug, manifest)),
|
||||
("notes", check_notes(release["body"])),
|
||||
("pypi", check_pypi(tag.removeprefix("v"), stored)),
|
||||
]
|
||||
|
||||
ok = report(results)
|
||||
print(f"\n{tag} {'verifies' if ok else 'does not verify'} as published")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
Reference in New Issue
Block a user