Skip to content

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.

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 a go mod tidy drift check. go-odio-api also 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 test on Node 22.
  • odio-ha runs flake8, mypy, and pytest across Python 3.13 and 3.14.
  • mpDris2 and snapclientmpris share an identical Python pipeline: ruff, mypy, and pytest on Python 3.11, driven through Makefile targets (make lint-ruff, make lint-mypy, make test) so local dev and CI invoke the same commands. On v* tags both run make check-tag to fail fast when the git tag and the package’s __init__.py disagree, before the .deb job spins up.
  • odios runs a unified checks.yml workflow combining ansible-lint, shellcheck (installer, test, image-builder, and scripts/), Python unittest against tests/ (covering the upgrade helper and state.json schema), and Python lint with ruff + mypy on odio_upgrade.py. It exposes a workflow_call trigger 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 / .mdx file.

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:

  1. A v* tag gets pushed.
  2. The repo’s build.yml produces the .deb. Most components cross-compile for amd64, arm64, armv6, and armv7, packed via nfpm (Go components) or dpkg-buildpackage inside per-arch builder images (odio-mympd); mpDris2 and snapclientmpris are pure Python, so a single arch:all .deb is built once inside a debian:trixie container (mpDris2 also publishes an sdist tarball alongside). go-odio-api also packs .rpm.
  3. A GitHub Release is created with every .deb attached.
  4. A notify-apt-repo job fires a repository_dispatch event of type release-published to odio-apt-repo, signed with a dedicated APT_REPO_TOKEN.

odio-apt-repo receives the dispatch and rebuilds from scratch:

  • gh release list resolves the latest stable and latest prerelease for the six tracked repos: go-odio-api, go-mpd-discplayer, spotifyd, mpDris2, odio-mympd, snapclientmpris. Manual workflow_dispatch accepts an explicit version override per repo.
  • Every .deb is downloaded fresh.
  • reprepro assembles two suites, stable and testing, across amd64, arm64, armhf, and armv7hf.
  • The tree is GPG-signed with GPG_PRIVATE_KEY, written with a CNAME for apt.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 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 build job on the shared checks.yml workflow (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-core as pure Python, strips platform-specific bits, and packs an odio-<version>.tar.gz alongside install.sh and a manifest.json.
  • Runs the playbook on ubuntu-latest with 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.sh in install and install-root modes against the just-published pre-release, which exercises the full curl | bash installer path end to end.
  • Runs an upgrade matrix against pre-provisioned baselines covering every supported state.json generation, so each upgrade path is exercised before publication. The matrix walks three entry points: fetching the PR’s odio_upgrade.py over curl, running the baseline’s installed /usr/local/bin/odio-upgrade, and triggering odio-upgrade.service via systemctl --user. arm64 baselines come from the published SD images via scripts/img-to-docker.sh, also rebuilt on demand from test-baseline-image.yml. amd64 baselines are layered onto Dockerfile.test via scripts/build-baseline-amd64.sh so the systemctl path runs under real systemd-logind without QEMU emulation. All baselines live at ghcr.io/b0bbywan/odios/test-baseline:<tag>-<arch>.
  • Builds Raspberry Pi images (odio-*.img.xz) for armhf and arm64 via image-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.

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).

The pipeline relies on two scoped secrets:

  • APT_REPO_TOKEN, a personal access token with repo scope on b0bbywan/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 by odio-apt-repo. Used by reprepro to sign the Release files, so apt clients 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.

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-N pre-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.

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.

RepoCIRelease outputRole in the ecosystem
go-odio-apiGo matrix, lint, coverage, CSS build.deb + .rpm (4 arches), multi-arch GHCR imageCore REST API, installed on every node via apt install odio-api. Consumed at runtime by odio-pwa and odio-ha over HTTP
go-mpd-discplayerBuild 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 libraryCUE sheet + GnuDB / MusicBrainz lookup, compiled into go-mpd-discplayer
go-odio-notifyGo matrix, lint, coverageCross-arch binariesShared audio notification lib. Planned as Go module dependency for go-odio-api and go-mpd-discplayer
odiosansible-lint, shellcheck, playbook + install + upgrade testsInstaller tarball, Pi images, Imager manifestOrchestrator. Adds apt.odio.love as a source, installs odio-api, mpd-discplayer, spotifyd, mpdris2, mympd, snapclientmpris
odio-pwanpm testZip archive, multi-arch GHCR imagesStandalone client app, talks to go-odio-api over HTTP at runtime
odio-haflake8, mypy, pytestGitHub Release, CI-gatedHome Assistant custom component, talks to go-odio-api over HTTP at runtime
mpDris2ruff, mypy, pytest, make check-tag on v*.deb (arch:all), sdist tarballMPRIS2 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
snapclientmprisruff, 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-mympdbuilder 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-reporeceives dispatches, weekly cronapt.odio.love via GitHub PagesIngests .deb from go-odio-api, go-mpd-discplayer, spotifyd, mpDris2, snapclientmpris, and odio-mympd
odio.loveauto-deployed by Vercel on pushStable 307 redirectsServes /install, /manifest.json, /odio.rpi-imager-manifest from the latest odios GitHub Release
odio-docsAstro build, lychee, cspellDeployed separatelyThis site
  • 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 .deb and the upgrade test images are built on top of. Specifically: