Files
yellowjacket/.planning/plans/completed/015-android-release-pipeline.md
yonlu eb139cf872
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m9s
docs: make the issue tracker the source of truth
Work has been starting from a chat message and a plan file, so two
people could pick up the same thing and neither could see the other.
The tracker is where that is visible.

Search before starting, claim before the first edit -- not before the
commit, since the point is that the other person can see the work is
taken while it is being done. If no issue covers it, open one first:
that is what makes the tracker a description of the project rather than
a description of the past.

The conventions were already right and are written down rather than
reinvented -- the Kind/Area/Priority/Platform/Reviewed/Status taxonomy,
its exclusive scopes, #73 as the roadmap, real Gitea dependencies for
hard blockers, and PR #83's body shape.

What #83 also demonstrated is that a Closes list closes nothing
reliably: it listed ten and five of them sat open in main for a
fortnight. So closing is a step you take and verify, not a keyword you
trust.

.planning/ stops being a queue and keeps design documents and measured
history -- NOTES.md, the audits, the completed plans and the arguments
in them. plans/pending/ is gone, because a plan nobody is executing is
an issue; everything unimplemented in it is now #85-#91, and each
completed plan says which issue carries its remainder. autotag.md is
kept as a historical record, marked stale where the scoring overhaul
overtook it.

The commit grammar is unchanged and is load-bearing for a different
reason, so the issue number lives in the branch name and the PR body
rather than the commit subject.

Refs #92
2026-08-18 16:23:52 -04:00

18 KiB
Raw Permalink Blame History

015 — Android release pipeline

Completed. The pipeline ships a signed APK from CI on every v* tag; docs/android-release.md is its operating document.

Ship an Android APK from CI on every version tag, published to the Gitea generic package registry so Obtainium can poll a plain URL.

The baseline is ~/Development/ljos, whose .gitea/workflows/ci.yml android: job has been through the failure modes already. Most of what follows is a transcription of that job onto this repo's conventions; where it differs, the difference is argued.

What this is not

This ships a pipeline, not a usable Android music player. The success criterion is a signed, installable APK that launches — not an app anyone would want. Explicitly out of scope, and each is real:

  • backend/mediacontrols/mpris_linux.go will be compiled on Android. Go's android GOOS implies the linux build tag, so the //go:build linux file is in the build and MPRIS will look for a session bus that does not exist. It compiles; it will error at runtime.
  • backend/system resolves XDG paths. Android has no XDG.
  • The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that.
  • The shell is a desktop shell: an eleven-item sidebar, a 800×600 measured minimum, a transport bar. None of that is a phone layout.
  • The library scanner walks a filesystem Android does not grant.

Those are the next plan, if there is one. Conflating them with this one is how a build pipeline takes six weeks.

Phase 0 — the gate [DONE 2026-08-16]

Passed, further than asked. No source changes were needed; a full 27 MB fat APK built first try, both ABIs, production-stripped. Numbers, the environment and four non-obvious findings are in .planning/NOTES.md — including a scaffold bug that put a debug library in the release APK's phone ABI, fixed here.

It also installs and launches on an emulator, and then exits. One line stops it: backend/system/buildUserDirPath switches on runtime.GOOS and Android takes the default: branch returning errUnsupportedOS, so main() hits os.Exit(1) six milliseconds after the JNI bridge comes up. That is the first thing that stops it, not the only one — see the "not this" section above, all of which is still true and still out of scope.

The emulator tier that found it is now part of the harness: scripts/android-emulator.sh, the make android-* targets, and .pi/skills/yellowjacket-dev/references/android-tier.md. It exists because the failure is invisible in all three places anyone would look (no panic, no tombstone, no crash buffer) and ActivityManager restarts the app fast enough that pidof always answers — so the tier's assertion is "same pid after N seconds", not "it started".

Original phase 0 text follows, kept because its reasoning is what the later phases rest on.

Everything downstream is wasted if the c-shared link fails. Establish it by hand, locally, before writing a line of YAML.

Already established, by probe rather than by assumption:

GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/...

compiles the entire tree. Exactly two packages fail, and both fail only because their Android implementation is cgo:

  • ebitengine/oto/v3driver_android.go needs the bundled oboe C++ backend. Oto supports Android natively; there is no Java audio glue to write.
  • wails/v3/pkg/applicationmobile_features_android.go needs the JNI bridge.

modernc.org/sqlite (the whole database layer), beep, godbus and every backend/ package are clean. No source changes are known to be required, which is the single most surprising finding here and the reason this plan is worth doing at all.

What Phase 0 must actually verify:

  1. Install NDK r26d (26.3.11579264) locally. Pinned, not "whatever sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking this build.
  2. Generate the scaffolding (Phase 1) and run wails3 task android:compile:go:shared ARCH=arm64 by hand.
  3. Confirm build/android/app/src/main/jniLibs/arm64-v8a/libwails.so exists and is an ARM64 shared object.
  4. Repeat for amd64 (the emulator ABI).

If the link fails, stop and re-plan. The likely culprits, in order: alsa (oto must select oboe, not ALSA — if it reaches for alsa.pc the build tags are wrong), and main.go's //go:embed all:frontend/dist combined with the generated main_android.gen.go overlay.

Deliverable: a note in .planning/NOTES.md recording the exact command and the NDK version that produced a .so, or the reason it cannot.

Phase 1 — un-ignore and commit the Android scaffolding [DONE]

Done as a side-effect of phase 0, which could not run without it. One correction to the text below: step 1 is wrong. update build-assets does not generate the android tree (NOTES.md explains); it was generated with generate build-assets into a scratch dir and android/ copied across. CLAUDE.md is corrected to match. Steps 2-5 were done as written.

build/android/ is gitignored (.gitignore:72) and its includes: entry was dropped from Taskfile.yml during plan 009. That was correct when nothing could target Android and is what has to be undone.

  1. wails3 task common:update:build-assets — beta.8 embeds internal/commands/build_assets/android/, so this generates the tree.
  2. Remove build/android/ from .gitignore; add build/ios/'s reason to a comment so the asymmetry is explained rather than looking like an oversight.
  3. Add android: ./build/android/Taskfile.yml to Taskfile.yml's includes:.
  4. Gitignore the tree's own output, or the repo grows a few hundred Gradle intermediates. ljos has exactly this problem — its app/build/android/app/build/** is committed. Ignore:
    • build/android/app/build/
    • build/android/app/src/main/jniLibs/
    • build/android/overlay.json and build/android/gen/
  5. make build-prod and make test still pass — the new include must not perturb the desktop path.

The refresh hazard has to be written down. CLAUDE.md's Packaging section already says build/'s platform metadata is regenerated from build/config.yml and hand edits are lost. Phase 2 edits build.gradle by hand. Extend that paragraph to name build/android/app/build.gradle specifically, because the loss is silent and the symptom (a debug-signed APK) appears months later as a failed update.

Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16]

Narrower than planned, because beta.8's scaffold is ahead of ljos's beta.3: the release signing config already exists and reads the four ANDROID_KEYSTORE_* variables with a debug-keystore fallback. So this phase was identity and versioning only. Verified end to end:

package app.yellowjacket (was com.wails.app)
versionCode / versionName 10301 / 1.3.1, from YJ_VERSION_CODE / YJ_VERSION
label YellowJacket
signing throwaway keystore -> Signer #1 DN: CN=YellowJacket Test, not the debug key
ABIs arm64-v8a + x86_64, both production-stripped

Installs and launches under the new identity. Still exits on the known buildUserDirPath bug, which is phase 0's finding and not this phase's.

Two things this phase learned that the text below did not know:

  • The identity has to be declared twice. applicationId in app/build.gradle is what Gradle installs; APP_ID in build/android/Taskfile.yml is what every adb-driven task targets. ANDROID.md says to set APP_ID in build/config.yml — that does nothing in beta.8, verified with --dry. Both are set, each commented pointing at the other.
  • The launcher activity is not under the applicationId. It stays com.wails.app.MainActivity (the scaffold's Java package), so am start -n app.yellowjacket/.MainActivity resolves the dot against the wrong package and fails. scripts/android-emulator.sh carries the fully-qualified name and a comment saying why.

The keytool PKCS12 note below was confirmed verbatim: given a -keypass differing from -storepass it prints "Different store and key passwords not supported for PKCS12 KeyStores. Ignoring user-specified -keypass value."

Original phase 2 text follows.

Edit build/android/app/build.gradle, following ljos's, whose comments are worth reading before writing this:

  • applicationId "app.yellowjacket" — matches config.yml's productIdentifier. The namespace stays com.wails.app (it is the Java package, not the app identity).
  • versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")Integer.parseInt, not (...) as Integer. Groovy binds the parentheses to versionCode first, so the cast reads as versionCode("1") as Integer, which sets a String and then casts the setter's null return; Gradle fails the whole project with "Value is null" at that line.
  • versionName System.getenv("YJ_VERSION") ?: "0.0.0".
  • abiFilters 'arm64-v8a', 'x86_64'.
  • A release signing config reading ANDROID_KEYSTORE_FILE / _PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD, falling back to the debug keystore only when no keystore is supplied.

Android orders releases by an integer and refuses anything not greater than what is installed. A hardcoded versionCode 1 means the first install is the last: every later build is rejected as a downgrade and the only fix is an uninstall. 1.3.1 -> 10301, monotonic as long as minor and patch stay under 100.

Signing is not optional past the first install. Android refuses to update an app whose signing key changed, and the debug keystore differs between every machine and every runner — so an unsigned CI build is a decision to reinstall by hand forever. The job must refuse to build without the keystore rather than quietly produce an APK that can never be updated.

There is one password and two required secrets. keytool has defaulted to PKCS12 since JDK 9 regardless of the .jks extension, and PKCS12 cannot hold a separate key password — given -keypass it warns and ignores it. So ANDROID_KEY_PASSWORD defaults to the store password and ANDROID_KEY_ALIAS to yellowjacket. Asking for a second password that cannot exist is how someone sets a wrong value and debugs Gradle at midnight.

Add make androidPATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package:fat, beside build-prod. make skill-check fails on a documented target that does not exist, so document it only once it does.

Phase 3 — the workflow [DONE 2026-08-16]

.gitea/workflows/android-apk.yml, plus docs/android-release.md as the operating document its error messages point at (phase 4's documentation half; the secrets themselves still have to be created by hand — see the table there).

Three departures from the text below, all argued in the file:

  • No continue-on-error. The plan inherited it from ljos, where the Android job shares a pipeline with a server deploy that must never go red over a phone build. Here it is standalone and can neither delay nor redden anything, so a release step that fails silently is strictly worse than one that fails visibly.
  • No cached wails3 binary. The plan budgeted for ljos's tools-bin copy. Unnecessary: the CLI is a vendored go tool, and the runner already bind-mounts GOCACHE/GOMODCACHE for every job, so it is warm from ci.yml's own make bindings-check. The GTK and WebKit dev headers are still installed, because go tool wails3 links them.
  • A fourth cache volume, /cache/gradle. Not in the plan and worth ~700 MB a run.

Four publish-gates were added and each was checked against a real APK: both ABIs present, versionCode equal to the one derived from the tag, a non-empty artifact, and not signed with the debug key — verified by pointing the check at a deliberately debug-signed build, which it refused.

Rehearsed locally with the exact CI invocation (make android ANDROID_SDK=... ANDROID_NDK=..., YJ_VERSION, YJ_VERSION_CODE, a throwaway keystore): app.yellowjacket, versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs, Signer #1 DN: CN=YellowJacket. Not yet run on the runner.

Original phase 3 text follows.

New file: .gitea/workflows/android-apk.yml. Not a job in ci.yml. ci.yml runs on every branch push and is the workflow that gates; the runner is capacity 1, and a 45-minute Android build in it would put every push behind an SDK download.

on:
  push:
    tags: ["v*"]
  workflow_dispatch:

This is where the baseline genuinely diverges. ljos computes its version in CI (scripts/next-version.sh) and gates the Android job on needs.release.outputs.version != '', with an always() whose absence would silently kill the manual path. This repo has no release automation — tags are pushed by hand and homebrew-formula.yml already keys on v*. So there is no needs:, no always(), and no status function to get wrong: the tag is the version, and a dispatch falls back to git describe --tags --abbrev=0.

Container, matching ci.yml's conventions (ubuntu:24.04, clone by hand with PACKAGE_TOKEN rather than actions/checkout, which is a JS action needing node before any step has installed it):

container:
  image: ubuntu:24.04
  volumes:
    - /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
    - /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk

The SDK path must be inside the runner's valid_volumes allowlist — a directory outside it makes the job fail to start, not silently skip the mount. /cache/tool is already allowed and already holds the Go toolchain ci.yml downloads.

continue-on-error: true and timeout-minutes: 45. Advisory, because a tag's other three workflows must not go red over a phone build, and a backstop because a wedged SDK download must not hold the only runner slot for hours.

Steps:

  1. System packages. ci.yml's set plus unzip and openjdk-17-jdk. libasound2-dev stays — it is for the host wails3 build, not the Android cross-build, which uses oboe.
  2. Go toolchain — reuse ci.yml's /cache/tool/go block verbatim.
  3. Android SDK and NDK (cached). ljos's install_if_missing idempotent guard, unchanged: cmdline-tools 11076708, platform-tools, platforms;android-34, build-tools;34.0.0, ndk;26.3.11579264. sdkmanager is itself idempotent but still spends minutes verifying, which is why the explicit directory guards are there. ~3 GB and most of the job's wall clock on the first run; a directory listing after.
  4. wails3. Cheaper here than in ljos, which pins go install …/wails3@$version against app/go.mod. This repo vendors the CLI (go tool wails3, scripts/toolbin/wails3), so the version is already pinned by go.mod and there is nothing to drift. It still links GTK and WebKit, so cache the built binary in /cache/android-sdk/tools-bin keyed on the wails version — and note ljos's finding that caching the binary alone turned a slow job into a broken one: wails3 is dynamically linked, so the runtime packages are needed even on a cache hit. Here they are already in step 1.
  5. Frontend + codegen. pnpm install --frozen-lockfile && pnpm build (pnpm, not ljos's npm), then make generate. main.go embeds frontend/dist, so nothing Go-side typechecks without it.
  6. Decode the keystore. Refuse to build if ANDROID_KEYSTORE_B64 is unset, with the sentence explaining why (Phase 2). Decide the absolute path here and export it via $GITHUB_ENV${{ env.HOME }} evaluates to an empty string in Gitea's expression context, which turned $HOME/x.jks into /x.jks and surfaced as a missing file fifty-five seconds into a Gradle run.
  7. Build. Compute YJ_VERSION_CODE from the tag, verify the keystore opens with keytool -list before Gradle does (Gradle only notices at :app:validateSigningRelease, a minute in, and reports it as a missing file), then make android.
  8. Verify the signature. apksigner verify --print-certs, and print the SHA-256 with the note that a change to it breaks every future update. Nothing here pipes into head: under set -o pipefail, head -1 exits early, the producer takes SIGPIPE, and the step fails with 141 after printing a perfectly good APK. Use find … -print -quit and a captured variable.
  9. Publish to api/packages/${OWNER}/generic/yellowjacket-android, authenticating --user "${OWNER}:${PACKAGE_TOKEN}" — the same credential pair arch-package.yml already uses, not ljos's REGISTRY_USER/REGISTRY_TOKEN. Two copies: a versioned one for history and a fixed latest/yellowjacket.apk that Obtainium watches. Gitea refuses to overwrite, so delete latest first. The generic registry is readable without credentials, which is what lets Obtainium poll a plain URL with no token and no public source mirror.

Phase 4 — secrets and documentation

Secrets to create on the repo (all under Settings → Actions → Secrets):

Secret Required Note
ANDROID_KEYSTORE_B64 yes base64 -w0 yellowjacket-release.jks
ANDROID_KEYSTORE_PASSWORD yes
ANDROID_KEY_ALIAS no defaults to yellowjacket
ANDROID_KEY_PASSWORD no defaults to the store password
PACKAGE_TOKEN already exists used by arch-package.yml

Write the keytool command, the Obtainium URL and the signing-key warning into a docs page — this is the part of ljos's setup that lives in docs/clients.md and is referenced from the workflow's error messages, so the messages have somewhere to point.

Then extend CLAUDE.md's CI section: it currently says "four workflows, three of them package and publish; only ci.yml gates". That becomes five, with the same sentence still true.

Order and stopping points

Phase 0 gates everything. Phases 12 are one commit's worth of work and are verifiable locally without CI. Phase 3 is the only part that needs a runner, and its first run will be slow and will probably fail once on something in the SDK step — budget for that rather than treating it as a setback.

Stop after Phase 0 if the c-shared link does not work. Every later phase is scaffolding for a build that does not exist, and the honest outcome is a NOTES.md entry saying which package cannot cross-compile and what it would take.