diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fb1a24..f446f5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -356,6 +356,40 @@ jobs: ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} commit_message: 'Release ${{ github.ref_name }}' + # Store-only: do not attach the .snap to the GitHub Release. grade: stable + # allows candidate; do not auto-release to stable — promote after QA. + # Secret: SNAPCRAFT_STORE_CREDENTIALS (snapcraft export-login). + publish-snap: + name: Build and Publish Snap + needs: build-and-release + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Require Snap Store credentials + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + run: | + if [ -z "$SNAPCRAFT_STORE_CREDENTIALS" ]; then + echo "SNAPCRAFT_STORE_CREDENTIALS is unset; cannot publish the snap" + exit 1 + fi + + - name: Build snap + id: build + uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1 + + - name: Publish snap to edge and candidate + uses: snapcore/action-publish@214b86e5ca036ead1668c79afb81e550e6c54d40 # v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ steps.build.outputs.snap }} + release: edge,candidate + deploy-website: name: Deploy Website runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 881bbe1..f5db04c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,13 @@ dist/ build/ *.egg-info/ *.egg +*.snap +# snapcraft local build trees (repo root only) +/parts/ +/prime/ +/stage/ +/snap/.snapcraft/ +/.snapcraft/ # Virtual environments venv/ diff --git a/README.md b/README.md index 5fc63ed..fd42f33 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,18 @@ cd vocalinux The installer handles everything: system dependencies, Python environment, speech models, and desktop integration. +### Snap (Ubuntu Snap Store) + +In-repo recipe: `snap/snapcraft.yaml` (issue [#48](https://github.com/VocaHQ/vocalinux/issues/48)). Store listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux). Pack and upload steps: [docs/INSTALL.md](docs/INSTALL.md). + +```bash +sudo snap install vocalinux --edge # current public channel (0.16.2 refresh) +# sudo snap install vocalinux --candidate # after candidate release +# sudo snap install vocalinux # after stable promotion +sudo snap connect vocalinux:audio-record # if mic is not auto-connected +sudo snap connect vocalinux:raw-input # global keyboard shortcuts (evdev) +``` + ### 🌙 Nightly Releases (Bleeding Edge) For developers and early adopters who want to test the latest features, check out our [GitHub Releases page](https://github.com/VocaHQ/vocalinux/releases) which includes both beta and nightly builds. diff --git a/docs/DISTRO_COMPATIBILITY.md b/docs/DISTRO_COMPATIBILITY.md index 39e1a12..49b954c 100644 --- a/docs/DISTRO_COMPATIBILITY.md +++ b/docs/DISTRO_COMPATIBILITY.md @@ -13,7 +13,7 @@ The cross-distribution compatibility improvements have been implemented in phase | **Phase 5** | ✅ **Complete** | **Fixed remaining hardcoded GI_TYPELIB_PATH values in install.sh and CI workflow** | | **Phase 6** | ✅ **Complete** | **Added wrapper script verification tests, updated documentation** | | **Phase 7** | ✅ **Complete** | **Flatpak packaging (whisper.cpp engine) for universal distribution support — see [`packaging/flatpak/`](../packaging/flatpak/README.md). Not on Flathub: submission [flathub#9368](https://github.com/flathub/flathub/pull/9368) closed 2026-07-23 on policy grounds; channel tracked in [#167](https://github.com/VocaHQ/vocalinux/issues/167).** | -| Phase 8 | 📋 Planned | Snap packaging for Ubuntu Software Store | +| Phase 8 | 🚧 In progress | Snap recipe in-repo + Store listing live ([snapcraft.io/vocalinux](https://snapcraft.io/vocalinux)); install `--edge` and pack/upload steps in [docs/INSTALL.md](INSTALL.md) | ## Technical Implementation diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f5da716..d74ffa1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -136,6 +136,29 @@ model during setup. On Ubuntu 24.04+ or Pop!_OS, install `libgirepository-2.0-dev` if `libgirepository1.0-dev` is not available. +### From Snap (Ubuntu Snap Store) + +Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux) (issue +[#48](https://github.com/VocaHQ/vocalinux/issues/48)). Recipe: `snap/snapcraft.yaml`. +Tagged `v*` releases build and publish to Snap Store `edge` and `candidate` +via CI when `SNAPCRAFT_STORE_CREDENTIALS` is set. `stable` is still a +manual promote after QA. + +```bash +sudo snap install vocalinux --edge + +# Optional: grant microphone / hotkeys if snapd does not auto-connect them +sudo snap connect vocalinux:audio-record +sudo snap connect vocalinux:raw-input +``` + +Pack and upload (requires `snapcraft` + LXD/Multipass): + +```bash +snapcraft pack +snapcraft upload --release=edge vocalinux_*.snap +``` + ## System Requirements | Requirement | Details | diff --git a/snap/gui/vocalinux.desktop b/snap/gui/vocalinux.desktop new file mode 100644 index 0000000..7263676 --- /dev/null +++ b/snap/gui/vocalinux.desktop @@ -0,0 +1,15 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Vocalinux +Comment=Free, offline voice dictation for Linux +GenericName=Voice Typing +Exec=vocalinux +Icon=${SNAP}/usr/share/icons/hicolor/scalable/apps/vocalinux.svg +Terminal=false +Categories=Utility;Accessibility;AudioVideo; +Keywords=voice;typing;dictation;speech;recognition;transcription;accessibility; +StartupNotify=true +StartupWMClass=com.vocalinux.Vocalinux +X-GNOME-UsesNotifications=true +X-SnapInstanceName=vocalinux diff --git a/snap/gui/vocalinux.png b/snap/gui/vocalinux.png new file mode 100644 index 0000000..44651a4 Binary files /dev/null and b/snap/gui/vocalinux.png differ diff --git a/snap/local/alsa/asound.conf b/snap/local/alsa/asound.conf new file mode 100644 index 0000000..8d6419b --- /dev/null +++ b/snap/local/alsa/asound.conf @@ -0,0 +1,16 @@ +# Route ALSA through the host PulseAudio/PipeWire-pulse server. +# PortAudio (pyaudio) opens the ALSA host API first; without this it either +# segfaults (missing /usr/share/alsa/alsa.conf) or finds zero devices. +pcm.!default { + type pulse + fallback "sysdefault" + hint { + show on + description "PulseAudio Sound Server" + } +} + +ctl.!default { + type pulse + fallback "sysdefault" +} diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..18d9422 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,129 @@ +# Vocalinux Snap packaging +# +# First ship: strict confinement, default whisper.cpp engine path, GTK tray UI, +# mic capture, model download, and X11/XWayland text injection (xdotool/xsel). +# Informed by issue #48 and packaging/flatpak lessons on feat/flatpak-packaging. +# +# Build (from repo root, with snapcraft + LXD or Multipass): +# snapcraft pack +# See docs/INSTALL.md for pack and store upload steps. + +name: vocalinux +base: core24 # Ubuntu 24.04 / Python 3.12 (project requires-python >=3.11) +# Fallback version; live version is set from src/vocalinux/version.py via +# adopt-info + craftctl set in the vocalinux part (required for craftctl). +version: "0.16.2" +adopt-info: vocalinux +summary: Free offline voice dictation for Linux +description: | + Vocalinux is free voice typing for Linux. Hold a shortcut or toggle dictation, and text lands in almost any app on X11 or Wayland. + + Speech recognition runs on your machine after you download a model. No Voca account. No cloud transcription on the default path. + + This snap ships the whisper.cpp engine plus VOSK. The optional OpenAI Whisper / PyTorch stack is not bundled (too large for the snap). + + Features: + - System tray control and GTK settings + - Offline dictation (models download on first use) + - Microphone capture and short feedback tones + - Text injection via xdotool/xsel (X11/XWayland) and IBus when available + + Sandbox notes: + - Global hotkeys need: sudo snap connect vocalinux:raw-input + - Microphone may need: sudo snap connect vocalinux:audio-record + - Config and models live under ~/snap/vocalinux/ + +grade: stable +confinement: strict +license: AGPL-3.0-only +icon: snap/gui/vocalinux.png +contact: https://github.com/VocaHQ/vocalinux/issues +issues: https://github.com/VocaHQ/vocalinux/issues +website: https://vocalinux.com +source-code: https://github.com/VocaHQ/vocalinux +donation: https://github.com/sponsors/jatinkrmalik + +apps: + vocalinux: + command: bin/vocalinux + extensions: [gnome] + plugs: + # gnome extension already grants desktop, desktop-legacy, gsettings, + # opengl, wayland, x11, unity7 (tray / indicators). + - audio-record # microphone (may require manual connect) + - audio-playback # start/stop feedback tones (PipeWire-pulse / PulseAudio) + - home # optional access outside snap HOME (shared files) + - network # download speech models on first run + - network-bind # optional remote_api engine + # Global hotkeys via python-evdev (/proc/bus/input + /dev/input/event*). + # Super-privileged: not auto-connected; user must: + # sudo snap connect vocalinux:raw-input + - raw-input + common-id: com.vocalinux.Vocalinux + desktop: usr/share/applications/vocalinux.desktop + environment: + # Prefer bundled CLI helpers (xdotool, xsel, xclip, wl-copy). + # Do NOT prepend $SNAP/usr/lib over the gnome extension platform path: + # that loads a second GLib/GTK and segfaults on `from gi.repository import Gtk`. + PATH: $SNAP/usr/bin:$SNAP/bin:$PATH + # PortAudio opens ALSA first. Point at snap-local config that routes to Pulse. + ALSA_CONFIG_PATH: $SNAP/etc/asound.conf + +# Make ALSA data/plugins visible at the paths libasound expects. +layout: + /usr/share/alsa: + bind: $SNAP/usr/share/alsa + /usr/lib/$CRAFT_ARCH_TRIPLET/alsa-lib: + bind: $SNAP/usr/lib/$CRAFT_ARCH_TRIPLET/alsa-lib + +parts: + vocalinux: + plugin: python + source: . + source-type: local + build-environment: + - PIP_DISABLE_PIP_VERSION_CHECK: "1" + build-packages: + - build-essential + - cmake + - git + - pkg-config + - portaudio19-dev + - libgirepository1.0-dev + - libcairo2-dev + - python3-dev + - libdbus-1-dev + stage-packages: + # Only extras not provided by the gnome-46-2404 platform content snap. + # Staging GTK/GLib/AppIndicator here duplicates the platform and can crash GI. + - libportaudio2 + - libasound2t64 # Ubuntu 24.04 / core24 name for libasound2 + - libasound2-data + - libasound2-plugins # pcm/ctl pulse → host PulseAudio/PipeWire + - pulseaudio-utils # paplay for start/stop UI sounds + - xdotool + - wtype # native Wayland text injection (preferred over xdotool) + - xsel + - xclip + - wl-clipboard + python-packages: + - pip + - setuptools + - wheel + override-pull: | + craftctl default + version="$(python3 -c "import pathlib, re; t = pathlib.Path('${CRAFT_PART_SRC}/src/vocalinux/version.py').read_text(); m = re.search(r'__version__\\s*=\\s*[\\'\"]([^\\'\"]+)', t); assert m, 'version not found'; print(m.group(1))")" + craftctl set version="$version" + # PyGObject/GI provided by gnome extension. + sed -i '/^[[:space:]]*"PyGObject/d' "${CRAFT_PART_SRC}/pyproject.toml" + override-build: | + craftctl default + install -Dm644 "${CRAFT_PROJECT_DIR}/snap/gui/vocalinux.desktop" \ + "${CRAFT_PART_INSTALL}/usr/share/applications/vocalinux.desktop" + install -Dm644 "${CRAFT_PROJECT_DIR}/resources/icons/scalable/vocalinux.svg" \ + "${CRAFT_PART_INSTALL}/usr/share/icons/hicolor/scalable/apps/vocalinux.svg" + # meta/gui is what snapd uses for the store/desktop icon when present. + install -Dm644 "${CRAFT_PROJECT_DIR}/resources/icons/scalable/vocalinux.svg" \ + "${CRAFT_PART_INSTALL}/meta/gui/icon.svg" + install -Dm644 "${CRAFT_PROJECT_DIR}/snap/local/alsa/asound.conf" \ + "${CRAFT_PART_INSTALL}/etc/asound.conf" diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 693b1b8..435e48c 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -12,7 +12,7 @@ import re import select import threading import time -from typing import Optional +from typing import Optional, TextIO # Try to import evdev try: @@ -119,38 +119,100 @@ def find_keyboard_devices() -> list[str]: """ Find all keyboard input devices. + Prefers /proc/bus/input/devices (host installs). Falls back to + ``evdev.list_devices()`` when that path cannot be read (common under snap + confinement even with raw-input, which grants /dev/input/event* but may + still deny /proc/bus/input/devices). + Returns: List of device paths for keyboard devices """ - keyboard_devices = [] - try: - # Read from /proc/bus/input/devices to find keyboards - with open("/proc/bus/input/devices", "r") as f: - current_device = None - for line in f: - line = line.rstrip("\n") - if line.startswith("I: Bus="): - current_device = {"handlers": []} - elif line.startswith("H: Handlers=") and current_device is not None: - handlers = line.split("=", 1)[1].strip() - current_device["handlers"] = handlers.split() - elif line.startswith("B: KEY=") and current_device is not None: - # Check if this device has keyboard keys (bit 0 is set) - key_bits = line.split("=", 1)[1].strip() - # The first hex digit after KEY= contains keyboard capability - # If it's not 0, 1, or ffffffffff, it has keyboard keys - if key_bits and key_bits != "0": - # Check if event handler exists - for handler in current_device.get("handlers", []): - if handler.startswith("event"): - device_path = f"/dev/input/{handler}" - if os.path.exists(device_path): - keyboard_devices.append(device_path) - current_device = None - + return _parse_keyboard_devices_from_proc( + open("/proc/bus/input/devices", "r", encoding="utf-8", errors="replace") + ) except (IOError, OSError) as e: - logger.error(f"Error reading input devices: {e}") + logger.debug(f"Cannot read /proc/bus/input/devices: {e}") + fallback = _find_keyboard_devices_from_evdev() + if fallback: + # Hot-plug rescans call this often; keep the noise down after first hit. + logger.debug( + "Using evdev.list_devices() fallback for keyboard discovery " + f"({len(fallback)} device(s))" + ) + return fallback + + +def _parse_keyboard_devices_from_proc(proc_file: TextIO) -> list[str]: + """Parse an open /proc/bus/input/devices stream for event KEY devices.""" + keyboard_devices: list[str] = [] + current_device = None + with proc_file as f: + for line in f: + line = line.rstrip("\n") + if line.startswith("I: Bus="): + current_device = {"handlers": []} + elif line.startswith("H: Handlers=") and current_device is not None: + handlers = line.split("=", 1)[1].strip() + current_device["handlers"] = handlers.split() + elif line.startswith("B: KEY=") and current_device is not None: + # Check if this device has keyboard keys (bit 0 is set) + key_bits = line.split("=", 1)[1].strip() + # The first hex digit after KEY= contains keyboard capability + # If it's not 0, 1, or ffffffffff, it has keyboard keys + if key_bits and key_bits != "0": + # Check if event handler exists + for handler in current_device.get("handlers", []): + if handler.startswith("event"): + device_path = f"/dev/input/{handler}" + if os.path.exists(device_path): + keyboard_devices.append(device_path) + current_device = None + return keyboard_devices + + +def _find_keyboard_devices_from_evdev() -> list[str]: + """Discover keyboards by opening devices from ``evdev.list_devices()``.""" + if not EVDEV_AVAILABLE: + return [] + + keyboard_devices: list[str] = [] + try: + paths = list(evdev.list_devices()) + except Exception as e: + logger.debug(f"evdev.list_devices() failed: {e}") + return [] + + for path in paths: + try: + device = InputDevice(path) + try: + capabilities = device.capabilities() + finally: + device.close() + except (OSError, IOError, TypeError, ValueError): + continue + + # Prefer devices that look like keyboards: EV_KEY with a common letter + # or a modifier. This includes combo "Keyboard" nodes on mice/remotes. + key_caps = capabilities.get(ecodes.EV_KEY, []) if capabilities else [] + if not key_caps: + continue + if any( + code in key_caps + for code in ( + KEY_LEFTCTRL, + KEY_RIGHTCTRL, + KEY_LEFTALT, + KEY_RIGHTALT, + KEY_LEFTSHIFT, + KEY_RIGHTSHIFT, + KEY_LEFTMETA, + KEY_RIGHTMETA, + getattr(ecodes, "KEY_A", -1), + ) + ): + keyboard_devices.append(path) return keyboard_devices @@ -326,22 +388,46 @@ class EvdevKeyboardBackend(KeyboardBackend): if not EVDEV_AVAILABLE: return "Install python-evdev: pip install evdev" + in_snap = bool(os.environ.get("SNAP_NAME") or os.environ.get("SNAP")) + try: devices = find_keyboard_devices() - if not devices: - return None # No devices found, not a permission issue + if devices: + # Try to open the first device to check permissions + for device_path in devices[:1]: + try: + InputDevice(device_path) + return None # Successfully opened, permissions OK + except (OSError, IOError) as e: + if "Permission denied" in str(e) or e.errno == errno.EACCES: + if in_snap: + return ( + "Snap is missing input-device access. " + "Connect once, then restart:\n" + "sudo snap connect vocalinux:raw-input" + ) + return ( + "Add your user to the 'input' group and log out/in:\n" + "sudo usermod -a -G input $USER" + ) + return None - # Try to open the first device to check permissions - for device_path in devices[:1]: # Just check the first one - try: - InputDevice(device_path) - return None # Successfully opened, permissions OK - except (OSError, IOError) as e: - if "Permission denied" in str(e) or e.errno == errno.EACCES: - return ( - "Add your user to the 'input' group and log out/in:\n" - "sudo usermod -a -G input $USER" - ) + # No devices found — snap without raw-input often cannot list anything. + if in_snap: + return ( + "Snap is missing input-device access. Connect once, then restart:\n" + "sudo snap connect vocalinux:raw-input" + ) + + try: + with open("/proc/bus/input/devices", "r"): + pass + except (OSError, IOError) as e: + if "Permission denied" in str(e) or getattr(e, "errno", None) == errno.EACCES: + return ( + "Add your user to the 'input' group and log out/in:\n" + "sudo usermod -a -G input $USER" + ) except Exception: pass diff --git a/src/vocalinux/ui/keyboard_shortcuts.py b/src/vocalinux/ui/keyboard_shortcuts.py index 5e71776..1f2a549 100644 --- a/src/vocalinux/ui/keyboard_shortcuts.py +++ b/src/vocalinux/ui/keyboard_shortcuts.py @@ -91,10 +91,22 @@ class KeyboardShortcutManager: def _log_unavailable_hints(self): """Log helpful hints when no backend is available.""" + import os + env = DesktopEnvironment.detect() + in_snap = bool(os.environ.get("SNAP_NAME") or os.environ.get("SNAP")) + + logger.warning("=" * 60) + if in_snap: + logger.warning("Keyboard shortcuts need Snap input access") + logger.warning("=" * 60) + logger.warning("Global hotkeys read /dev/input (evdev). Connect once:") + logger.warning(" sudo snap connect vocalinux:raw-input") + logger.warning("Then restart Vocalinux. Until then, use the system tray.") + logger.warning("=" * 60) + return if env == DesktopEnvironment.WAYLAND: - logger.warning("=" * 60) logger.warning("Keyboard shortcuts not available on Wayland") logger.warning("=" * 60) logger.warning("To enable keyboard shortcuts on Wayland:") diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index 5d09a83..a1e35d0 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -75,14 +75,33 @@ B: KEY=ff000000000000 0 0 0 def test_find_keyboard_devices_io_error(self): """Test error handling when /proc/bus/input/devices cannot be read.""" with patch("builtins.open", side_effect=IOError("Permission denied")): - result = find_keyboard_devices() - assert result == [] + with patch( + "vocalinux.ui.keyboard_backends.evdev_backend._find_keyboard_devices_from_evdev", + return_value=[], + ): + result = find_keyboard_devices() + assert result == [] def test_find_keyboard_devices_file_not_found(self): """Test error handling when /proc/bus/input/devices doesn't exist.""" with patch("builtins.open", side_effect=FileNotFoundError()): - result = find_keyboard_devices() - assert result == [] + with patch( + "vocalinux.ui.keyboard_backends.evdev_backend._find_keyboard_devices_from_evdev", + return_value=[], + ): + result = find_keyboard_devices() + assert result == [] + + def test_find_keyboard_devices_falls_back_to_evdev_list(self): + """When /proc is denied (e.g. snap), discover keyboards via list_devices.""" + with patch("builtins.open", side_effect=PermissionError(13, "Permission denied")): + with patch( + "vocalinux.ui.keyboard_backends.evdev_backend._find_keyboard_devices_from_evdev", + return_value=["/dev/input/event10"], + ) as fallback: + result = find_keyboard_devices() + assert result == ["/dev/input/event10"] + fallback.assert_called_once() def test_find_keyboard_devices_no_keyboard_capability(self): """Test filtering devices without keyboard capability.""" diff --git a/tests/test_snap_packaging.py b/tests/test_snap_packaging.py new file mode 100644 index 0000000..425cd8f --- /dev/null +++ b/tests/test_snap_packaging.py @@ -0,0 +1,60 @@ +"""Structural tests for Snap packaging (issue #48).""" + +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +SNAPCRAFT_YAML = REPO_ROOT / "snap" / "snapcraft.yaml" +DESKTOP_FILE = REPO_ROOT / "snap" / "gui" / "vocalinux.desktop" +SNAP_PNG = REPO_ROOT / "snap" / "gui" / "vocalinux.png" + + +def test_snapcraft_recipe_and_gui_assets() -> None: + assert SNAPCRAFT_YAML.is_file() + doc = yaml.safe_load(SNAPCRAFT_YAML.read_text(encoding="utf-8")) + assert doc["name"] == "vocalinux" + assert doc["base"] == "core24" + assert doc["summary"] == "Free offline voice dictation for Linux" + assert doc["license"] == "AGPL-3.0-only" + assert doc["icon"] == "snap/gui/vocalinux.png" + assert doc["website"] == "https://vocalinux.com" + assert doc["confinement"] == "strict" + assert doc["grade"] == "stable" + + plugs = set((doc.get("apps") or {}).get("vocalinux", {}).get("plugs") or []) + assert "raw-input" in plugs + assert "audio-record" in plugs + + assert DESKTOP_FILE.is_file() + assert SNAP_PNG.is_file() + assert SNAP_PNG.stat().st_size > 0 + + +def test_snap_strips_pygobject_and_uses_gnome_gi() -> None: + """Pip must not build PyGObject; GI comes from the gnome extension.""" + text = SNAPCRAFT_YAML.read_text(encoding="utf-8") + doc = yaml.safe_load(text) + override_pull = doc["parts"]["vocalinux"]["override-pull"] + assert "PyGObject" in override_pull + assert "pyproject.toml" in override_pull + assert "gnome extension" in override_pull + stage = doc["parts"]["vocalinux"].get("stage-packages") or [] + assert "python3-gi" not in stage + assert "python3-gi-cairo" not in stage + + +def test_release_publishes_snap_to_edge_and_candidate() -> None: + """v* tags must ship the snap to the store, not attach it to GitHub.""" + text = (REPO_ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + assert " publish-snap:\n" in text + job = text.split(" publish-snap:\n", 1)[1].split("\n deploy-website:", 1)[0] + assert "needs: build-and-release" in job + assert "snapcore/action-build@" in job + assert "snapcore/action-publish@" in job + assert "release: edge,candidate" in job + assert "release: stable" not in job + assert "SNAPCRAFT_STORE_CREDENTIALS is unset; cannot publish the snap" in job + assert "gh release upload" not in job + assert "action-gh-release" not in job + assert "upload-artifact" not in job