Skip to content

CI/CD, packaging, and releases

this project uses GitLab CI/CD to test, package, and release the app for Linux, Android, and Wear OS. a local wrapper, tool/build.sh, uses the same fastforge configuration as CI so local builds match pipeline builds.

GitLab CI/CD overview

the .gitlab-ci.yml pipeline has six stages:

  1. build-image — builds a custom Docker builder image from ci/Dockerfile.
  2. test — runs dart format, flutter gen-l10n, dart run build_runner build, flutter analyze, and flutter test --coverage.
  3. secret-detection — GitLab's Secret Detection template.
  4. package — builds Linux packages, the Android phone and F-Droid APKs, the Wear OS APK, and (best-effort) the Windows installer and macOS disk image.
  5. release — uploads artifacts to the GitLab Package Registry and creates a GitLab release from CHANGELOG.md.
  6. deploy — publishes the docs/ tree as the project website on GitLab Pages (see the website below).

the flutter job is the same gate you should run locally:

dart format --output=none --set-exit-if-changed .
flutter gen-l10n
dart run build_runner build
dart format .
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test

the pipeline also includes GitLab's SAST, Dependency-Scanning, and Secret-Detection security templates. 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 on-device integration suite

integration_test/ holds tests that need a real android runtime — the real SoLoud engine, audio_session, and genuine focus/lifecycle events. they run in the android-integration job: a KVM-accelerated emulator inside the privileged docker runner, driven by tool/run_integration_tests.sh. the script boots the kyb_ci avd (creating it from the android-36 system image baked into BUILDER_IMAGE), watches logcat for the tests' KYB_IT:* markers, and injects the matching os events — adb emu gsm call for an incoming call, power-key events for the screen.

the job is scoped like the packaging jobs: automatic on release tags (where linux-package and android-package name it in needs:, so it gates packaging) and a manual button on branch pipelines. run the same suite locally with tool/run_integration_tests.sh — it reuses a running emulator or physical device (-d <serial>), though markers that need the emulator console only inject on an emulator.

runners

the tool/build.sh script and CI jobs expect dart, flutter, fastforge, and the Linux packaging tools listed below to be available. CI uses a custom BUILDER_IMAGE built from ci/Dockerfile. most jobs require a runner tagged with docker and enough disk space for Flutter, dist/, and build/ outputs.

local builds

build configuration

local deploy settings (ADB device ids and the AppImage copy path) live in tool/build_config.env, which is gitignored. copy the example file and fill in your values:

cp tool/build_config.env.example tool/build_config.env
# edit tool/build_config.env

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.

usage

tool/build.sh linux                  # build all Linux packages
tool/build.sh --deploy linux         # build all Linux packages and install/copy them locally
tool/build.sh --deployonly linux     # install/copy existing Linux packages

tool/build.sh android              # build phone + F-Droid + Wear OS APKs
tool/build.sh android-phone        # build only the Android phone APK
tool/build.sh android-fdroid       # build only the F-Droid APK
tool/build.sh wear                 # build only the Wear OS APK
tool/build.sh all                  # build Linux and Android

tool/build.sh --deploy android     # build and deploy to configured devices
tool/build.sh --deployonly wear    # deploy the Wear OS APK without building
tool/build.sh --debuggable --deploy android  # debuggable build for adb run-as

use --debuggable when you need to inspect a release build's app data (e.g. the SQLite database) with adb shell run-as. it temporarily adds android:debuggable="true" to AndroidManifest.xml, builds and deploys the APK, and then removes the flag before the script exits. the installed APK remains debuggable until the next install.

for a development build that installs side-by-side with the release app on the same Android device, pass --dev:

tool/build.sh --dev --deploy android-phone

the flag writes android/dev-build.properties (gitignored) with applicationId (com.wesorick.john.knowthatyouarebreathing.dev), appLabel (know that you are breathing dev), and appIcon (the inverted ic_launcher_dev icons produced by tool/generate.dart app), which android/app/build.gradle.kts reads in defaultConfig — the label and icon reach the manifest through the appLabel/appIcon placeholders. a properties file is used instead of environment variables because a long-lived Gradle daemon keeps the environment it was started with and would not see freshly exported vars. prod builds delete the file; while it is present, even a plain flutter run/flutter build produces the dev variant. presetting BREATHING_APPLICATION_ID, BREATHING_APP_LABEL, or BREATHING_APP_ICON overrides the values the script writes. Android artifacts are renamed to know_that_you_are_breathing-dev-<channel>-<version>-android.apk so they cannot clobber or be mistaken for prod artifacts in dist/. --dev also implies --debuggable, so the dev APK can be inspected with adb run-as. --dev is valid only for android, android-phone, android-fdroid, wear, and all (where it applies to the Android half); it is a local-development flag — CI always builds without it. there is no --prod; omitting --dev builds the normal release.

supported platforms include linux, linux-appimage, linux-deb, linux-pacman, linux-zip, linux-rpm, linux-flatpak, linux-snap, android, android-phone, wear, and all. the linux platform builds .deb, .AppImage, .pacman, .zip, .rpm, .flatpak, and .snap packages; the linux-* sub-platforms build only that format.

the wrapper exports BREATHING_VERSION_CODE from git rev-list --count HEAD before invoking fastforge or flutter build, so local Android builds derive the same monotonically-increasing versionCode as CI (see releases and versioning below). without the export, Gradle's resolveVersionCode still computes the count as a fallback; exporting it pins the value up front, matches the CI path, and logs it. non-Android platforms ignore the variable — their build number comes from the pubspec +N, as in CI.

you can still run the underlying flutter build commands directly, but the wrapper keeps outputs in the same dist/ layout as CI and sets BREATHING_VERSION_CODE for you.

deploy behavior

when --deploy or --deployonly is used, tool/build.sh reads tool/build_config.env:

  • Android phone: android_phone_device_ids is a semicolon-separated list of ADB serials; the phone APK is installed to each with adb -s <id> install -r. the legacy single-serial android_phone_device_id is still honored if the plural form is not set.
  • Wear OS: wear_os_device_ids is a semicolon-separated list of ADB serials; the Wear OS APK is installed to each with adb -s <id> install -r. the legacy single-serial wear_os_device_id is still honored if the plural form is not set.
  • F-Droid: android_fdroid_device_ids is a semicolon-separated list of ADB serials for the F-Droid APK, with android_fdroid_device_id as a legacy fallback.
  • AppImage: if appimage_path is set, the .AppImage is copied there and made executable.
  • other Linux packages: the corresponding local package manager command is used (dpkg -i, rpm -i, pacman -U, flatpak install --user --bundle, snap install --dangerous). these may require sudo on some systems.
  • .zip archives have no deploy action; extract them manually.

you can skip any deploy step by setting the matching skip_* flag to true in tool/build_config.env: skip_android_phone, skip_android_fdroid, skip_wear, skip_appimage, skip_deb, skip_flatpak, skip_pacman, skip_rpm, skip_snap.

the Wear OS APK is built from the wear Android flavor with lib/main_wear.dart as the entry point. CI builds it as part of the android-package job via fastforge release --name android, which includes the android-wear-apk job defined in distribute_options.yaml.

building for Linux

know that you are breathing supports .deb, .rpm, .AppImage, .pacman, .zip, .flatpak, and .snap packages on x86_64. the desktop entry, icon, and AppStream metainfo used by the packages live in linux/packaging/.

prerequisites

  • Flutter and Linux desktop build dependencies are installed (gtk3, libblkid1, liblzma5, libsecret-1-0, plus the -dev variants on Debian/Ubuntu).
  • fastforge: dart pub global activate fastforge and add $HOME/.pub-cache/bin to PATH.
  • native packaging tools:
  • .deb: dpkg / dpkg-deb
  • .rpm: rpmbuild / rpm (Arch/SteamOS package rpm-tools)
  • .AppImage: appimagetool, patchelf. the GitLab CI job downloads the official appimagetool-x86_64.AppImage and installs a wrapper so it runs with APPIMAGE_EXTRACT_AND_RUN=1.
  • .pacman: bsdtar (Arch libarchive), xz
  • .flatpak: flatpak, flatpak-builder, and the runtime/sdk
  • .snap: snapcraft (destructive mode is used, so a snapcraft-compatible host is required)

for the .flatpak runtime:

flatpak install flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08

flatpak-builder mounts a rofiles-fuse filesystem inside its build directory, which fails with fusermount3: mount failed: Permission denied when the project path is outside the mount targets allowed by the host's AppArmor fusermount3 profile (openSUSE and recent Ubuntu allow only /home, /tmp, /mnt, /media, and /run/user). set flatpak_work_directory in tool/build_config.env (see tool/build_config.env.example) to relocate the build directory, ostree repo, and .flatpak-builder cache — e.g. /tmp/know-that-you-are-breathing-flatpak.

org.freedesktop.Platform does not ship every library the bundled plugins link against (libsecret for flutter_secure_storage, libssh2 for the bundled libgit2). flatpak/build-flatpak.sh mirrors what the AppImage maker does: it ldd-resolves each bundled .so, subtracts the libraries the runtime already provides plus the libflutter_linux_gtk.so dependency set, and stages the rest into build/linux/x64/release/bundle/flatpak-libs/, which the manifest installs to /app/lib. it also warns if a staged library requires a newer glibc than the runtime's, and after flatpak-builder finishes it re-runs ldd inside the sandbox to flag anything still unresolved.

local build

the wrapper builds every Linux format:

tool/build.sh linux

build only a specific format:

tool/build.sh linux-appimage
tool/build.sh linux-deb
tool/build.sh linux-flatpak
tool/build.sh linux-pacman
tool/build.sh linux-rpm
tool/build.sh linux-snap
tool/build.sh linux-zip

you can still run the underlying steps directly, which is useful when debugging a single packager:

flutter build linux --release
fastforge release --name linux
linux/packaging/rpm/build-rpm.sh
flatpak/build-flatpak.sh
snapcraft --destructive-mode

after a successful build, artifacts appear in dist/<pubspec-version>/:

  • know_that_you_are_breathing-<version>-linux.deb
  • know-that-you-are-breathing-<version>-<release>.x86_64.rpm
  • know_that_you_are_breathing-<version>-linux.AppImage
  • know_that_you_are_breathing-<version>-linux.pacman
  • know_that_you_are_breathing-<version>-linux.zip
  • know-that-you-are-breathing-<version>-linux.flatpak

the .snap file is written to the project root as know-that-you-are-breathing_<version>_amd64.snap.

SteamOS / Arch notes

dpkg, rpm, and patchelf are available in the Arch repositories. fastforge is installed with dart pub global activate fastforge.

rpmbuild 4.20+ on Arch changes the build-directory layout in a way that breaks fastforge's built-in RPM maker. the repository includes linux/packaging/rpm/build-rpm.sh, which generates a minimal spec and uses rpmbuild --build-in-place. it also uses a user-local RPM database at ~/.local/var/lib/rpm, so no root access is required.

building for Android

the Android build produces three APKs: a direct-install phone APK, a Wear OS APK, and an F-Droid phone APK (see the F-Droid flavor below). AAB builds for the Play Store are currently disabled.

local Android build

make sure fastforge is on your path, then run the wrapper:

dart pub global activate fastforge
tool/build.sh android              # phone APK + F-Droid APK + Wear OS APK
tool/build.sh android-phone        # phone APK only
tool/build.sh android-fdroid       # F-Droid APK only
tool/build.sh wear                 # Wear OS APK only

add --deploy to install on a configured device after building, or --deployonly to install an existing build:

tool/build.sh --deploy android

you can also build only the APK through fastforge directly:

fastforge package --platform android --targets apk

after a successful build, artifacts appear in dist/<pubspec-version>/:

  • know_that_you_are_breathing-phone-<version>-android.apk
  • know_that_you_are_breathing-fdroid-<version>-android.apk
  • know_that_you_are_breathing-wear-<version>-android.apk

--dev builds write know_that_you_are_breathing-dev-<channel>-<version>-android.apk instead.

the F-Droid flavor

the fdroid product flavor is identical to phone except that it builds without the proprietary Google Play services dependency used for Wear OS Data Layer sync (play-services-wearable and its kotlinx-coroutines-play-services Task bridge). F-Droid's inclusion policy rejects non-free dependencies, so the phone/watch sync stack cannot ship in that build.

the split is kept as small as possible:

  • android/app/src/gms/ holds the real DataLayerPlugin, DataLayerListenerService, and DataLayerCache plus a manifest declaring the listener service. the phone and phoneall flavors share src/gms/AndroidManifest.xml via manifest.srcFile; wear repeats the service block in src/wear/AndroidManifest.xml because a flavor has exactly one manifest file.
  • android/app/src/foss/ holds a no-op DataLayerPlugin that registers the same data_layer_sync method channel and reports "no peer connected", so MainActivity and the Dart DataLayerChannel/DataLayerSyncProvider need no build-specific handling — watch sync is simply never offered.
  • the GMS dependencies are declared per-flavor (phone/phoneall/wear Implementation configurations) in android/app/build.gradle.kts; the fdroid flavor has no Google Play services on its classpath and its manifest contains no com.google.android.gms entries.

build it directly with:

flutter build apk --release --flavor fdroid

generated Dart sources (*.g.dart, *.freezed.dart, *.drift.dart, lib/l10n/generated/) are committed, so an F-Droid build recipe only needs flutter pub get and the build command above. the versionCode resolves from git rev-list --count HEAD, which works in F-Droid's full-clone build environment.

store-listing metadata for F-Droid lives in metadata/en-US/ at the repo root (Fastlane layout: title.txt, short_description.txt, full_description.txt, images/). F-Droid reads it straight from the tagged release commit. changelogs/ is intentionally absent: changelog files are keyed by versionCode, which is the git commit count and not known ahead of time.

to request inclusion, open an issue in the F-Droid RFP tracker (gitlab.com/fdroid/rfp) once a tagged release exists.

Android signing

release builds can be signed with a Java keystore. the keystore itself is never committed; CI injects it from GitLab variables, and local builds without those variables fall back to the Android debug key.

generate a keystore once:

tool/generate_android_keystore.sh $HOME/release.keystore "YOUR_PASSWORD"

this prints a base64-encoded keystore and the matching CI variables. add these to your GitLab project under Settings > CI/CD > Variables:

  • ANDROID_KEYSTORE_FILE — base64 output from the script above
  • ANDROID_KEYSTORE_PASSWORD
  • ANDROID_KEY_PASSWORD
  • ANDROID_KEY_ALIAS — com.wesorick.john.knowthatyouarebreathing

no Play Store or other store publishing step is included.

releases and versioning

the app version has two parts that serve different audiences and must not be confused:

  • version name — the 0.0.4 in pubspec.yaml's version: 0.0.4+45. this is the human-readable string shown in the app and in package names (know_that_you_are_breathing-0.0.4+45-android.apk). it is the source of truth for Linux packages, Flatpak, Snap, and the GitLab release title.
  • Android versionCode — an opaque integer that Android uses only to decide upgrade vs. downgrade. a new APK whose versionCode is lower than the installed one is rejected with INSTALL_FAILED_VERSION_DOWNGRADE, regardless of the version name. this code must be monotonically increasing across every release.

the pubspec build number (the +N in 0.0.4+45) is not a reliable source for the Android versionCode. CI and local builds instead derive the versionCode from the total git commit count (git rev-list --count HEAD), which is strictly monotonic and unique per commit and survives rebuilds of the same tag. android/app/build.gradle.kts resolves it in priority order:

  1. BREATHING_VERSION_CODE environment variable — set by the android-package CI job to $(git rev-list --count HEAD) before invoking fastforge, and by tool/build.sh for local builds.
  2. git rev-list --count HEAD — computed by Gradle for local builds inside a git checkout.
  3. flutter.versionCode — the pubspec build number, as the final fallback for non-git source tarballs.

the android-package job sets GIT_DEPTH: "0" so the clone is full and the commit count is accurate; the default shallow clone would cap the count and reintroduce the bug.

rules for the pubspec build number going forward:

  • it is a last-resort fallback only. do not rely on it being monotonic across releases.
  • when bumping the pubspec version for a new release, +0 is fine — CI will override the versionCode. the build number does not need to track the commit count.
  • never reset it to a value lower than a previously released Android versionCode and rely on it as the code; the commit-count scheme makes this moot, but keep it in mind for non-git builds.

release tags and CHANGELOG.md headings use the bare version name without a build number (e.g. tag 0.0.5, heading ## 0.0.5). the extract-release-notes CI job does an exact string match between the tag and the changelog heading, so they must agree. the release job strips any +N from the tag before uploading packages (package_version="${version%%+*}"), so bare tags are the clean form. pubspec keeps a build number because Flutter requires one there, but it is a placeholder — it does not need to match the tag.

the release tag flow is unchanged: tag the commit, push the tag, CI builds and creates the GitLab release. the versionCode is whatever the commit count is at that tag.

installing with Obtainium

Obtainium can install and auto-update the Android APKs straight from the GitLab releases: the release job attaches each artifact as a release link pointing at the Generic Package Registry, which Obtainium follows. users add the app with the repository url https://gitlab.com/johnkneevsource/know-that-you-are-breathing.

each release carries three APKs, so users should set Obtainium's "filter APKs by regular expression" option to pick the right one:

  • phone/tablet: phone-.*\.apk
  • degoogled devices (no Play services): fdroid-.*\.apk
  • Wear OS: wear-.*\.apk

the phone and F-Droid builds share the same applicationId, so they cannot be installed side by side; switching between them requires an uninstall (data is not migrated). updates only work while the signing key stays the same, which holds for the CI-built APKs.

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.

the pages job runs automatically on release tags — so the published user guide describes the released app, matching how the in-app help is bundled at build time — 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'". the site logo (docs/assets/know_that_you_are_breathing_logo_200.png, shared as the favicon) is generated by tool/generate.dart logo but 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://ktyab.johnkneev.dev, a custom domain mapped under deploy > pages (CNAME ktyab -> 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.

one constraint when writing docs: docs/user-guide/*.md is bundled in-app and rendered by flutter_markdown, so it must stay plain commonmark — no mkdocs-specific syntax (!!! note, { ... } attribute lists, tab blocks). the other docs may use the enabled extensions sparingly, but keep gitlab's own markdown rendering readable too.

optional git hooks

.githooks/ holds a pre-commit and a pre-push hook that run the same gates as the flutter CI job, so a formatting slip or a red test surfaces locally instead of in a pipeline. they add no gates of their own: a clean local run means a clean flutter job.

git does not clone hooks, so they are opt-in per checkout and are not installed by default:

git config core.hooksPath .githooks   # enable
git config --unset core.hooksPath     # disable
hook runs roughly
pre-commit formats staged Dart files with dart format 3–4s
pre-push flutter test varies

the split keeps commits cheap and runs the expensive gate once per push, which is what CI reacts to anyway. both are bypassable with --no-verify for work in progress.

macOS and Windows

the windows-package and macos-package jobs build on GitLab-hosted SaaS runners (an Ultimate-tier feature on gitlab.com):

  • windows-package runs on saas-windows-medium-amd64 (Windows Server 2022, Visual Studio Build Tools with the C++ workload, Chocolatey, and CMake preinstalled). the job git-clones the pinned Flutter SDK, installs Inno Setup via choco, and fastforge release --name windows drives flutter build windows --release plus the exe maker configured by windows/packaging/exe/make_config.yaml (the app_id GUID there is a persisted contract — upgrades and uninstalls key on it, so never change it). the installer is unsigned, so first run shows a SmartScreen warning; msix targets stay out of scope until a real code-signing certificate exists because Windows refuses unsigned msix.
  • macos-package runs on saas-macos-medium-m1 (Apple Silicon). macOS jobs require an image: selecting the VM image — pinned to macos-26-xcode-26, a GA image carrying Xcode and CocoaPods; bump it deliberately because the runner default moves with each new GA image. the job clones Flutter (the image's fvm versions are below the pubspec floor) and installs appdmg from npm for fastforge's dmg maker, which reads macos/packaging/dmg/make_config.yaml. the app is ad-hoc signed (CODE_SIGN_IDENTITY = "-"), so downloads trip Gatekeeper — right-click

    open — until an Apple Developer account enables Developer ID notarization. the build is arm64-only; Intel macs are not covered.

both jobs are allow_failure: true on tags and manual on branches so an unproven platform cannot hold a release, and release lists them as optional needs so their dist/ artifacts upload to the package registry when they succeed. each job renames its artifact with an -alpha suffix (…-windows-alpha.exe, …-macos-alpha.dmg) so the untested marker lives in the filename itself, and release appends a matching platform-status note to the release notes — drop the rename steps and the note once the builds have had real testing. the builds are intentionally absent from README.md and docs/user-guide/ for the same reason.

desktop platform notes worth knowing before enabling them as required:

  • the app keeps the macOS sandbox, with network.client, user-selected file read-write, and device.audio-input entitlements (plus NSMicrophoneUsageDescription) so WebDAV sync, file pickers, and the record-backed custom-sound capture keep working.
  • sqlite3 3.x bundles a precompiled sqlite through Dart build hooks on every platform, so Windows (which has no system sqlite3) needs no extra packaging.
  • audio_session ships android/ios/macos only, so SoloudAudioService skips the platform session on linux and windows and just arms the engine keep-alive — sessions work, but there is no os-level audio focus handling there.