Release pipeline
AudioSilo ships from three pipelines that hang off the same v* tag. One rule
ties them together:
The web image publishes first. Both the server Docker image and the native binaries consume a pinned
audiosilo-webimage. Building them before the web image exists, or before it is current, bakes in the wrong player.
Separately, every push to the server's main publishes an :edge server image
for testing before a release (see
Server Docker image).
This page explains how the machinery fits together. The step-by-step operator runbook is Releasing.
The pipeline
1. Web image: audiosilo-frontend/.github/workflows/web.yml
web.yml runs on pushes to main and on v* tags, plus manual dispatch.
- It exports the player as a static site
(
npx expo export --platform web --output-dir dist).app.jsonexperiments.baseUrl: "/web"makes asset URLs resolve under the server's/webmount. - It packages the export into a tiny image with
Dockerfile.web, pushed asghcr.io/<owner>/audiosilo-web.
Tagging gives :latest only from the default branch, a semver tag on v* tags,
and a :sha tag always. GHCR references must be lowercase. docker/metadata-action
handles that here, so web.yml needs no explicit lowercase step. The server's
image.yml and release.yml lowercase the owner themselves.
2. Server Docker image: audiosilo-server/.github/workflows/image.yml
image.yml runs on v* tags, on pushes to main, and on manual dispatch (with a
web_version input, default latest). It tags through docker/metadata-action:
| Build | Tags | VERSION stamp |
|---|---|---|
A v* tag, a -rc tag included, pushed or dispatched on the tag | the semver version and :latest. No sha- tag, because a release is pinned by its version. | the semver |
A push to main | sha-<short> and :edge | dev-<short sha>, the same 7 characters as its sha- tag |
A dispatch on main that leaves web_version at latest | sha-<short> and :edge | dev-<short sha> |
A dispatch on another branch, or on main with another web_version | sha-<short> | dev-<short sha> |
A top-level concurrency group per ref cancels a superseded build of the same ref.
An edge image bakes whatever audiosilo-web:latest points at (frontend main).
Only a push to the server's main, or a dispatch on it, rebuilds it.
The multi-stage Dockerfile:
-
builds the admin console in a
node:24-alpinestage (npm ciandnpm run buildinadmin-ui/). The stage runs on the build host's platform, since the output is plain JS and CSS. It copiesinternal/web/adminui/distinto the Go stage, where it is embedded (see Built-in web UI); -
builds the server binary with the native SQLite engine by default (
ARG SQLITE_ENGINE=native;--build-arg SQLITE_ENGINE=moderncbuilds the pure-Go engine without cgo instead, see SQLite choices):CGO_ENABLED=1,-tags "sqlite_native sqlite_fts5 sqlite_preupdate_hook sqlite_omit_load_extension netgo osusergo",-trimpath;- linked statically against glibc (
-linkmode external -extldflags -static) on the Debian-basedgolang:1.26image, so the binary runs unchanged on the Alpine final stage and keeps glibc's allocator (with musl's, a first scan was 27% slower than on the pure-Go engine).netgoandosusergokeep it off glibc's NSS; - the build runs on the build host's platform. A cross leg installs the
target's cross compiler and C library (
gcc-aarch64-linux-gnuandlibc6-dev-arm64-cross, orgcc-x86-64-linux-gnuandlibc6-dev-amd64-cross);
-
stamps the release version via
-ldflags "-X github.com/kodestar/audiosilo-server/internal/api.Version=${VERSION}"(image.ymlpasses the semver on a tag build, elsedev-<short sha>, as theVERSIONbuild-arg; the default isdev); -
pins the player:
FROM ${WEB_IMAGE} AS web…COPY --from=web /web /app/web, withARG WEB_IMAGE=ghcr.io/kodestar/audiosilo-web:latest; -
uses
alpine:3.24for the final stage, with ffmpeg installed from apk. Unlike the native binaries, the Docker image does bundle ffmpeg. The stage also has aPUIDandPGIDentrypoint, which gives that user its folders and checks them (see Folder permissions), and presetsAUDIOSILO_WEB_DIR=/app/web.
Because the image pins one specific web build, the server and its bundled player
always ship as a known-compatible pair. Native apps talking to any server
version negotiate through the GET /api/v1/server capability flags instead.
3. Native binaries: release.yml and .goreleaser.yml
The same v* tag triggers GoReleaser on a single Linux runner, in two builds:
- Linux (
audiosilo-linux,amd64andarm64, for the tarballs and the.deband.rpm) uses the native SQLite engine, with the Docker build stage's tags and static glibc link (section 2) plusembedplayer. The result is one static binary that runs on any distro, Alpine included. Each architecture needs its C compiler, named by its triplet inCC(x86_64-linux-gnu-gcc,aarch64-linux-gnu-gcc).release.ymlinstalls the arm64 cross compiler (gcc-aarch64-linux-gnu,libc6-dev-arm64-cross) on the amd64 runner. - macOS and Windows (
audiosilo,amd64andarm64): CGO-free on the pure-Go SQLite engine (modernc), so they cross-compile from the same runner with no C toolchain.
Six binaries in all. The rest is shared:
- Archives:
.tar.gz(Linux and macOS) and.zip(Windows), namedaudiosilo_<version>_<os>_<arch>. Each contains the binary plus README, LICENSE and RELEASING. - Linux packages:
.deband.rpm(nfpm) that depend on the distro's ffmpeg and install a systemd unit (packaging/systemd/audiosilo.service). checksums.txt. GoReleaser creates the release as a draft, and a person reviews it before publishing. Its notes end with therelease.footerin.goreleaser.yml: the install pointers and the one sponsor line, linking GitHub Sponsors.- Version stamping: the same ldflags as the Docker build.
-X …/internal/api.Version={{ .Version }}overridesvar Version = "dev"ininternal/api/api.go, andGET /server, the admin console and the web player all report it.
Embedding the admin console
The admin console's build output is never committed, so a GoReleaser before
hook also runs scripts/build-admin.sh --build-only. release.yml sets up Node
from admin-ui/.nvmrc. Every native binary embeds the console. Skipping the hooks
produces a binary whose /admin serves a "console not built" page.
Embedding the player: -tags embedplayer
Native binaries can't mount a baked-in /app/web directory, so the player is
compiled into the binary:
- A GoReleaser
beforehook runsscripts/fetch-web-player.sh. The script usesdocker createanddocker cpto copy the static export out of the pinnedaudiosilo-webimage intointernal/web/player/(gitignored). It fails ifindex.htmlis missing. The image is theWEB_IMAGEenv var, whichrelease.ymlsets from the lowercased owner and theweb_versiondispatch input (defaultlatest). - Builds use
-tags=embedplayer.internal/web/player_embed.goembeds that directory, while the defaultplayer_disk.go(//go:build !embedplayer) serves fromweb_dirat runtime. The embedded player takes precedence overweb_dir, so/webworks with zero configuration. - Local snapshot builds skip the hook (
goreleaser build --snapshot --clean --skip=before --single-target). A committedinternal/web/player/.gitkeepkeeps the embed compiling without a real player.
ffmpeg/ffprobe are NOT bundled
They're large (~80 MB each) and usually already installed, so the archives stay
small. At startup pkg/launcher (resolveTools in pkg/launcher/app.go)
resolves each tool in order:
- an explicit
--ffmpegor--ffprobepath, - a copy sitting next to the binary,
$PATH,- only if none is found:
internal/toolfetchdownloads a static build over HTTPS into<data>/tools, self-checks it by running-version, and caches it.
Offline or on an unsupported platform, the server runs without transcoding and
probing, which are both optional by design, and retries on the next start.
The .deb/.rpm packages sidestep all of this by declaring a dependency on the
distro's ffmpeg.
Smoke test, and the LinuxServer.io image downstream
release.yml's last step smoke-tests the archives while the release is still a
draft:
- the exact linux archive names and the binary at each archive's root;
- each binary's build settings (
go version -m):- a Linux binary's tags
embedplayer,sqlite_native,netgoandosusergo,CGO_ENABLED=1, its architecture, and static linking (readelffinds no ELF interpreter); - a macOS or Windows binary's
embedplayertag,CGO_ENABLED=0and architecture;
- a Linux binary's tags
- an amd64 boot with
--dataonly (the headless first run and the self-signed TLS default) that must serve/healthzand/web/and logengine=nativeat start.
That is a contract, because
linuxserver/docker-audiosilo
(lscr.io/linuxserver/audiosilo, the image Unraid's Community Applications lists)
installs audiosilo_<version>_linux_{amd64,arm64}.tar.gz from the published release
onto Alpine. The binary is static, so that image runs the native engine too.
The LinuxServer.io CI polls releases/latest hourly, so publishing the draft is
what triggers a new <tag>-ls<N> image. The full list is in
audiosilo-server/RELEASING.md section 2c.
A static glibc binary carries two obligations of its own. The release notes' footer states both.
- The LGPL notice. glibc is LGPL-2.1-or-later. So
NOTICEships in every archive, and at/usr/share/doc/audiosilo/NOTICEin the.deb, the.rpmand the Docker image. It says where the glibc source is and how to relink, since the full source and build recipe are public. The packages' license readsAGPL-3.0 and LGPL-2.1-or-later. - Docker 20.10.10 or newer. glibc starts threads with
clone3, which Docker's seccomp profile refused withEPERMbefore 20.10.10. So in a container the server needs Docker 20.10.10 or newer. On older ones it stops at start-up withpthread_create failed.
Ordering and compatibility, in one place
- Web image first. Both
image.yml(COPY --from) andrelease.yml(fetch-web-player.sh) pullaudiosilo-web. Publish it before either runs, or re-run them after. On a plain tag push all workflows fire together, and the web image they resolve is whatever:latest(or the dispatchedweb_version) points at. That is why the runbook publishes the web image first. - Publish the native draft last. The draft is uploaded before
release.yml's smoke test runs, and the LinuxServer.io image follows its publication. Publish only once the run is green, and tick Set as a pre-release for a-rctag. - Docker and native builds share the server code and the pinned player. They
differ in three ways:
- ffmpeg is bundled in the image, and resolved or fetched at runtime for the binaries.
- The image attaches the player with
/app/webandAUDIOSILO_WEB_DIR, and the binaries with-tags embedplayer. - The SQLite engine is pure Go on macOS and Windows, and native in the image and the Linux binaries.
- Version stamping is the same mechanism everywhere. The ldflags set
api.Version, whichGET /serverreports and the UIs show. - Compatibility between the native apps and the server is not pinned. It is
negotiated at runtime through the
GET /api/v1/servercapability flags (see seam 8 of the cross-repo contract).
For the human checklist (what to click, in what order, and how to smoke-test the result), see Releasing.