CI/CD
You don’t need to know the whole CI/CD pipeline to contribute to odio. The sections below cover it piece by piece, with the workflow files linked inline, so you can jump straight to the part you care about. The pipeline runs on GitHub Actions; from a v* tag on a component repo to a signed .deb on apt.odio.love, it’s short enough to trace end to end whenever you do want the full view.
CI on every pull request
Section titled “CI on every pull request”Nothing merges to main without the relevant checks running green. The details vary by language:
- Go repos (go-odio-api, go-odio-notify) run a matrix across Go 1.24 and 1.25:
go test -race, coverage gate (15% minimum, uploaded to Codecov on 1.25), golangci-lint v2.5.0,gofmt, and ago mod tidydrift check.go-odio-apialso compiles the embedded Tailwind CSS via Task before the test/lint steps, because the binary ships the UI as an embedded asset. - odio-pwa runs
npm teston Node 22. - odio-ha runs
flake8,mypy, andpytestacross Python 3.13 and 3.14. - mpDris2 and snapclientmpris share an identical Python pipeline:
ruff,mypy, andpyteston Python 3.11, driven throughMakefiletargets (make lint-ruff,make lint-mypy,make test) so local dev and CI invoke the same commands. Onv*tags both runmake check-tagto fail fast when the git tag and the package’s__init__.pydisagree, before the.debjob spins up. - odios runs a unified
checks.ymlworkflow combiningansible-lint,shellcheck(installer, test, image-builder, andscripts/), Pythonunittestagainsttests/(covering the upgrade helper andstate.jsonschema), and Python lint with ruff + mypy onodio_upgrade.py. It exposes aworkflow_calltrigger so the release pipeline gates publishing on the very same checks. - odio-docs builds the Astro site, runs lychee for broken links, and cspell on every
.md/.mdxfile.
The component release chain
Section titled “The component release chain”Six upstream repos feed .deb into apt.odio.love: the two Go components built in-house (go-odio-api, go-mpd-discplayer), the spotifyd fork, odio-mympd, and the two pure-Python rewrites mpDris2 and snapclientmpris. The flow is:
- A
v*tag gets pushed. - The repo’s
build.ymlproduces the.deb. Most components cross-compile for amd64, arm64, armv6, and armv7, packed via nfpm (Go components) ordpkg-buildpackageinside per-arch builder images (odio-mympd); mpDris2 and snapclientmpris are pure Python, so a singlearch:all.debis built once inside adebian:trixiecontainer (mpDris2 also publishes an sdist tarball alongside). go-odio-api also packs.rpm. - A GitHub Release is created with every
.debattached. - A
notify-apt-repojob fires arepository_dispatchevent of typerelease-publishedto odio-apt-repo, signed with a dedicatedAPT_REPO_TOKEN.
odio-apt-repo receives the dispatch and rebuilds from scratch:
gh release listresolves the latest stable and latest prerelease for the six tracked repos:go-odio-api,go-mpd-discplayer,spotifyd,mpDris2,odio-mympd,snapclientmpris. Manualworkflow_dispatchaccepts an explicit version override per repo.- Every
.debis downloaded fresh. repreproassembles two suites,stableandtesting, acrossamd64,arm64,armhf, andarmv7hf.- The tree is GPG-signed with
GPG_PRIVATE_KEY, written with aCNAMEforapt.odio.love, and deployed via GitHub Pages.
odio-mympd follows a build-only pattern: no source is vendored, just CI. A daily watch-upstream.yml cron polls jcorporation/myMPD at 06:17 UTC and pushes a matching tag here when a new upstream release shows up. Tagging triggers build.yml, which cross-compiles myMPD inside per-arch builder images (rebuilt monthly via build-images.yml) and publishes the .deb Release that the apt repo then ingests. The b0bbywan/spotifyd fork carries no source changes either and will likely migrate to the same pattern when the appetite shows up.
A weekly cron runs the same workflow every Monday at 04:00 UTC as a safety net, so a missed dispatch can never leave the repo stale for more than a week. The workflow can also be triggered manually with explicit version overrides.
odios releases
Section titled “odios releases”odios uses a different track: it ships the installer, not .deb packages. On a CalVer tag like 2026.4.1, release.yml runs a long pipeline that:
- Gates the
buildjob on the sharedchecks.ymlworkflow (ansible-lint, shellcheck, Python unit tests, plus a role-drift check that fails any PR whose touched files don’t match a role-version bump), so a red lint or test never produces a release. - Vendors
ansible-coreas pure Python, strips platform-specific bits, and packs anodio-<version>.tar.gzalongsideinstall.shand amanifest.json. - Runs the playbook on
ubuntu-latestwith an idempotence rerun, then a single-pass cross-arch sanity check on native ARM64 and on ARM/v7 + ARM/v6 under QEMU (the ARM rerun was dropped to keep matrix runtime in check; ARM/v6 stays marked experimental). - Runs
test.shininstallandinstall-rootmodes against the just-published pre-release, which exercises the fullcurl | bashinstaller path end to end. - Runs an upgrade matrix against pre-provisioned baselines covering every supported
state.jsongeneration, so each upgrade path is exercised before publication. The matrix walks three entry points: fetching the PR’sodio_upgrade.pyovercurl, running the baseline’s installed/usr/local/bin/odio-upgrade, and triggeringodio-upgrade.serviceviasystemctl --user. arm64 baselines come from the published SD images viascripts/img-to-docker.sh, also rebuilt on demand fromtest-baseline-image.yml. amd64 baselines are layered ontoDockerfile.testviascripts/build-baseline-amd64.shso the systemctl path runs under real systemd-logind without QEMU emulation. All baselines live atghcr.io/b0bbywan/odios/test-baseline:<tag>-<arch>. - Builds Raspberry Pi images (
odio-*.img.xz) forarmhfandarm64viaimage-builder/build.sh. - Publishes a combined Raspberry Pi Imager manifest (
odio.rpi-imager-manifest) as a release asset, so the images show up inline in the Imager UI. Pre-releases also publish per-arch manifests (odio.armhf.rpi-imager-manifest,odio.arm64.rpi-imager-manifest), so a PR can be tested in Imager as soon as one arch is built, without waiting for the other.
Pull requests to odios run the same matrix against a pr-<N> pre-release instead of the tag, which means the install path you’d take is tested before merge, not only before release.

All of it lands on a single GitHub Release. The URLs users hit, beta.odio.love/install, /manifest.json, /odio.rpi-imager-manifest, and odio.love/upgrade, are 307 redirects served by the odio.love Astro site on Vercel, pointing at github.com/b0bbywan/odios/releases/latest/download/<asset>. The URLs stay stable across releases, so curl -fsSL https://beta.odio.love/install | bash never needs updating. Pinning a specific version means calling the GitHub URL with the tag directly, as shown in Upgrade.
PWA and Home Assistant
Section titled “PWA and Home Assistant”odio-pwa releases on v* tags. release.yml zips the Astro dist/ as odio-pwa-<version>.zip, and publishes multi-arch Docker images (amd64 + arm64) to ghcr.io/b0bbywan/odio-pwa. Prereleases (tags containing -) are marked as such on GitHub; only stable releases move the :latest tag on the container registry.
odio-ha uses a two-step pattern: a semver-tagged push runs the CI workflow, and a separate workflow waits for CI to succeed via workflow_run before creating the release. Prerelease detection is based on the tag suffix (-alpha, -beta, -rc).
Secrets and trust
Section titled “Secrets and trust”The pipeline relies on two scoped secrets:
APT_REPO_TOKEN, a personal access token withreposcope onb0bbywan/odio-apt-repo. Held by component repos so they can fire cross-repo dispatches. Nothing else can trigger the apt-repo rebuild from outside.GPG_PRIVATE_KEY, held only byodio-apt-repo. Used byrepreproto sign theReleasefiles, soaptclients on every odio node can verify the signature chain before installing anything.
Everything else (creating GitHub Releases, downloading release assets in another repo, deploying to Pages) uses the default GITHUB_TOKEN scoped to each workflow run.
Manual verification on hardware
Section titled “Manual verification on hardware”On top of the automated matrix, two Raspberry Pis cover the manual checks, with split responsibilities:
- A Pi 3B+ (arm64), a dedicated test rig, validates each PR. The
pr-Npre-release is run both as an upgrade from the previous release to the PR version, and as a reflash from the PR’s image. - A Pi B+ (armv6) takes the post-publication upgrade path: every newly-tagged stable is applied to the previous one on this board, after the release ships. This is also the maintainer’s production node, where a full playbook run takes around two hours, so it picks up upgrades on a post-publication cadence rather than mid-cycle.
Transcripts of these sessions, Ansible PLAY RECAP outputs with failed=0, and the upgrade paths walked are kept publicly in the upgrade-system RFC. That discussion also captures many additional manual hardware runs done while the upgrade system was being built out, kept as design notes rather than a per-release routine.
Repository overview
Section titled “Repository overview”The ecosystem is twelve repositories, most connected to each other at build time (Go modules, .deb ingest into the apt repo, archives fetch at install time) or at runtime (client apps hitting the API over HTTP). Green solid edges are build, install, or deploy events; grey dashed edges are runtime. The hosting row at the bottom shows which platform serves each public endpoint: GitHub Pages for apt.odio.love, Vercel for everything else. Deploy arrows point from each hosted artifact down to its platform, and the apt install arrow goes from GitHub Pages back up to odios, since that’s where the .deb actually lands at install time.
| Repo | CI | Release output | Role in the ecosystem |
|---|---|---|---|
| go-odio-api | Go matrix, lint, coverage, CSS build | .deb + .rpm (4 arches), multi-arch GHCR image | Core REST API, installed on every node via apt install odio-api. Consumed at runtime by odio-pwa and odio-ha over HTTP |
| go-mpd-discplayer | Build matrix | .deb (4 arches) | CD / USB auto-play daemon, installed via apt install mpd-discplayer. Embeds go-disc-cuer as a Go module |
| go-disc-cuer | (no workflows) | Go library | CUE sheet + GnuDB / MusicBrainz lookup, compiled into go-mpd-discplayer |
| go-odio-notify | Go matrix, lint, coverage | Cross-arch binaries | Shared audio notification lib. Planned as Go module dependency for go-odio-api and go-mpd-discplayer |
| odios | ansible-lint, shellcheck, playbook + install + upgrade tests | Installer tarball, Pi images, Imager manifest | Orchestrator. Adds apt.odio.love as a source, installs odio-api, mpd-discplayer, spotifyd, mpdris2, mympd, snapclientmpris |
| odio-pwa | npm test | Zip archive, multi-arch GHCR images | Standalone client app, talks to go-odio-api over HTTP at runtime |
| odio-ha | flake8, mypy, pytest | GitHub Release, CI-gated | Home Assistant custom component, talks to go-odio-api over HTTP at runtime |
| mpDris2 | ruff, mypy, pytest, make check-tag on v* | .deb (arch:all), sdist tarball | MPRIS2 D-Bus bridge for MPD. Complete rewrite around python-mpd2 and dbus-fast, with as_directory-aware cover-art lookup for virtual CUE folders. Ingested into apt.odio.love |
| snapclientmpris | ruff, mypy, pytest, make check-tag on v* | .deb (arch:all) | MPRIS2 D-Bus bridge for the local Snapcast client. Forwards Play/Pause/Next/Previous to the snapserver source so multi-room pauses pause every listener. Ingested into apt.odio.love |
| odio-mympd | builder images, multi-arch QEMU build | .deb (multi-arch) | Build-only pipeline (no source vendored). Daily cron tracks upstream jcorporation/myMPD; tagging here ships the matching myMPD .deb to apt.odio.love |
| odio-apt-repo | receives dispatches, weekly cron | apt.odio.love via GitHub Pages | Ingests .deb from go-odio-api, go-mpd-discplayer, spotifyd, mpDris2, snapclientmpris, and odio-mympd |
| odio.love | auto-deployed by Vercel on push | Stable 307 redirects | Serves /install, /manifest.json, /odio.rpi-imager-manifest from the latest odios GitHub Release |
| odio-docs | Astro build, lychee, cspell | Deployed separately | This site |
Acknowledgments
Section titled “Acknowledgments”- The people whose work and thinking have inspired me through odio’s development.
- Laurent T., for the Pi 3B+ used for per-PR testing, which keeps that loop off my production node.
- @vascoguita, for raspios-docker, the per-arch Raspberry Pi OS Docker images that every ARM
.deband the upgrade test images are built on top of. Specifically:- go-mpd-discplayer’s Dockerfile cross-builds the CGO
.debfor arm64/armhf insidevascoguita/raspios:arm64andvascoguita/raspios:armhfunder QEMU. - spotifyd’s Dockerfile.builder uses the same bases for the
spotifyd-builderimage published to GHCR. - odio-mympd’s per-arch builders are rebuilt monthly on top of the same images.
- odios’s test image targets the same bases so the upgrade matrix runs against a Pi-faithful environment, and
scripts/img-to-docker.shderives the upgrade-test baselines following the same approach.
- go-mpd-discplayer’s Dockerfile cross-builds the CGO