Development
How the repo is built, tested and released, and why the build config looks the way it does. CONTRIBUTING.md is the short version.
Two environments
Section titled “Two environments”uv sync --extra export # the real one: OpenVINO, the export tooling, Python <3.14
uv venvuv pip install --group dev # tests and lint only, ~5 seconds, what CI usesThe dev group is installable on its own on purpose. CI has no NPU, no audio
server and no reason to download ~400MB of OpenVINO wheels to run tests that
are not allowed to import it. Nothing under tests/ imports openvino,
transcriber or server, which is what keeps the split honest.
requests is in the dev group even though no test talks to the server:
vinowhisper.caption imports it at module scope through client.py, so
tests/test_caption.py cannot be collected without it. It is ~100KB of pure
Python.
Pytest imports the package from the source tree (pythonpath = ["."]) rather
than from an install, because installing the project would pull in the
OpenVINO stack the dev group exists to avoid. For the same reason
transcriber.py, server.py and replay.py are left out of coverage: they
need an NPU, an audio server or a live socket.
Checks
Section titled “Checks”uv run poe check # ruff check, ruff format --check, mypy, pytestuv run poe fix # ruff --fix and ruff formatuv run poe security # the same Bandit scan the Bandit workflow runsmypy is not pinned to python_version = "3.11". numpy’s stubs use
3.12-only syntax (type X = ...), and mypy parses them against the target
version, so pinning 3.11 while running on 3.12 fails inside numpy before it
checks any code here. The 3.11 floor is enforced by requires-python and
ruff’s target-version instead. openvino_genai ships no stubs, and CI runs
without it installed, hence ignore_missing_imports.
Bandit skips three checks, each because the finding is this tool’s job rather than a defect in it:
| Check | Why it is skipped |
|---|---|
| B404 | Flags import subprocess itself, in a program whose purpose is to drive pw-record, parec, pactl, optimum-cli and systemctl |
| B603 | Every call site passes a literal argv list with no shell. The variable parts (PipeWire node names from pw-dump, a --target the user typed) arrive as single argv elements |
| B607 | pw-record, parec and pactl live in different places on different distros. They are found with shutil.which before use, and hardcoding /usr/bin would break the fallbacks |
B602 (shell=True) stays on. There is no shell anywhere in this codebase, and
if one appears it should fail the scan.
Packaging
Section titled “Packaging”[tool.setuptools] packages = ["vinowhisper"] is explicit because flat-layout
auto-discovery sees the top-level systemd/ directory, decides there are two
packages, and refuses to build (confirmed 2026-08-03).
These data files ride in the wheel:
model_digests.json:integrity.load_pins()treats a missing file as “nothing pinned” rather than crashing, so dropping it frompackage-datawould degrade silently.tests/test_integrity.pychecks it is declared and readable.model_sources.json: the Hugging Face commit and per-file sha256 the export is made from. Missing, the wizard refuses to export rather than fetch unchecked.tests/test_source.pychecks it is declared. Hand-edited, from the Hub API’s LFS sha256 and the files’ own bytes, never from a guess.tests/test_integrity.pyalso fails whenuv.lock’s export toolchain moves away from the NPU pin, so drift shows up in CI rather than on install.gui_release.json: exists only in a release build.release.ymlwrites it (the sha256 of the overlay binary on the GitHub Release) withscripts/pin_gui_release.pyjust beforeuv build, then checks the wheel carries it. A checkout has none, andoverlay.availability()then refuses to download, which is the point.
openvino-tokenizers is listed directly even though openvino-genai pulls it
in. The NPU pipeline hard-depends on it, and a transitive-only dependency is
one upstream packaging change from disappearing.
The overlay’s dependencies
Section titled “The overlay’s dependencies”Every crate in gui/Cargo.toml is pure Rust, so the binary links nothing but
libc and installs as one file on any distro (see gui.md).
| Crate | Does |
|---|---|
| smithay-client-toolkit | Wayland: layer-shell, shm buffers, the event loop. Pure-Rust backend, so no libwayland, and no xkbcommon since nothing reads the keyboard |
| cosmic-text | Shaping, wrapping and glyph rasterising. Finds fonts by parsing fontconfig’s files, not by linking libfontconfig |
| ksni | The tray icon (StatusNotifierItem over D-Bus) |
| ashpd | Global shortcuts, and Shift+Insert for dictation (xdg-desktop-portal) |
| rustix | Signals, the child’s parent-death signal, the memfd that carries a keymap to the compositor, and the tray’s idle timerfd |
rust-version = "1.88" is for let-chains.
Shell completion
Section titled “Shell completion”vinowhisper-setup installs scripts/vinowhisper-completion.bash. By hand,
with no root:
mkdir -p ~/.local/share/bash-completion/completionsln -sf "$PWD/scripts/vinowhisper-completion.bash" \ ~/.local/share/bash-completion/completions/vinowhisper-captionfor c in server replay doctor setup; do ln -sf vinowhisper-caption ~/.local/share/bash-completion/completions/vinowhisper-$cdonebash-completion loads a file lazily on the first Tab for a command of the same
name, which is why it is one symlink per command rather than a line in
.bashrc. It completes vinowhisper-caption ..., not uv run vinowhisper-caption ...: there the command word is uv, and uv’s own
completion owns the line.
--target completion runs vinowhisper-caption --list-targets off PATH, so
the pw-dump parse stays in one place. COMP_WORDS[0] is not used because it
can be a relative path that no longer resolves. It costs ~0.2s of Python
startup per Tab, only on --target.
Conventions
Section titled “Conventions”- Comments are minimal. No module, class or function docstrings in
vinowhisper/, no//!or item docs ingui/src/. A comment stays only where the code would invite a wrong “fix” without it, and then it is one line. Rationale, measurements and gotchas go in the matchingdocs/page.tests/is exempt. - Measured claims carry a date. “Measured 2026-08-07: the sink monitor reads 0.98x of the app’s level while muted” is worth keeping. “The monitor is pre-mute” on its own is how the project spent a week believing the opposite of the truth.
- A failure names its fix. Every error path tries to print the command
that resolves it, in the local distro’s package names
(
distro.remediation). - Tests are for the logic, not the hardware. Anything under
tests/runs with no NPU, no audio server and no OpenVINO. If a change can only be verified on the laptop, say so in the PR rather than faking a test. test_characterization_*pins a known oddity on purpose.--targetrefused on PulseAudio rather than reinterpreted, the sink monitor being pre-volume and pre-mute,Live.update()needingrefresh=True. These are correct and look like bugs cold. If one goes red, the question is whether the behaviour was supposed to change, not which assertion to update.
| Workflow | Runs | What it is for |
|---|---|---|
ci.yml |
every push and PR | ruff, mypy, pytest on 3.11 to 3.13, shellcheck, and the overlay’s cargo checks |
bandit.yml |
pushes and PRs to main, and Mondays | The Bandit scan, into Security > Code scanning. Config is [tool.bandit], so poe security matches it |
deps-canary.yml |
Mondays, and on changes to pyproject.toml or uv.lock |
The full dependency set, which nothing on the PR path installs |
release.yml |
a v* tag |
Build, PyPI, GitHub Release |
3.14 is left out of the test matrix on purpose, for the reason in
install.md. shellcheck runs on the installer and the export
script because they are the first thing a new user runs. The overlay’s tests
need no compositor, tray or portal: they draw into memory, bind scratch
sockets and write into temp dirs, the same bargain tests/ makes about the
NPU.
The dependency canary exists because PR CI installs only the dev group, so nothing there would notice the real set breaking. A failure is usually optimum or transformers moving, a new OpenVINO stable breaking the import or plugin load, or a yanked transitive release. It has three steps, kept separate so the failures stay distinguishable:
uv sync --extra export, not--frozen, since resolving from scratch is part of the test, then import OpenVINO and enumerate devices (no inference, the runner has no NPU).optimum-cli env. A resolve can succeed on a toolchain that cannot export: satisfying a raised transformers cap by backtracking optimum to a pre-5.x release still resolves, andoptimum-clithen dies on import. Dependabot #24 did exactly that and went green on step 1.envis the cheapest subcommand that imports the whole command tree.uv lock --upgrade --dry-run: “the lock no longer installs” and “nothing newer resolves either” are different problems.
Before 2026-08-31 it had a sharper job: the project pinned OpenVINO nightly wheels, which upstream prunes on its own schedule.
Commits and releases
Section titled “Commits and releases”Conventional commits (feat:, fix:, docs:, ci:, …). CHANGELOG.md is
generated from them with git-cliff, so the prefix decides the section.
uv run bump-my-version bump minor # writes the version, commits, tags vX.Y.Zgit cliff -c cliff.toml --tag vX.Y.Z -o CHANGELOG.md # then hand-editgit push --follow-tagsgit cliff --unreleased previews what is pending. The version lives in
pyproject.toml, gui/Cargo.toml and gui/Cargo.lock, and bump-my-version
moves all three. They cannot differ: the overlay is downloaded from the
release tagged with the Python package’s version, and
tests/test_packaging.py fails if they drift.
This documentation is published at docs.vinowhisper.com, built by Starlight
from site/. docs/ stays the source: site/scripts/sync-docs.mjs copies it
into a gitignored directory, adds the title from each file’s H1 and rewrites
relative links, so a page needs nothing site-specific. cd site && npm ci && npm run dev previews it, and the site CI job runs the build.
Pushing the tag runs release.yml:
- gui builds the overlay once for
x86_64-unknown-linux-musl, so the one binary is fully static (static-pie, 6.0MB, measured 2026-09-12). It never goes to PyPI. - build checks the tag matches
pyproject.toml, pins the overlay’s sha256 into the package, builds the sdist and wheel, checks the wheel carries the pin, runstwine check --strict(a rejected PyPI upload cannot be retried under the same version), and cuts the release notes out ofCHANGELOG.md. The changelog is the single source of release prose because it is hand-edited, and re-deriving notes from commits would drop those edits. - pypi publishes the same bytes with Trusted Publishing.
- github-release attaches the wheel, sdist, overlay binary and
SHA256SUMS.
The split is about permissions. build holds no token, pypi holds only
id-token: write, and only github-release can write to the repo. PyPI goes
before the GitHub Release on purpose: a PyPI version can never be re-uploaded,
so if that step fails the tag can be deleted and re-cut, which a published
release pointing at a missing PyPI version would not allow.
Trusted Publishing means no API token exists anywhere. GitHub mints a
short-lived OIDC token that PyPI trusts because the project’s publisher
config names this repo, release.yml and the pypi environment. Renaming
either breaks the upload by design, so change the publisher config on PyPI
first. The pypi environment is also where a required reviewer would go.
Only a pushed v* tag publishes. There is no manual trigger (it had never
been used, and a run from a branch could not pass the tag check anyway), and
pypi and github-release also check github.ref, so adding a trigger later
cannot publish from a branch by accident. gh release create --verify-tag
fails if the tag is missing rather than creating one at the default branch’s
HEAD.
The tools the release and the Bandit scan run are pinned to exact versions:
twine, bandit (in bandit.yml and poe security alike) and the Rust
toolchain that builds the overlay. Nothing bumps them automatically; change
them in a reviewed commit.
Renovate auto-merges only the dev dependency group, and only after a
release has been public for seven days. Cargo crates and Actions always wait
for review, since crates ship inside the overlay binary and Actions run with
the release’s tokens. Runtime Python dependencies are not touched by Renovate
at all.
github-release has no checkout, so gh is told the repo through GH_REPO.
Without it gh exits with “not a git repository”, which is how v0.3.0 had to
be released by hand after its PyPI upload had already gone through.