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:
build-image— builds a custom Docker builder image fromci/Dockerfile.test— runsdart format,flutter gen-l10n,dart run build_runner build,flutter analyze, andflutter test --coverage.secret-detection— GitLab's Secret Detection template.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.release— uploads artifacts to the GitLab Package Registry and creates a GitLab release fromCHANGELOG.md.deploy— publishes thedocs/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_idsis a semicolon-separated list of ADB serials; the phone APK is installed to each withadb -s <id> install -r. the legacy single-serialandroid_phone_device_idis still honored if the plural form is not set. - Wear OS:
wear_os_device_idsis a semicolon-separated list of ADB serials; the Wear OS APK is installed to each withadb -s <id> install -r. the legacy single-serialwear_os_device_idis still honored if the plural form is not set. - F-Droid:
android_fdroid_device_idsis a semicolon-separated list of ADB serials for the F-Droid APK, withandroid_fdroid_device_idas a legacy fallback. - AppImage: if
appimage_pathis set, the.AppImageis 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 requiresudoon some systems. .ziparchives 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-devvariants on Debian/Ubuntu). - fastforge:
dart pub global activate fastforgeand add$HOME/.pub-cache/bintoPATH. - native packaging tools:
.deb:dpkg/dpkg-deb.rpm:rpmbuild/rpm(Arch/SteamOS packagerpm-tools).AppImage:appimagetool,patchelf. the GitLab CI job downloads the officialappimagetool-x86_64.AppImageand installs a wrapper so it runs withAPPIMAGE_EXTRACT_AND_RUN=1..pacman:bsdtar(Archlibarchive),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.debknow-that-you-are-breathing-<version>-<release>.x86_64.rpmknow_that_you_are_breathing-<version>-linux.AppImageknow_that_you_are_breathing-<version>-linux.pacmanknow_that_you_are_breathing-<version>-linux.zipknow-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.apkknow_that_you_are_breathing-fdroid-<version>-android.apkknow_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 realDataLayerPlugin,DataLayerListenerService, andDataLayerCacheplus a manifest declaring the listener service. thephoneandphoneallflavors sharesrc/gms/AndroidManifest.xmlviamanifest.srcFile;wearrepeats the service block insrc/wear/AndroidManifest.xmlbecause a flavor has exactly one manifest file.android/app/src/foss/holds a no-opDataLayerPluginthat registers the samedata_layer_syncmethod channel and reports "no peer connected", soMainActivityand the DartDataLayerChannel/DataLayerSyncProviderneed no build-specific handling — watch sync is simply never offered.- the GMS dependencies are declared per-flavor (
phone/phoneall/wearImplementationconfigurations) inandroid/app/build.gradle.kts; thefdroidflavor has no Google Play services on its classpath and its manifest contains nocom.google.android.gmsentries.
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 aboveANDROID_KEYSTORE_PASSWORDANDROID_KEY_PASSWORDANDROID_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.4inpubspec.yaml'sversion: 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
versionCodeis lower than the installed one is rejected withINSTALL_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:
BREATHING_VERSION_CODEenvironment variable — set by theandroid-packageCI job to$(git rev-list --count HEAD)before invoking fastforge, and bytool/build.shfor local builds.git rev-list --count HEAD— computed by Gradle for local builds inside a git checkout.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,
+0is fine — CI will override theversionCode. the build number does not need to track the commit count. - never reset it to a value lower than a previously released Android
versionCodeand 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-packageruns onsaas-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 viachoco, andfastforge release --name windowsdrivesflutter build windows --releaseplus theexemaker configured bywindows/packaging/exe/make_config.yaml(theapp_idGUID 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-packageruns onsaas-macos-medium-m1(Apple Silicon). macOS jobs require animage:selecting the VM image — pinned tomacos-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 installsappdmgfrom npm for fastforge'sdmgmaker, which readsmacos/packaging/dmg/make_config.yaml. the app is ad-hoc signed (CODE_SIGN_IDENTITY = "-"), so downloads trip Gatekeeper — right-clickopen — 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, anddevice.audio-inputentitlements (plusNSMicrophoneUsageDescription) so WebDAV sync, file pickers, and therecord-backed custom-sound capture keep working. sqlite33.x bundles a precompiled sqlite through Dart build hooks on every platform, so Windows (which has no system sqlite3) needs no extra packaging.audio_sessionships android/ios/macos only, soSoloudAudioServiceskips 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.