Skip to content

CI and packaging

GitLab CI/CD overview

.gitlab-ci.yml defines these stages:

  • build-image — builds and pushes the custom builder image (ci/Dockerfile, tagged by Flutter version) when the Dockerfile, this file, or a tag changes; manual otherwise.
  • test — sast template, plus the flutter job: format check → flutter gen-l10n → build_runner (root and each workspace package that needs it) → format → analyze → tool/build_web_client.sh (the browser client's compile doubles as the dart:io-leak guard — see Bundled browser client below) → flutter test --coverage → per-package tests. This mirrors tool/verify.sh plus the web build.
  • secret-detection — template job.
  • package — linux-package, android-package, and snap-package. They run on tags automatically; on branches they are manual. Artifacts land in dist/<version>/.
  • release — extract-release-notes pulls the tag's CHANGELOG.md section; release uploads dist/ artifacts to the GitLab package registry and creates a release with those notes.
  • deploy — pages renders docs/ into the project website with mkdocs build --strict and publishes it on GitLab Pages (see The website below).

Dependency scanning excludes the gemnasium-maven analyzer (DS_EXCLUDED_ANALYZERS in .gitlab-ci.yml): it detects android/*.gradle.kts and tries to resolve the Gradle project, which needs the Flutter and Android SDKs the analyzer image does not carry, so it can only fail. The app's real dependency surface is pubspec.lock, which the generic gemnasium analyzer still scans.

The builder image also makes ALSA's default PCM the built-in null plugin (/etc/asound.conf, plus libasound2t64 for the shared library miniaudio dlopens). Runners normally have no sound hardware and no audio daemon, and the real-engine tests under test/data/services/ need SoLoud.init() to find an openable playback device or the backend aborts. The null PCM is pure userspace — no card, /dev/snd node, or PipeWire — and the tests assert on voice counts, not audible output.

The workspace shape means codegen/tests iterate packages/*/ — keep that loop in sync with tool/verify.sh when changing either side.

Local builds

tool/build.sh is the entrypoint for builds that match CI:

tool/build.sh linux            # deb, AppImage, pacman, zip + rpm + flatpak + snap
tool/build.sh linux-deb        # one format only
tool/build.sh android          # arm64 APK into dist/<version>/
tool/build.sh --deploy android # build + adb install to configured devices
tool/build.sh --deployonly android  # deploy existing artifacts, no build
tool/build.sh --dev android    # side-by-side dev variant (.dev app id,
                               # inverted icon, "LibreBash Dev" label)
tool/build.sh --debuggable android  # release APK inspectable via run-as

Deploy targets (ADB serials, AppImage dir, per-format skips) live in tool/build_config.env — copy tool/build_config.env.example and fill it in. The file is gitignored so device IDs stay local.

The optional on_complete_command there is a shell command run when tool/build.sh exits after build or deploy work has begun — exits before that point (--help, bad arguments) never fire it. The command receives the outcome (success or failure) as $1 and the platform as $2, so a notification can say what finished; a failing command is reported but does not change the script's exit status.

Every build writes tool/build-timestamp.json (UTC ISO-8601) and passes it via --dart-define-from-file; lib/core/build_info.dart reads it and the about tile shows it. Builds that bypass build.sh simply hide the line.

fastforge is installed on demand (dart pub global activate fastforge). Linux packaging copies the tree to a temp dir automatically when the project path contains spaces, because the AppImage maker's ldd parsing breaks on space-separated paths.

Bundled browser client

Every packaged build carries assets/web_client/ — the Flutter web build of lib/web_client/main.dart that a room serves to browsers and embedded WebViews (ADR 0006). tool/build_web_client.sh is the shared staging step: flutter build web (--pwa-strategy=none, so no service worker can serve a stale bundle to a later room) then stages build/web/ into assets/web_client/ flattened — Flutter's asset bundler packs only the top-level files of a declared directory, so the nested canvaskit//icons//assets/ tree would silently 404 in the packaged app (the page boots white: main.dart.js loads but its renderer wasm can't). The script joins each path with __ and WebClientBundle.assetKeyFor maps requests back. It also drops build/web/assets/assets/web_client/ first — the web build bundles the app's declared assets, which includes the previously staged copy of itself. Like the piper payload it must land before the native build — asset packing happens inside flutter build/fastforge, so build.sh calls it at the top of each platform path and the CI package jobs call it explicitly before their native build steps. It picks up tool/build-timestamp.json when present so the bundle's build metadata matches its enclosing package; run it standalone after flutter clean or any web-client change.

A build without it still runs — the asset dir holds only a .gitkeep marker — but its rooms would 404 every browser join and embedded fallback, so never skip the step for anything someone might install.

Building for Linux

Formats: .deb, .rpm, .pacman, .AppImage, .zip, .flatpak, .snap.

  • fastforge builds deb/appimage/pacman/zip from distribute_options.yaml and the per-format linux/packaging/*/make_config.yaml.
  • .rpm uses linux/packaging/rpm/build-rpm.sh — fastforge's RPM maker is incompatible with rpm 4.20+.
  • .flatpak uses flatpak/build-flatpak.sh + flatpak/dev.johnkneev.librebash.yml against the release bundle. If the host's AppArmor fusermount3 profile restricts where unprivileged FUSE mounts may live (openSUSE, recent Ubuntu — allowed targets are /home, /tmp, /mnt, /media, /run/user), the build fails with fusermount3: mount failed: Permission denied; set flatpak_work_directory in tool/build_config.env (see the .example) to relocate the build dir, ostree repo, and .flatpak-builder cache — e.g. /tmp/librebash-flatpak. The script also ldd-resolves each bundled .so and stages the libraries org.freedesktop.Platform lacks (e.g. libsecret) into flatpak-libs/, which the manifest installs to /app/lib; it warns if a staged library needs a newer glibc than the runtime's and re-checks linkage inside the finished sandbox.
  • .snap uses snap/snapcraft.yaml (needs snapcraft).

The shared metadata lives in linux/packaging/librebash.desktop, dev.johnkneev.librebash.metainfo.xml, and linux/runner/resources/librebash.png (generated by dart tool/generate.dart app).

Speech engine per format

Linux read-aloud (ProcessSpeaker) prefers piper — the neural engine — falling back to spd-say, then espeak-ng/espeak on hosts that have them. Piper synthesizes but doesn't play, so a wav player (paplay, pw-play, aplay — probed in that order) completes the pipeline.

linux/packaging/bundle-piper.sh is the shared bundling step: it fetches the pinned piper release tarball plus the two multi-speaker English models (en_US-l2arctic-medium, en_US-libritts_r-medium), verifies sha256s, and primes linux/packaging/piper-assets/ with piper/ + piper-voices/. linux/CMakeLists.txt then installs that payload into the flutter bundle — beside the executable, exactly where ProcessSpeaker probes — on every build. That ordering is the only reliable one: every flutter build linux wipes the bundle dir (file(REMOVE_RECURSE …) in the generated install rules), and fastforge runs an internal rebuild while packaging, so injecting after our build always loses the payload. Priming before the build is what makes it stick for tool/build.sh, fastforge's rebuilds, rpm/flatpak/snap (reuse the bundle dir), and CI alike. Fetches are cached under ~/.cache/librebash/piper-bundle so repeat builds don't redownload ~185 MB. Missing downloads are fatal under CI=true (or --required) — a release never ships voiceless — while a local build without network warns and ships unbundled (piper on the host's PATH or models in the scanned dirs still get used).

Plain flutter build linux/flutter run installs the payload too — as long as piper-assets/ has been primed:

./linux/packaging/bundle-piper.sh   # once, or after flutter clean
flutter run                         # piper lands in the debug bundle

Passing a bundle dir (bundle-piper.sh <dir>) still does a direct copy for one-off injection into an already-built bundle.

Per format:

  • .deb — piper + voices ship inside the package (bundle dirs land under the app's install dir); Recommends: alsa-utils, pulseaudio-utils covers the player.
  • .rpm — same bundling; Recommends: alsa-utils, pulseaudio-utils.
  • .pacman — same bundling; optdepends: alsa-utils, pulseaudio-utils.
  • .snap — the bundle's piper/ + piper-voices/ are copied into $SNAP/bin (the app finds them beside the executable); pulseaudio-utils is staged for paplay, which reaches the sound server over the audio-playback plug.
  • .flatpak — the bundle dirs land in /app/bin; playback uses the runtime's pw-play (--filesystem=xdg-run/pipewire-0) with paplay over --socket=pulseaudio as the fallback.
  • .AppImage — the bundle dirs land in usr/bin/ inside the image — no repack step needed anymore. The player is the host's paplay/aplay.
  • .zip — the bundle dirs ride along; the player is the host's.

A system-installed piper on PATH (or $LIBREBASH_PIPER) and models in ~/.local/share/piper-voices, /usr/local/share/piper-voices, /usr/share/piper-voices, or $LIBREBASH_PIPER_VOICES are picked up too — user's own files shadow bundled ones.

mDNS per format

Room discovery (bonsoir) is native on Android/iOS/macOS/Windows, but on Linux it drives avahi-daemon over the system D-Bus (org.freedesktop.Avahi). If the daemon isn't running — or the package sandbox can't reach its bus name — both directions silently degrade to QR/manual joins (LobbyBeacon/LobbyBrowser return null). Per format:

  • .deb / .rpm / .pacman / .zip / .AppImage — unsandboxed; the host just needs avahi-daemon running (systemctl enable --now avahi-daemon) and UDP 5353 permitted inbound (firewalld's mdns service on distros that ship it). Advertising additionally needs publishing enabled — some distros ship /etc/avahi/avahi-daemon.conf with disable-publishing=yes, which produces exactly "browsing works, nobody sees my rooms". Set it to no and restart the daemon; avahi-publish-service "Probe" _librebash._tcp 1 is the quick probe — Not permitted means the config still rejects it. The other publish-*=no keys only gate automatic host records and static service files, so they're fine as shipped.
  • .flatpak — --system-talk-name=org.freedesktop.Avahi in finish-args reaches the host daemon.
  • .snap — avahi-control (advertising) and avahi-observe (browsing) plugs; on a --dangerous install they need a manual snap connect librebash:avahi-control / :avahi-observe.

Gamepad access per format

The gamepads plugin's Linux side reads /dev/input/event* — needed for hosting from a Linux PC on a TV (see ARCHITECTURE.md). No build deps; sandbox permissions only:

  • .flatpak — --device=input in finish-args. That subdevice needs Flatpak >= 1.15.6; older runtimes only offer the far broader --device=all, which we refuse. The builder image clears the floor by installing Flatpak from the ppa:flatpak/stable PPA — the Ubuntu 24.04 base only carries 1.14.x, which rejected the arg at flatpak-builder's finishing step.
  • .snap — the joystick plug.
  • Unsandboxed formats (deb/rpm/AppImage/zip) see /dev/input directly.

Launching through Steam

Adding the .AppImage to Steam as a non-Steam game fails silently — Play flicks to Cancel and back, no window. The captured environment shows why: Steam wraps the shortcut in the Steam Linux Runtime (container=pressure-vessel, STEAM_COMPAT_TOOL_PATHS=SteamLinuxRuntime_4, PATH=/usr/bin:/bin, LD_PRELOAD=gameoverlayrenderer.so), and inside pressure-vessel there is no usable fusermount — the AppImage runtime prints Cannot mount AppImage and exits before the app starts. Two escapes:

  • Uncheck Force the use of a specific Steam Play compatibility tool on the shortcut so it runs on the host env (host fusermount3 mounts normally); or
  • Keep the container and run the AppImage extract-and-run via a wrapper — APPIMAGE_EXTRACT_AND_RUN=1 exec ./librebash-linux.AppImage. Slower start (~200 MB extract) but it survives the sandbox.

A second failure mode is possible after that: the overlay preload can crash GL init on some drivers — unset LD_PRELOAD in the wrapper drops the overlay popup only (Steam Input is evdev-level, unaffected). The .zip bundle doesn't depend on FUSE at all, so it survives the same container without a wrapper. The user-facing version lives in USER_GUIDE.md.

Building for Android

tool/build.sh android produces dist/<version>/librebash-phone-<version>-android.apk. Release builds are signed with android/key.properties + android/app/release.keystore when present, falling back to debug signing (which keeps flutter run --release working). To set up signing:

  1. tool/generate_android_keystore.sh <output.keystore> <password>
  2. Locally: write android/key.properties (see what the script prints) and point it at the keystore.
  3. CI: set ANDROID_KEYSTORE_FILE (base64), ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD, ANDROID_KEY_ALIAS as masked CI variables — tool/prepare_android_signing.sh materializes them per job.

Keystore and key.properties are gitignored; never commit them.

Android TV

The same APK runs on Android TV / Google TV — the manifest declares it TV-aware rather than shipping a leanback build:

  • android.intent.category.LEANBACK_LAUNCHER on the launcher activity puts the app on the TV home screen next to its phone entry.
  • android:banner (the ${appBanner} placeholder → drawable-xhdpi/tv_banner.png, 320x180) is the artwork the TV home screen shows; tool/generate.dart app regenerates it from assets/brand/app_icon_source.png on the brand violet — no text — alongside a tv_banner_dev.png twin for --dev installs (wired through the appBanner manifest placeholder, same mechanism as appIcon).
  • Phone hardware features are declared required="false" — android.hardware.touchscreen, camera/camera.autofocus, wifi, location/location.gps, and android.software.leanback itself — so Play's feature filtering doesn't hide the listing from television devices. The companion code treats them as best-effort already: TvDetectionService answers isTelevision over the dev.johnkneev.librebash/tv_detection channel (UiModeManager/FEATURE_LEANBACK), and the QR join scanner simply never gets offered on a camera-less box.

Verify a build with aapt2 dump badging (or aapt) on the APK: expect leanback-launchable-activity and no uses-feature line with required='true' for touchscreen, camera, wifi, or location.

Permissions

The permission list is deliberately short — store listings and F-Droid review both audit it, so every entry carries its reason in a manifest comment, and anything the merger pulls in unused is stripped. Runtime prompts only ever appear for the camera (QR join scanner), nearby devices/location (starting or joining the host's hotspot), and notifications (the room-presence service, API 33+).

What the app declares, and why:

  • INTERNET — the host runs an embedded WebSocket server on the LAN/hotspot and clients connect to it (also contributed by bonsoir).
  • CAMERA — scanning the host's QR join ticket.
  • CHANGE_WIFI_MULTICAST_STATE — receiving mDNS broadcasts while browsing for lobbies (bonsoir acquires the multicast lock).
  • ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE, CHANGE_WIFI_STATE, CHANGE_NETWORK_STATE — the host-provides-the-network tier: startLocalOnlyHotspot plus the requestNetwork app-scoped join (getLinkProperties in the join callback is a fatal SecurityException without ACCESS_NETWORK_STATE).
  • NEARBY_WIFI_DEVICES (neverForLocation) — the API 33+ runtime grant for startLocalOnlyHotspot.
  • ACCESS_FINE_LOCATION (≤ API 32) — the same call's legacy runtime grant on older builds; nothing reads GPS.
  • FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC, POST_NOTIFICATIONS — the room-presence service below.
  • VIBRATE — the turn ping plays a real VibrationEffect pattern.

What the manifest explicitly strips, and why: the merger ORs every plugin's manifest into the APK, and camera_android_camerax (the camera implementation flutter_zxing pulls in) declares permissions for camera features the app never calls. Each gets a tools:node="remove" in android/app/src/main/AndroidManifest.xml:

  • RECORD_AUDIO — camera's video-recording path; the scanner runs enableAudio: false and it was never requested at runtime.
  • WRITE_EXTERNAL_STORAGE (≤ API 28) — camera's photo-capture save path; the scanner only streams frames, takePicture never runs.
  • READ_EXTERNAL_STORAGE — implied by WRITE in the merged result; removed explicitly so no library can reintroduce it. The scanner's gallery button goes through image_picker's system photo picker, which grants per-URI access and needs no storage permission.

(dev.johnkneev.librebash.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION in the merged output is androidx boilerplate — signature-level, guards unexported receivers, declared by the toolchain rather than us.)

To audit: aapt2 dump permissions on the APK lists the final grants, and build/app/outputs/logs/manifest-merger-*-report.txt records which library contributed every merged element.

Room presence foreground service

The app holds a dataSync foreground service while a room's SessionLink lives (see Room presence in ARCHITECTURE.md), so the manifest declares FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC, and POST_NOTIFICATIONS — the last is a runtime prompt the acquire path raises on API 33+. Two limits worth knowing: Android 15+ budgets dataSync services to roughly six hours per 24-hour window (a party session won't reach it, but a marathon room would silently lose its keep-alive), and Play Console may ask for a foreground-service-type declaration when this ships — dataSync covers "maintain an active LAN socket", which is the honest answer.

--dev writes android/dev-build.properties, which Gradle reads to swap the applicationId (.dev suffix), launcher label, icon (ic_launcher_dev, the inverted icon), and TV banner (tv_banner_dev, the inverted banner) so dev builds install side-by-side with release. It also implies --debuggable.

Releases and versioning

  • pubspec.yaml version: is the source of truth: x.y.z+build. Tags are the same value (1.0.0+2), and release notes come from the ## <tag> section of CHANGELOG.md.
  • Android versionCode is the git commit count, not the pubspec build number. resolveVersionCode() in android/app/build.gradle.kts prefers LIBREBASH_VERSION_CODE (CI exports git rev-list --count HEAD), then the local git rev-list --count HEAD, then flutter.versionCode. The commit count is monotonic, which is the invariant Play and INSTALL_FAILED_VERSION_DOWNGRADE care about.
  • Linux bundles carry the same commit count — but not through version.json. flutter_build_linux passes --build-number, yet fastforge's internal flutter build reruns without it and restamps version.json with pubspec's +0, so package_info_plus reports nothing useful inside packaged builds. Instead, build-timestamp.json carries the count as a BUILD_NUMBER dart-define (written by build.sh and the CI jobs), and buildNumberProvider prefers it over package_info_plus — the About dialog shows "Build N" exactly like Android, and multiplayer announcements report the same number on every platform. Comparing build numbers across devices is the quick check for a mixed-version room.
  • F-Droid readiness: there is one Android variant and it is already FOSS-clean — no Play Services or non-free deps. If a future feature needs a proprietary dependency, it must land behind a dedicated flavor like the sibling project's phone/fdroid split so a GMS-free build keeps shipping.

The website (GitLab Pages)

The project publishes a static website built from docs/ itself. mkdocs.yml configures mkdocs-material: docs/README.md is the homepage (it still reads as a normal readme when browsing the repo), each section's README.md is its index page, and every docs file is listed under nav: — mkdocs build --strict fails on a missing nav entry or a broken link, so add new docs files to nav when you create them.

Two parts of docs/ are deliberately not on the site: PROJECT_PLAN.md and adr/ are historical documents, excluded via exclude_docs in mkdocs.yml and browsed in the repo instead. Because excluded pages 404 on the site, links to them from published pages must use absolute GitLab URLs (https://gitlab.com/johnkneevsource/librebash/-/blob/main/docs/…), never relative .md paths.

The pages job runs automatically on release tags — so the published player guide describes the released app — and appears as a manual button on main pipelines for shipping doc fixes between releases. It builds public/ with mkdocs build --strict and uploads it as an artifact; gitlab.com deploys any public artifact produced by a job named pages. The job clears the squidfunk/mkdocs-material image's mkdocs entrypoint (entrypoint: [""]) — without that, the runner's sh -c wrapper is passed to mkdocs as arguments and the job fails with "No such command 'sh'" — and declares dependencies: [] since it builds purely from the checked-out docs tree and has no use for earlier stages' artifacts. The site logo (docs/assets/app_icon.png, shared as the favicon) is a copy of the generated app icon committed to the repo, so the job needs no generation step — which is just as well, since the image has no Dart SDK.

The site is served at https://librebash.johnkneev.dev, a custom domain mapped under Deploy > Pages (CNAME librebash -> johnkneevsource.gitlab.io plus GitLab's verification TXT record; the Let's Encrypt certificate is automatic). The default *.gitlab.io Pages domain keeps serving the same content alongside it.

To preview or check the site locally:

python3 -m venv .venv-mkdocs
.venv-mkdocs/bin/pip install mkdocs-material==9.7.7
.venv-mkdocs/bin/mkdocs build --strict   # or: .venv-mkdocs/bin/mkdocs serve

Keep the pip pin and the squidfunk/mkdocs-material image tag in the pages job on the same version.

Docs may use the enabled markdown extensions sparingly, but keep GitLab's own markdown rendering readable too — the same files are browsed in the repo.

Optional git hooks

./tool/install_git_hooks.sh sets core.hooksPath to .githooks/:

  • pre-commit formats staged Dart files (refuses partial-stage files it can't safely re-stage).
  • pre-push runs flutter test for the app and every packages/*/test/.

Both are local conveniences; CI is the real gate. Bypass once with --no-verify.

macOS and Windows

Platform folders exist from flutter create, and tool/generate.dart app fills their icon slots, but neither is a build target yet — packaging for them lands when there's a reason to ship there (the plan's initial targets are Android and Linux). iOS needs a macOS runner; Windows needs a Windows runner — both are sketched as commented jobs in the sibling project's CI if we ever want the pattern.