docs: record what the Android work established and disproved
Build & publish Arch package / arch-package (push) Successful in 2m26s
CI / check (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m51s

CLAUDE.md said `wails3 task common:update:build-assets` regenerates
build/ios/ and build/android/. It does not: in beta.8 that command
extracts only updatable_build_assets, which is darwin/ios/linux/windows,
and the android tree comes from `generate build-assets`. It also said
nfpm's homepage and license are left alone by the refresh -- a comment
in that file says the same -- and a refresh reset them to wails.io and
MIT. Both corrected, and the CI section now describes five workflows.

NOTES.md gains the measurements: what cross-compiles and what does not,
the emulator environment, the Wails Android documentation's own two
errors, and the one line that stops the app at runtime --
buildUserDirPath switches on runtime.GOOS and Android takes the default
branch returning errUnsupportedOS, so main() calls os.Exit(1) six
milliseconds after the JNI bridge comes up.

The fix is a documented, build-tag-free API:
application.Mobile.StoragePath() returns the app's private files
directory and returns "" on desktop, and resolveUserDirPath already
lets YJ_HOME override the path on every OS. Deliberately not taken here
-- plan 015 is a pipeline, not a port, and the larger question it does
not answer is that open-directory dialogs return an error on Android
while this app's entire first run is "choose your music folder".
This commit is contained in:
2026-08-16 15:31:18 -04:00
parent 0c6ca72cf1
commit a7a33527c4
3 changed files with 609 additions and 10 deletions
+193
View File
@@ -2394,3 +2394,196 @@ And `tag_status` was only ever written by the *insert* path, so a file
another tagger stamped after import kept `untagged` for ever and its
folder kept asking; `updateAudioFile` promotes it now, guarded on
`untagged` so a deliberate `user_skipped_permanent` survives a rescan.
## Android cross-compiles, unchanged (measured 2026-08-16)
Plan 015's phase 0 gate, and it passed further than it was asked to: the
whole app builds for Android and produces a working 27 MB fat APK with
**no source changes at all**.
Environment: Arch's `android-ndk-26` (`/opt/android-ndk`, r26d /
26.3.11579264 — the pinned version), platform `android-35` and
build-tools 34.0.0 from `~/Android/Sdk`. Note that Arch's
`/opt/android-sdk` carries *no* platforms, so `ANDROID_HOME` has to
point at `~/Android/Sdk` for the Gradle half while `ANDROID_NDK_HOME`
points at `/opt/android-ndk` for the Go half.
```
export ANDROID_NDK_HOME=/opt/android-ndk
export ANDROID_HOME="$HOME/Android/Sdk" ANDROID_SDK_ROOT="$HOME/Android/Sdk"
cd frontend && pnpm build && cd .. # main.go embeds frontend/dist
PATH="$PWD/scripts/toolbin:$PATH" go tool wails3 task android:package:fat
```
Results, all first-try:
| | |
|---|---|
| `libwails.so` arm64-v8a | 29.9 MB, production, stripped |
| `libwails.so` x86_64 | 31.8 MB, production, stripped |
| `bin/yellowjacket.apk` | 27.3 MB, both ABIs |
| Go compile, per ABI | ~9 s |
| Gradle assemble | ~13 s cold |
**The dependency that looked fatal is fine.** A `CGO_ENABLED=0` probe of
`./backend/... ./internal/...` for `android/arm64` compiles *everything*
except two packages, and both fail only because their Android
implementation is cgo: `ebitengine/oto/v3` (`driver_android.go` needs its
bundled **oboe** C++ backend) and `wails/v3/pkg/application` (the JNI
bridge). Both are exactly what the NDK supplies. `modernc.org/sqlite` —
the whole database layer, and the thing most likely to have no Android
target — is clean. Confirmed in the linked object rather than inferred:
`nm -D` shows `oto_oboe_Play` and the `oboe::` symbols, `readelf -d`
shows `libOpenSLES.so` as NEEDED, and the
`Java_com_wails_app_WailsBridge_native*` exports are present. The audio
backend is genuinely linked, not stubbed.
Four things found on the way that are not obvious:
- **`wails3 update build-assets` does not generate `build/android/`.** In
beta.8 it extracts only `internal/commands/updatable_build_assets`,
which is darwin/ios/linux/windows. The android tree comes from
`generate build-assets`, which extracts the *whole* asset FS and would
rewrite all of `build/`. So it was generated into a scratch dir and
`android/` copied across. CLAUDE.md claimed the refresh regenerates it;
that was wrong, and is corrected.
- **`update build-assets` does clobber nfpm's `homepage` and
`license`**, which `build/linux/nfpm/nfpm.yaml` says in a comment it
leaves alone. It reset them to `https://wails.io` and `MIT`. The
comment is wrong; those two fields need re-checking after any refresh.
- **The scaffold's `package:fat` shipped a debug arm64 library.**
`build` forwards `ARCH` to `compile:go:shared` but not `PRODUCTION`,
so the arm64 leg recomputed `BUILD_FLAGS` against an unset
`.PRODUCTION` and took the debug branch — while amd64, which
`package:fat` calls directly with `PRODUCTION: "true"`, was correct.
A release APK therefore carried a 40 MB unstripped debug library for
the phone ABI and a 31 MB production one for the emulator. Fixed in
`build/android/Taskfile.yml`, which is this repo's one edit to that
scaffold file and is commented as such. 34 MB APK before, 27 after.
- **The generated APK is not yet an identity.** `com.wails.app`,
`versionCode 1`, `versionName 1.0`, signed `CN=Android Debug`. That is
plan 015 phase 2 and none of it is a surprise, but it is worth knowing
that the scaffold happily produces an installable-once,
never-updatable APK by default.
**Not established:** that it *runs*. There is no AVD or system image on
this machine and no device attached, so nothing has launched the APK.
Every runtime concern plan 015 lists as out of scope is still out of
scope and still real — MPRIS in particular is compiled *in*, because
Go's `android` GOOS implies the `linux` build tag.
## The Android build runs, and stops on one line (measured 2026-08-16)
The APK installs and launches on an emulator. `libwails.so` loads, the
JNI bridge comes up — and the process is gone six milliseconds later.
**The cause is `backend/system/buildUserDirPath`.** It switches on
`runtime.GOOS` with cases for `darwin`, `linux` and `windows` and a
`default:` returning `errUnsupportedOS`. `runtime.GOOS` is `"android"`,
so it takes the default, `NewYellowJacketApp` fails, and `main()` calls
`os.Exit(1)`. `YJ_HOME` overrides that path on every OS, so an
`android` case pointing at the app-private directory is the shape of
the fix. It is the *first* thing that stops it, not the only one.
**What cost the time was not finding the bug, it was that the failure
is invisible in all three places you would look.** Worth knowing before
meeting it:
- **Go's stdout does not reach logcat.** An app's fd 1 and 2 go to
`/dev/null`, so the `slog` line naming the error is discarded.
`setprop log.redirect-stdio true` does not help — that redirects the
*Java* runtime's `System.out`, not a c-shared native library's.
- **`os.Exit` leaves no evidence.** No panic, no `AndroidRuntime`
stack, nothing in `/data/tombstones`, nothing in `logcat -b crash` or
dropbox. The only signal present is `Zygote: exited due to signal 9`,
which reads as "the system killed it" and sends you looking at the
low-memory killer.
- **ActivityManager restarts it faster than you can observe it.**
`pidof` always answers and `am start` always says `Status: ok`, so
the app looks alive while crash-looping several times a second. The
honest check is whether it is the *same pid* a few seconds later,
which is what `make android-smoke` asserts.
The tell is `I/WailsBridge: Wails bridge initialized` followed
immediately by a new pid doing the same thing.
**Emulator environment**, which is not the obvious one on Arch: Gradle
needs a *platform*, and `/opt/android-sdk` (the `android-sdk` package)
has an NDK and build-tools but an empty `platforms/`. So `ANDROID_HOME`
points at `~/Android/Sdk` (user-owned, where sdkmanager writes) while
`ANDROID_NDK_HOME` points at `/opt/android-ndk` — two SDKs, one for
each half of the build. The image is
`system-images;android-35;google_apis;x86_64` (~3.5 GB with the
emulator sdkmanager pulls alongside it): `google_apis` rather than
`default` because this is a WebView app and that image carries the
Chrome-based WebView. KVM is present and usable here; without it a 30 s
boot becomes tens of minutes, which reads as a hung target.
Operating all of this is `scripts/android-emulator.sh` and the
`make android-*` targets, documented in
`.pi/skills/yellowjacket-dev/references/android-tier.md`.
## What the Wails v3 Android docs say, and where they are wrong (2026-08-16)
Read after phase 0, before phase 2. Sources: `ANDROID.md` shipped inside
`wails/v3@v3.0.0-beta.8` (authoritative for our exact version) and
`v3.wails.io/guides/mobile/*`.
**Two claims in `ANDROID.md` are wrong for beta.8, and both were
checked.** Its Configuration section says to put `APP_ID: com.example.
myapp` in `build/config.yml` and that this "controls the package name".
Neither half holds. `wails3 task` builds its variable set from CLI
`KEY=VALUE` arguments and the Taskfile tree and **never reads
`config.yml`** (`internal/commands/task.go`); adding `APP_ID` there and
running `android:run:device --dry` still emits
`am start -n com.wails.app/`. And `APP_ID` feeds only the adb commands
in the android Taskfile — uninstall, launch, log filter — never Gradle,
whose `applicationId` is a literal in `app/build.gradle`. So the
identity is necessarily declared **twice** and nothing enforces
agreement. Both are set now, each with a comment pointing at the other.
**The fix for the crash we found is a documented API.**
`application.Mobile.StoragePath()` returns the app's private internal
files directory (`getFilesDir()` on Android, Application Support on
iOS) and — the useful part — is **build-tag-free**: `mobile.go` declares
the interface and `mobile_stub.go` returns `""` on desktop. Since
`resolveUserDirPath` already lets `YJ_HOME` override the path on every
OS, the whole fix is to set that override from `StoragePath()` early in
`main()` when it is non-empty. No `//go:build` split, no new import in
`backend/system` (which must stay Wails-free — the `indexbuild` tag
split exists for exactly that), and desktop behaviour is untouched
because the stub returns empty.
The same section gives the general rule: branch on
`application.System.IsMobile()` / `IsPlatform(application.PlatformAndroid)`
rather than build tags, because it compiles everywhere.
**`android` implies `linux` is documented**, which confirms rather than
discovers the MPRIS problem: `//go:build linux` files are in the Android
build and desktop-Linux-only ones need `linux && !android`.
**A finding for the runtime plan, not this one: the folder picker does
not exist on Android.** Open-*directory* dialogs "return an error — SAF
yields tree URIs, not filesystem paths", and save-file dialogs likewise.
This app's entire first run is "choose your music folder", and its
library model is filesystem paths. That is a design problem, not a
porting detail, and it is larger than the data-directory one.
**The scaffold ships its own android tasks**, and they are worth knowing
before writing anything: `android:run`, `run:device`, `deploy-emulator`,
`deploy-device`, `package`, `package:fat`, `bundle`/`bundle:fat` (AAB
for Play), `studio`, `device:list`, `logs`, `logs:all`, `clean`, and an
internal `ensure-emulator`. `make android-*` deliberately does not wrap
most of them. Two reasons it does not just use `android:logs`: that task
greps logcat for `(Wails|yellowjacket)`, which matches the `WailsBridge`
tag but **not** the app's own process tag (`app.yellowjacket`, lowercase)
and **not** `ActivityManager`'s "has died" line — the one that tells you
it crashed. And `ensure-emulator` takes whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be stopped or
sequenced by a Makefile.
Two smaller things. Debug builds log framework diagnostics to logcat
under the `Wails` tag and are inspectable from `chrome://inspect`;
production builds compile that out — so a debug APK is the more
informative one when something is wrong. And the docs recommend
`build-tools;35.0.0`; 34.0.0 is what is installed here and builds fine.
@@ -0,0 +1,383 @@
# 015 — Android release pipeline
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/v3``driver_android.go` needs the bundled **oboe**
C++ backend. Oto supports Android natively; there is no Java audio
glue to write.
- `wails/v3/pkg/application``mobile_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 android``PATH="$(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.
```yaml
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):
```yaml
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.
+33 -10
View File
@@ -1768,10 +1768,24 @@ Feature branches and PRs are the norm, but direct pushes to `main` are allowed.
## CI
Four workflows in `.gitea/workflows/`. Three of them package and
publish (`arch-package`, `homebrew-formula`, `index-artifact`); only
`ci.yml` gates, and it is the one to look at when deciding whether a
push was healthy.
Five workflows in `.gitea/workflows/`. Four of them package and
publish (`arch-package`, `homebrew-formula`, `index-artifact`,
`android-apk`); only `ci.yml` gates, and it is the one to look at when
deciding whether a push was healthy.
**`android-apk.yml` is the only one keyed on a tag and the only one
that can lose something irrecoverable.** It builds the signed fat APK
on every `v*` tag and publishes it to the *generic* registry, which is
readable without credentials — the reason Obtainium can poll a plain
URL. Android refuses to update an app whose signing certificate
changed, and the only remedy is an uninstall that takes the user's
library with it, so the job **refuses to build** without the keystore
secret rather than falling through to Gradle's debug-key default, and
**refuses to publish** an artifact whose certificate says `CN=Android
Debug`. It is deliberately not a job in `ci.yml`: that workflow runs on
every branch push, this one takes tens of minutes on a cold cache, and
the runner has capacity 1. `docs/android-release.md` is the operating
document.
Two jobs, both in an `ubuntu:24.04` container:
@@ -1853,11 +1867,20 @@ four bit the packaging recipes:
**`build/`'s platform metadata is generated from `build/config.yml`.**
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
one file — so a hand edit to any of them is lost on the next refresh,
and the two fields it does *not* own (nfpm's `homepage` and `license`)
say so in place. That refresh also regenerates `build/ios/` and
`build/android/`, which this repo does not carry: they are gitignored
rather than deleted-and-rediscovered, and their `includes:` entries
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the
one file — so a hand edit to any of them is lost on the next refresh.
nfpm's `homepage` and `license` say in place that the refresh does not
own them, and **that comment is wrong**: a refresh reset them to
`https://wails.io` and `MIT`. Re-check those two after any refresh.
**That refresh does not touch the mobile trees**, contrary to what this
file said for five phases. `update build-assets` extracts only
`updatable_build_assets` (darwin/ios/linux/windows); `build/android/`
and `build/ios/` come from `generate build-assets`, which rewrites the
whole of `build/`. So `build/android/` is **committed and hand-edited
like source** — it was generated once into a scratch directory and
copied across (plan 015), it carries one deliberate edit to its
`Taskfile.yml`, and only its output is gitignored. `build/ios/` is
still not carried and its `includes:` entry is still dropped.
`build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version.