Quality gates and CI
Every repo has a full gate: the exact command sequence CI runs on every PR and push. A change is not done until the gate of every repo you touched passes locally. The gates are per-repo and independent. Don't run Go checks for a frontend change, and remember that green CI in one repo says nothing about the seam between repos.
The full gates (run these before calling anything done)
audiosilo-server
cd ~/dev/audiosilo/audiosilo-server
scripts/build-admin.sh # the admin console (Node 24): npm ci + check + build
go build ./... && go vet ./... && go test -race ./... && golangci-lint run
# the native SQLite engine (CI's test-native job), needs a C compiler:
TAGS="sqlite_native sqlite_fts5 sqlite_preupdate_hook"
CGO_ENABLED=1 go vet -tags "$TAGS" ./... && CGO_ENABLED=1 go test -race -tags "$TAGS" ./... \
&& CGO_ENABLED=1 golangci-lint run --build-tags=sqlite_native,sqlite_fts5,sqlite_preupdate_hook
The native run matters most for a change under internal/store/ or to SQL,
because the engine's files build only under its tags (see
SQLite choices).
scripts/build-admin.sh is the admin console's gate. It runs npm ci, then
npm run check (tsc -b, ESLint including the CSP guard rules,
prettier --check, Vitest), then the build, which itself fails on any CSP
violation. Skip it only when admin-ui/ is untouched. Without a build,
internal/web/adminui's TestEmbeddedBuild skips instead of checking the
embedded console.
audiosilo-frontend
cd ~/dev/audiosilo/audiosilo-frontend
npx tsc --noEmit && npm run lint && npm run format && npm test
# after a dependency change, also:
npx expo install --check && node scripts/check-native-dupes.mjs
npm run format is prettier --check .. It fails on unformatted files
rather than fixing them. Auto-fix locally with npx prettier --write . before
committing.
What CI actually runs
The workflows for the server, player and metadata repos, verified against
.github/workflows/:
audiosilo-server
| Workflow | Name | Triggers | What it does |
|---|---|---|---|
ci.yml | ci | every PR; push to main | Four jobs side by side: test, test-native, admin-ui and lint. See The server's ci.yml jobs. |
image.yml | server image | v* tags; push to main; manual dispatch (input web_version) | Builds the Docker image, baking the pinned web player in via the WEB_IMAGE build-arg, and pushes ghcr.io/<owner>/audiosilo-server (semver and latest on tags; edge on main; sha-<short> off a tag). See Testing main before a release. |
release.yml | release (native binaries) | v* tags; manual dispatch (input web_version) | GoReleaser: cross-platform native binaries with the web player embedded (-tags embedplayer; the Linux ones also build the native SQLite engine in, static), published as a draft GitHub Release. |
probe-drift.yml | probe-drift | PRs and pushes to main touching internal/metadata/**, the Dockerfile or the workflow; weekly (Monday 05:17 UTC); manual dispatch | Job image-ffprobe. It builds the internal/metadata test binary, then runs TestProbeMatchesLiveFFprobe and TestProbeMatchesGoldens in the Dockerfile's Alpine base image, with that image's ffmpeg installed and AUDIOSILO_PROBE_LIVE=1. It fails when the in-process probe answers a fixture differently from the image's ffprobe (drift), or when that ffprobe is missing or of a release the probe has no rules for. It runs weekly because Alpine updates ffmpeg with no change in the repo. See The in-process probe's fixtures. |
The server's ci.yml jobs
- test runs on the pure-Go modernc engine. It sets up Go from
go.mod, Node fromadmin-ui/.nvmrc, and runsscripts/build-admin.sh --vite-onlybefore the Go steps, so the embed tests see a real build. It does no typecheck, since the admin-ui job does that. It then installs ffmpeg, runsgo test -coverprofile=coverage.out ./...with no-race, and uploads the coverage artifact. The coverage run compiles every package, test files or not, so there is no separate build step. - test-native runs on the native SQLite engine the Docker image ships, with
CGO_ENABLED=1and its build tags, since the engine's files build only under them (see SQLite choices). It installs ffmpeg, runsgo test -raceover the whole suite, then runs golangci-lint with the same tags. Its govet replaces a separatego vetstep. There is no admin console build here, so the embed tests skip in this job and run in test. - The race detector runs only in test-native. It instruments the pure-Go SQLite, which is transpiled C, and makes the modernc run several times slower. The cgo engine's C code is not instrumented.
- admin-ui runs the full
scripts/build-admin.sh(npm ci,npm run check, the build). - lint runs
golangci-lint-action@v9(golangci-lint v2, config.golangci.yml) on the default build.
Both Go test jobs get ffmpeg and ffprobe from the local setup-ffmpeg action.
It installs a pinned, checksummed static build (cached between runs) of the
FFmpeg release the image's Alpine ships, so the ffprobe-dependent scanner tests
and the in-process probe's live comparison run in both jobs.
audiosilo-frontend
| Workflow | Name | Triggers | What it does |
|---|---|---|---|
ci.yml | ci | every PR; push to main | Job check runs, in order: Node from .nvmrc, npm ci, npx expo install --check (the Expo SDK pins; expo.install.exclude in package.json holds the one deliberate drift, AsyncStorage 3.x), node scripts/check-native-dupes.mjs (one copy of each native module), npx tsc --noEmit, npm run lint, npm run format (prettier --check), npm test -- --ci --coverage. |
web.yml | web image | push to main; v* tags; manual dispatch | npx expo export --platform web (built with baseUrl=/web) and publishes the static export as ghcr.io/<owner>/audiosilo-web (:latest from main, semver from tags). |
audiosilo-meta
| Workflow | Name | Triggers | What it does |
|---|---|---|---|
check.yml | check | every PR; push to main | A changes job classifies the diff first, then parallel jobs run. See The meta check.yml jobs. |
release.yml | release | push to main (paths touching data, schema, the builder, or the compose code, but not data/go.mod - it changes no data); community-data repository_dispatch from audiosilo-meta-community; manual dispatch (refused unless run on main) | Composes the SQLite artifact from both repositories, creates the release as a draft, verifies every asset against the GitHub API's own reported size and sha256 digest, publishes only once verification passes, and sends the signed webhook notification. |
image.yml | image | v* tags; manual dispatch | Builds and publishes the metaserve + static-site container image to ghcr.io/kodestar/audiosilo-meta (no catalogue data baked in). |
intake.yml | intake | issue edited/labeled; workflow_call from route.yml; push to main | Turns a data issue-form submission into a validated bot pull request (metaissue --profile core), and rebases every open bot-intake pull request (the intake bot's and the sync bot's) on every push to main. |
route.yml | route | issue opened | Routes an issue filed through the AudioSilo Contributions App (by a Bridge) by its title prefix: labels it and calls intake.yml, or holds it with intake:held (a young account, too many open App-filed issues, a denylisted id). The community repository has its own copy. See Issues filed through the AudioSilo App. |
ai-verify.yml | ai-verify | PR touching data/**; manual dispatch | Advisory, non-blocking AI review of a data pull request's records, reading an entry-level change summary from metadiff rather than the raw diff. A rebase that leaves that summary unchanged reuses the last verdict without a model call; a manual dispatch or a re-run always judges afresh. |
workflows.yml | workflows | PR or push touching .github/workflows/** or .github/scripts/** | Lints the workflows themselves (actionlint) and the shell scripts they call out to (shellcheck). It is paths-filtered, so it is not a required check. |
The meta check.yml jobs
A changes job classifies the diff first. Then these jobs run in parallel:
- build runs
go buildandgo vet, and test runsgo test -racein three shards. Both are Go-gated. - metacheck and metafmt run
metacheck --profile coreandmetafmt --check --profile core. They are unconditional, since they are the data's own check. - advisories (pull requests only, never red) runs metacheck over the base for the advisory delta.
- check is the verdict over the jobs above, not the ones below. It writes
the advisories the PR introduces into its summary. It is the name branch
protection and the sync bot require (see
checkis the verdict). - lint runs
golangci-lint run(v2.12, over the linter set.golangci.ymlpins). Go-gated. - vuln runs
govulncheckdirectly (pinned to v1.8.0). Go-gated. - compose (pull requests only, compose-gated) builds the artifact against a
shallow
audiosilo-meta-communitycheckout, to catch the cross-repo rules a single tree can't see. - site builds, typechecks and tests
site/when present.
A PR touching only data/go.mod is classified as Go, not data, so it still
runs the Go-gated jobs. data/go.mod is the nested, code-free module that
keeps this module's zip small (see
Package layout).
Each repo's CI is independent, so a server-side wire change with no matching frontend change sails through both pipelines green. The cross-repo checklist is what covers that gap.
The green-baseline lint policy
The server and audiosilo-meta repos run golangci-lint
v2 in CI from a green baseline: the suppressions in each .golangci.yml are
documented and intentional. The policy:
- fix new findings in the code you touched;
- never widen the excludes to make a finding go away.
Test conventions
Every feature ships with a test, in the same change. Where and how depends on the repo.
Server (Go)
-
Handler and integration tests use the
newTestEnvharness ininternal/api/api_test.go: a file-backed SQLite database plus the tiny generated M4B fixtures intestdata/library. The database is file-backed, as in production. Reads go to the read-only reader pool, so a write sent through a read method fails the test instead of passing on:memory:. -
Never migrate per test. Open a test database with
storetest.Open(t)(internal/store/storetest;storetest.Path(t)when the test opens the file itself), notstore.Openon a fresh file or:memory:. Under-racerunning every migration costs about a second, so the helper migrates once per test binary and hands each test a file-backed copy. -
Cheap hashing. A package whose tests create many password users calls
auth.UseCheapHashingForTests()from itsTestMain(seeinternal/api/main_test.go), because one argon2id hash at the real cost takes about a quarter of a second under-race. It panics outside a test binary. -
Tests run in parallel. A new top-level test starts with
t.Parallel(). Each has its own database and temp dirs, so the only thing tests share is the read-onlytestdata/fixtures. Copy one intot.TempDir()before changing it. The exceptions:- a test that calls
t.Setenvort.Chdir, since both panic in a parallel test; - a test that writes a package-level variable. Pass the value in instead, as
hashWithCostdoes forTestHashCost.
- a test that calls
-
Pure-logic tests sit next to the code (
internal/api/middleware_test.go,internal/catalog/shares_test.go,internal/web/web_test.go, …). Keep business logic out ofinternal/apihandlers, so it stays unit-testable.apiis transport-only. -
Security-critical code requires both an allowed and a denied regression test. The denied one is the point. That covers anything touching:
library.SafeJoin;Scope.Allows,VisibleInBrowseorpathFilterSQL;- the household member rule (
catalog/members.go) and member routes (handlers_members.go); - the rate limiters;
auth.ResolveRequestorlookupToken;web.htmlCSP;bearerToken.
See Auth & security.
The in-process probe's fixtures
The scanner's in-process probe must answer exactly as ffprobe does, so it is tested against ffprobe's own answers, with and without ffprobe installed:
- The corpus.
internal/metadata/testdata/probeholds about 90 small fixtures (a few seconds of audio each, under 3 MB together) in every shape the probe reads or leaves to ffprobe: MP3 (CBR, VBR with and without a Xing or VBRI header, gapless, ID3 and APE tags, chapters), M4B and M4A, FLAC, Ogg Vorbis, Opus and FLAC (.oggand.opus) and MKA, plus crafted and truncated files.scripts/gen-probe-fixtures.shmakes them, withcmd/mkfixturesfor the crafted ones. The script needs ffmpeg and ffprobe 7.x or 8.x with libmp3lame, libvorbis and libopus, thelameCLI andmetaflac. Regenerating rewrites every fixture, since encoders differ by version. Commit the files and the goldens together, and review the goldens' diff. - Goldens.
golden-<release>.json(golden-7.1.json,golden-8.1.json) is a known release's normalised answer for every fixture, written bycmd/probediff -golden. The script writes one for the local ffprobe and, through docker, one for the Docker image's.probe_golden_test.gochecks the probe against each with no ffprobe needed, so the plaingo test ./...gate covers it everywhere. - The live comparison.
probe_live_test.gocompares the probe with the ffprobe onPATHover the fixtures, and over any folders of real booksAUDIOSILO_PROBE_DIRS(a path list) names. It skips without an ffprobe, or with one of a release the probe has no rules for. WhenAUDIOSILO_PROBE_LIVEis set, either case is a failure instead. Theprobe-driftworkflow sets it. - Adding a release. Add its rules where they differ, its golden and a
knownRulesentry. The live test and the CI job then cover it. - Stand-in ffprobe scripts. A test whose ffprobe is a script on valid
files (to fake a rejection or a time-out) would otherwise be answered by the
probe and never run the script. Such a test calls
metadata.ForceFFprobeForTests(path), aslibrary'sstubProbehelper does. It sends everyExtractgiven that ffprobe path to ffprobe. It is keyed by the path, so parallel tests don't touch each other, and it panics outside a test binary.
Frontend (TypeScript)
- Harness: jest-expo (Jest 30) and
@testing-library/react-native14. Matchers are built in (nojest-native).jest.setup.tsprovides in-memory mocks forexpo-secure-storeand AsyncStorage. Tests mockfetchand@/api/reachabilityas needed, and flipPlatform.OSat runtime to cover the web and native branches. - Pure, framework-free modules get direct co-located
*.test.tsfiles (src/api/client.ts,src/lib/*,src/playback/book-queue.ts,src/playback/progress-sync.ts,src/stores/*). Keep logic out ofsrc/app/**screens so it stays unit-testable. See Testing the player.
Two gate gotchas
- ffmpeg-dependent tests skip silently. A few server scanner tests need
ffprobe. Without it they
t.Skipand the suite still passes, so a green local run without ffmpeg proved less than you think. CI installs a pinned ffmpeg to keep those tests live. Install it locally too. The in-process probe's golden test needs no ffprobe, and its live comparison skips without one (see The in-process probe's fixtures). - Keep
package-lock.jsoncommitted in sync. CI usesnpm ci(frozen lockfile). After any dependency change, regenerate the lockfile withnpm installand commit it. Otherwise CI fails on a lockfile mismatch.
Definition of Done (digest)
The workspace file ~/dev/audiosilo/CODE-HEALTH.md exists because a full health
review found that the recurring problems (wire-contract drift, dead code, stale
docs, untested modules) were patterns, not one-offs. They were conventions
that were documented but never mechanically checked. It is the checklist that
catches the drift, so read it before adding code. The digest:
Always
- The repo's full gate (above) passes.
- New logic ships with a test in the same change.
- You searched for and removed anything the change supersedes (an old hook, method, constant or flag). Don't leave the previous implementation behind.
- Any doc or comment the change contradicts is updated in the same commit.
Grep the symbol, flag or route you changed across
*.mdand doc-comments. Docs updates are part of done. That includes this documentation site (see Maintaining these docs) and, for seam changes, the workspaceCROSS-REPO.md.
If you touched the wire format (any JSON a handler emits or a client decodes)
- Both repos are updated in one logical change.
- Every field is mirrored in
types.ts, even ones the client doesn't read yet. - Tests on both sides.
CROSS-REPO.mdis updated if the seam changed.
Full walkthrough: Landing a cross-repo change.
If you touched a security-critical path
- Allowed and denied regression tests (see the list in Server (Go)).
- User-influenced SQL
LIKEinput isESCAPEd.
If you mapped an HTTP error
- 4xx only for genuine client errors, branching on typed sentinels
(
errors.Is(err, catalog.ErrNotFound)and friends). - Everything else is a 500 plus a server-side log. Never put
err.Error()in a response body.
If you edited native code (modules/audiosilo-player/{ios,android})
- Ran the native checks for what you
changed (the Android JVM tests, the iOS self-check, Android Auto on the Desktop
Head Unit, CarPlay in the Simulator, or a device build via
npx expo run:ios/run:android) and verified the behavior. A Metro reload does not pick up native changes, and Jest and the web prove nothing about Swift or Kotlin.
The gates all run on Node and CI engines with full Intl and no real device. A
change can pass every gate and still crash on the Hermes runtime or misbehave
against a live server. Don't claim something works from green checks alone;
exercise the affected flow.