Contributing¶
Contributions are Sofa King welcome, from everyone who follows the code of conduct. This guide covers the repository's layout, the conventions the app, the server and the contract share, and the CI that checks them. Each part's build, tests and code rules are in its development page and its AGENTS.md:
| Part | Build and tests | Code constraints |
|---|---|---|
| App | development | app/AGENTS.md |
| Server | development, with the smoke scripts and adding a native provider | backend/AGENTS.md |
| Contract | its README, with the rules for changing a wire type |
Repository layout¶
Three Gradle builds, each with its own wrapper; build each from its own directory:
| Directory | Build | Checks |
|---|---|---|
app/ |
The Kotlin Multiplatform app | Build and test |
backend/ |
The Ktor server and its Docker image | Build and test |
contract/ |
The wire types both include | ./gradlew jvmTest assemble |
They share these files at the root:
gradle/libs.versions.toml, the one version catalog, so a Kotlin or kotlinx.serialization bump moves every build at once. It holdssofaking, the one product version, which.github/version.shextends with the build number that both the app snapshot and the server image carry.gradle/repositories.settings.gradle.ktsandgradle/build-cache.settings.gradle.kts, the repositories and the remote build cache every build applies. The cache is read-only without theSOFAKING_CACHE_*credentials.build-logic/, the convention plugins:sofaking.kotlin(the JDK 21 toolchain and the web toolchain repositories),sofaking.android(the SDK levels) andsofaking.lint(formatting and static analysis)..editorconfig, the code style the IDE and ktlint share.settings.gradle.kts, which includes all three builds for one IDE import only, with thegradle.propertiesand the.run/configurations that import uses. CI never builds from the root; the app's development page covers opening it in Android Studio.renovate.json, which proposes the dependency, wrapper, base image and workflow action updates of every build.
.gitignore ignores every directory named build, so no package or source directory may carry that name.
Formatting and static analysis¶
Kotlin follows IntelliJ IDEA's Kotlin style, which the root .editorconfig sets for the IDE and for ktlint, with lines up to 120 characters and no wildcard imports. The sofaking.lint convention applies Spotless with ktlint to every project's Kotlin sources and Gradle scripts, and detekt to its Kotlin sources, with the Compose rules where the project uses Compose. gradle/detekt.yml and gradle/detekt-compose.yml hold what differs from the defaults, each change with its reason. From app/, backend/ or contract/:
./gradlew spotlessApply # format
./gradlew spotlessCheck # fail on unformatted code or a finding ktlint cannot fix, such as a long line
./gradlew detekt # static analysis
CI runs spotlessCheck and detekt in every build. Fix a finding in the code rather than suppressing it; where a rule is wrong for one declaration, @Suppress that one rule there, with a comment saying why. There is no baseline, and no build turns a rule off.
Catch the exceptions a call can throw, such as IOException or SerializationException, never a bare catch (e: Exception). Where a failure of any kind is logged or shown and the work goes on, such as a background loop's next pass or a screen's load, use runCatchingCancellable (the server's Results.kt, the app's data/Failures.kt): it rethrows CancellationException, so canceling still cancels, and never catches an Error. To clean up after a failure and let it go on, use runCatching { … }.onFailure { … }.getOrThrow(), or finally.
Commits¶
Use Conventional Commits: type(scope): summary, in the imperative mood, for example fix(player): back off on the lineup reload or feat(providers): resolve the guide id for a channel. The scope names the area:
- App:
app(the shell, navigation and the tabs),guide,player,preview,captions,settings,library,data,web,ios,tvos,desktop. - Server: the package or area, such as
providers,policy,hdhr,logos,playback,admin,serverorbackend. - Anywhere:
buildandcifor the builds and workflows,depsfor Renovate's dependency updates, anddocsfor documentation-only changes.
The squash-merge title is the release note: the snapshot release lists every feat, fix and perf title since the previous snapshot, rendered by git-cliff with .github/cliff.toml, so write the summary for someone using the app or the server. The app's update check shows the same lines, without the scope and the pull request, as What's new. Other types stay out of the notes. A breaking change (feat!:, or a BREAKING CHANGE: footer) goes under Upgrade notes whatever its type; say there what someone updating has to do.
A release is a v* tag on main whose version matches sofaking in the version catalog: bump the catalog in a pull request, merge it, then push the tag (git tag -a v0.2.0 -m "Highlights"). The tag's message heads the release notes. A Flyway migration bumps at least the minor version, since a host cannot move back past it, and gets an upgrade note.
Pull requests¶
- Keep one change per pull request. Describe why it is needed first; the diff already shows what changed. Put the reasoning behind a change in the commit or the pull request, not in the docs.
- Add or update tests with any behavior change. Name tests for the behavior they check.
- Update the documentation in the same pull request, including any changed route, setting, control or variable.
- List the checks you ran, including the checks on a TV for a change to the app's playback, focus or remote, and add before and after screenshots for a visible change.
- Leave tokens, cookies, device identifiers, server addresses and private URLs out of code, fixtures, logs, screenshots and descriptions. The repository is public.
- Write US English in prose and in names, such as color, behavior and license; the root AGENTS.md lists the spellings and the third-party names that keep theirs.
- Squash-merge once CI is green at the head.
Documentation¶
The Markdown pages are also the documentation site, built with MkDocs and Material for MkDocs from mkdocs.yml. The pages stay where they are and read the same on GitHub: docs/site/stage.py copies every tracked page and docs image (agent instructions and .github/ aside) into build/docs-src, with the server's OpenAPI document rendered by Scalar as the API reference, and docs/site/hooks.py points links to scripts and other non-page files at GitHub. To build it locally, strictly as CI does:
pip install -r docs/site/requirements.txt
python3 docs/site/stage.py && mkdocs build --strict
The root keeps README.md, this file, SECURITY.md, SUPPORT.md, CODE_OF_CONDUCT.md and docs/ (the index and shared glossary, comparison, migrating and site/). app/ and backend/ each keep a README.md and a docs/ folder whose index lists its pages; both have getting-started, troubleshooting, architecture, security, development and dependencies. Put a new page in the part it describes, link it from that index, and add it to the nav of mkdocs.yml.
- Write current facts in the present tense, with steps in the imperative.
- One fact lives on one page; the others link to it. The server's configuration table is the one place its environment variables are listed.
- Split a sentence over about 40 words. Three or more controls, options or fields make a table.
- Per-platform exceptions go in a trailing paragraph or a table column.
- Implementation detail goes in the architecture pages, not the usage or API pages.
- A flow, state machine or timing that takes more than a paragraph gets a Mermaid diagram: no colors or styling, every edge labeled.
- No review diaries, dated benchmark or test-count summaries, design histories or roadmaps.
Wire changes¶
A request or response type the app and the server share lives in contract/, never in either part. A change to it lands with both sides in one pull request, follows the rules in the contract's README, and keeps every fixture in contract/src/jvmTest/resources/wire: never edit or delete one. Released apps run against newer servers, so the server must keep answering what those fixtures hold.
Dependencies¶
Versions are pinned exactly in the shared catalog, with no lockfiles or verification metadata, so an update changes only the pin. Renovate opens the pull requests for the catalog, the Gradle wrappers, the Dockerfile base images and the workflow actions, pinning images and actions by digest. It follows Kotlin, kotlinx.serialization and kotlinx.coroutines prereleases and lists pending updates on its Dependency Dashboard issue. It merges minor, patch, pin and digest updates at least three days old once every check on the latest commit passes. Kotlin, the Android Gradle plugin and Compose Multiplatform move together in one pull request, and major updates wait too, for a person; the JDK and the Ubuntu runner image wait for approval on the dashboard. The catalog also pins the ktlint version Spotless runs (ktlint) and detekt, on its 2.0 prerelease line (plugin id dev.detekt); Renovate updates them like any other pin.
By hand:
- Update the exact versions in the catalog.
- Run the part's checks: app, server with both smoke scripts, and
jvmTestincontract/. - For a change to the app's playback, captions or remote, run the checks on a TV.
- For a wrapper update, pin the official distribution checksum in
gradle-wrapper.properties. - A Kotlin or kotlinx.serialization bump moves all three builds: run each part's checks and follow the contract's README.
- For a wrapper, JDK or SDK change, update the version table in the part's dependencies page.
Each part's dependencies page lists the versions the catalog does not hold: app, server.
CI¶
| Workflow | Triggers | What it does |
|---|---|---|
ci.yml |
Pull request, push to main, manual |
Tests and builds the app, runs the Apple TV remote tests, the iOS touch tests and the web key fuzz, and publishes the snapshot from main |
backend.yml |
Pull request, push to main, v* tag, manual |
Tests and builds the server, and publishes its image from main and from each tag |
docs.yml |
Pull request, push to main, manual |
Checks that every image a page draws is a tracked file (docs/site/check_images.py), builds the site strictly, so a link to a missing page or anchor fails, and publishes it to GitHub Pages from main |
release.yml |
v* tag |
Builds the packages through snapshot.yml, then publish-release.yml publishes them as the vX.Y.Z release, which GitHub marks the latest; backend.yml publishes the image for the same tag |
screenshots.yml |
Mondays, pull request, manual | Takes the documentation's screenshots on every platform a runner can show and the admin portal, and opens a pull request with the ones that changed (Screenshots) |
tv-monkey.yml |
Nightly, pull request, manual (key count and seed) | The Maestro flows, then random remote keys, on the debug app in a headless Android TV emulator (TV emulator monkey) |
desktop-monkey.yml |
Nightly, pull request, manual (key count and seed) | Random keys on the desktop app in a virtual X display, with libVLC playing fixture channels (Desktop monkey) |
phone-tests.yml |
Nightly, pull request, manual (step count and seed) | The debug app's touch tests in a headless Android phone emulator (Phone touch tests) |
codeql.yml |
Mondays, pull request, manual | Scans the workflows with CodeQL; findings appear under the Security tab. The Kotlin waits for CodeQL to support the catalog's Kotlin version |
scorecard.yml |
Push to main, Tuesdays, manual |
Rates the repository with the OpenSSF Scorecard and publishes the result for the README's badge |
dependency-submission.yml |
Push to main, manual |
Sends each build's resolved dependencies to GitHub, so Dependabot alerts cover them |
What runs on a change (a pull request or a push to main unless noted; manual runs aside):
| You change | Runs |
|---|---|
app/ code, not Markdown, app/docs/, app/maestro/ or the tv-monkey.py, desktop-monkey.py, screenshots.py and showcase.py scripts |
ci.yml |
contract/, not Markdown |
ci.yml, backend.yml |
backend/, not Markdown or backend/docs/ |
backend.yml |
The app's core or ui module or its build files |
ci.yml, and backend.yml on a push to main only |
gradle/, build-logic/, .editorconfig |
ci.yml, backend.yml |
A *.gradle.kts or gradle.properties file, gradle/, build-logic/ |
dependency-submission.yml, on a push to main only |
Markdown, docs/, mkdocs.yml, the server's OpenAPI document |
docs.yml |
app/maestro/, app/scripts/tv-monkey.py |
tv-monkey.yml |
app/scripts/desktop-monkey.py |
desktop-monkey.yml |
app/androidApp/src/androidTest/, app/androidApp/build.gradle.kts |
phone-tests.yml, ci.yml |
app/scripts/screenshots.py, showcase.py |
screenshots.yml |
app/scripts/doc_images.py, doc-images.json |
screenshots.yml, ci.yml |
app/scripts/fixture_server.py |
ci.yml, tv-monkey.yml, desktop-monkey.yml, phone-tests.yml, screenshots.yml |
.github/actions/apt-install/ |
ci.yml, backend.yml, screenshots.yml |
.github/version.sh |
ci.yml, backend.yml |
| A workflow file | That workflow; ci.yml also runs for snapshot.yml and publish-snapshot.yml |
A push to main that changes the app publishes the rolling snapshot prerelease; one that changes the server publishes ghcr.io/jamesarich/sofa-king:main, which the home server installs within about 5 minutes. A v* tag matching the catalog's sofaking publishes the vX.Y.Z release and the image as :X.Y.Z, :X.Y and :latest. Every published package carries a build attestation, and the image a cosign signature too.
ci.yml runs up to five jobs:
checkruns the formatting check and detekt, the app's tests, Android lint, the Native test compile, the release APK, the Android screenshot tests and both production web builds on Ubuntu, then presses random keys on both web builds in Chrome against fixture channels. It uploads the test reports when it fails and theRenderSmokeTestscreenshots every time.appleruns the data layer's tests on iOS, macOS and tvOS simulators and, outside pull requests, on watchOS too and the UI's iOS tests (a pull request compiles both), links the iOS framework, builds the SwiftUI host inapp/iosAppwith XcodeGen andxcodebuild, and runs the iOS touch tests, on macOS. A pull request runs the iOS and Apple TV random walks shorter thanmaindoes, to save macOS runner time.apple-tvlinks the tvOS framework, builds the SwiftUI host inapp/tvosAppand runs the Apple TV remote tests on a second macOS runner, besideapple.snapshotruns for a push tomain. It callssnapshot.yml, which builds every platform but iOS and Apple TV as CI and snapshots describes, and which also runs on its own from the Actions tab, where it publishes by itself.publishruns once the checks and the packages are all green. It callspublish-snapshot.yml, which replaces thesnapshotprerelease with the packages the run uploaded, signed as a flat apt repository when theAPT_SIGNING_KEYsecret is set. Its notes add a section of the user-facing commits since the release it replaces above that release's sections, keeping the last 20 snapshots.
backend.yml runs four jobs, and serves the web app built from the same commit at /app/, which is why a push to main that changes the app's core or ui runs it too:
testruns./gradlew spotlessCheck detekt test :server:installDistand the contract'sspotlessCheck detekt jvmTeston Ubuntu 24.04 with its FFmpeg 6.1, the version the image ships, and Comskip. FFmpeg's output differs between major versions, so a test that passes against a newer local FFmpeg can still fail there. Each test fails after a minute unless it sets a longer@Timeout. The contract'sjvmTestchecks the wire fixtures older apps rely on, which the server'stestdoes not reach. The job then runsshellcheckoverbackend/deploy, the unit tests inbackend/scripts, and both smoke scripts against the installed distribution, which need no network, then fuzzes every documented route with Schemathesis. Thecomskipjob builds Comskip from source (backend/scripts/build-comskip.sh, cached on the script and its patch) on Ubuntu 26.04 and runs the Comskip test with it.webbuilds the app's production Wasm bundle with the image's version, besidetest, and gzips its files; the image serves it at/app/(serve the web app).imagebuilds the image from the tested distribution and the web bundle forlinux/amd64andlinux/arm64, each on a runner of its own architecture, then starts it and checks/readyz,/healthz, the web app's page and its precompressed WebAssembly, and a clean stop. A pull request builds both without pushing them.publishruns formain, on a push or a manual run, and for av*tag, once both images are built. It tags them together asghcr.io/jamesarich/sofa-king:main(for a tag,:X.Y.Z,:X.Yand:latestinstead) and:sha-<commit>, with a BuildKit SBOM and max-mode build provenance, which deploy shows how to inspect. Hosts with automatic updates install that image.
Ubuntu packages (FFmpeg, Comskip, libVLC) come through .github/actions/apt-install, which keeps the downloaded .deb files in the Actions cache per runner image: the runners' Ubuntu mirror sometimes takes half an hour for FFmpeg.
Every Gradle job restores the Gradle user home that the same job last saved from main (pull requests only read it), and the app jobs keep ~/.konan, the Kotlin/Native distribution, per Kotlin version. Pushes to main also write the remote build cache. The configuration cache is not kept between runs: setup-gradle stores it only for a Develocity trial, so every run configures the build from scratch, 15 to 45 seconds per job and about 70 on Windows.
Security¶
Report vulnerabilities privately, as SECURITY.md describes.