Section A of plan 016 is closed and B1 is decided, so the three tenses move together: CLAUDE.md for what mediacontrols now is, the skill for what to run, NOTES.md for what was measured and when. The entry worth reading is the one that disproves a claim written here earlier in the same session. Dropping x86_64 was expected to make make android-install fail with INSTALL_FAILED_NO_MATCHING_ABIS. Measured, it installs and launches: Google's google_apis x86_64 images carry arm64 translation (abilist = x86_64,arm64-v8a), so the loader maps lib/arm64/libwails.so and runs it. It dies before any of our code with SIGILL, and the disassembly names the reason exactly -- `mrs x0, ID_AA64ISAR0_EL1`, Go's internal/cpu reading the arm64 feature register at runtime init, which the translator does not implement. So no Go binary starts under it, and that is not a property of this app. Which closes the last plausible shortcut. There are now three distinct ways this app fails on an x86_64 Android -- seccomp on the x86_64 build, an unimplemented system register on the translated arm64 one, and a real device still unverified -- and none of them is a bug in it. A phone remains the only verification path. Plan 016 also carries the B2 scope, now decided rather than recommended: option 1's data model with option 2's surface. The phone gets home, library browse, now-playing-as-a-view, the queue, search and playlists; it does not get autotag, downloads, Explore or the 93-control Settings page, and each of those has a reason written beside it. One rule for the work: no view forks, because a phone template that copies a view's is two templates to fix every bug in.
5.7 KiB
Releasing the Android APK
.gitea/workflows/android-apk.yml builds a signed arm64-v8a APK on
every v* tag and publishes it to Gitea's
generic package registry, which is readable without credentials —
which is what lets Obtainium poll a plain URL with no token.
https://git.ljones.me/api/packages/yonlu/generic/yellowjacket-android/latest/yellowjacket.apk
A versioned copy is kept alongside it at
…/yellowjacket-android/<version>/yellowjacket-<version>.apk.
The app on the device is app.yellowjacket. Its launcher activity is
com.wails.app.MainActivity — a different package, because that is the
Wails scaffold's Java package and renaming it would mean renaming its
source. Every am start needs the fully-qualified form.
The signing key is the thing you cannot lose
Android refuses to update an app whose signing certificate changed. There is no override and no recovery: the only way to install a build signed with a different key is to uninstall first, which takes the user's library, playlists and play counts with it. The key therefore outlives every other secret in this repo.
Two consequences are wired into the workflow rather than left to
discipline. It refuses to build when ANDROID_KEYSTORE_B64 is
absent, instead of falling through to Gradle's debug-keystore default —
a debug key differs between every machine and every CI runner, so a
build signed with one is un-updatable from the moment it is installed.
And it refuses to publish an APK whose certificate reads
CN=Android Debug, which is the same rule enforced one step later, on
the artifact rather than the configuration.
Creating it
keytool -genkeypair -v \
-keystore yellowjacket-release.jks \
-alias yellowjacket \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass '<a long random password>' \
-dname "CN=YellowJacket, O=Shadow-Puppet, C=GB"
Do not pass -keypass. keytool has produced PKCS12 keystores by
default since JDK 9 — the .jks extension does not change the format —
and PKCS12 cannot hold a key password distinct from the store password.
Given one it tells you so and ignores it:
Warning: Different store and key passwords not supported for PKCS12
KeyStores. Ignoring user-specified -keypass value.
So there is one password. Asking for a second is how someone sets a wrong value and then debugs Gradle at midnight.
Back the .jks up somewhere that is not this repository and not this
server. Record the certificate fingerprint the build prints
(Signer #1 certificate SHA-256 digest); if it ever changes, updates
have already broken.
The secrets
Repository → Settings → Actions → Secrets.
| Secret | Required | Notes |
|---|---|---|
ANDROID_KEYSTORE_B64 |
yes | base64 -w0 yellowjacket-release.jks |
ANDROID_KEYSTORE_PASSWORD |
yes | the -storepass above |
ANDROID_KEY_ALIAS |
no | defaults to yellowjacket |
ANDROID_KEY_PASSWORD |
no | defaults to the store password, and per the PKCS12 note it cannot differ |
PACKAGE_TOKEN |
already set | shared with arch-package.yml; publishes to the registry |
base64 -w0 yellowjacket-release.jks # paste as ANDROID_KEYSTORE_B64
Cutting a release
git tag v1.3.1
git push origin v1.3.1
That is the whole trigger. homebrew-formula.yml keys on the same tag,
so the desktop formula and the APK are cut together. The version code
Android orders releases by is derived from the tag — 1.3.1 → 10301,
monotonic as long as minor and patch stay below 100 — so do not
publish 1.100.0, and never move a tag that has already been built.
workflow_dispatch rebuilds without a new tag, taking an explicit
version input or falling back to the latest v* tag.
What the workflow checks before publishing
- the APK exists and is non-empty;
- it carries exactly one ABI (
native-code: 'arm64-v8a'). x86_64 Android cannot run this app at all —modernc.org/libcissues a rawlstatsyscall that Android's seccomp policy forbids, on every x86_64 device and not merely the emulator — so an x86_64 slice would be ~31 MB that runs nowhere, and its reappearance means someone put the ABI back inapp/build.gradlewithout knowing that; - its
versionCodeis the one derived from the tag; - it is not signed with the debug key.
The keystore is also opened with keytool -list before Gradle runs,
because Gradle only notices a bad password at
:app:validateSigningRelease — a minute of build time in — and reports
it as a missing file rather than a wrong password.
Caches, and the first run
The job mounts four cache volumes; on a cold runner the first build is slow and everything after it is not.
| Volume | Holds | Cold cost |
|---|---|---|
/cache/android-sdk |
SDK, platform, build-tools, NDK r26d | ~2 GB |
/cache/gradle |
GRADLE_USER_HOME — wrapper + AGP graph |
~700 MB |
/cache/tool |
the Go toolchain (shared with ci.yml) |
~200 MB |
/cache/pnpm-store |
pnpm store (shared with ci.yml) |
— |
Every path must be inside the runner's valid_volumes allowlist. One
that is not makes the job fail to start rather than silently skip
the mount.
The NDK is pinned to r26d (26.3.11579264). Newer NDKs have broken
the Wails Android build before; it is a version, not a floor.
Building one locally
make android # unsigned-ish: debug key, versionCode 1, 0.0.0
YJ_VERSION=1.3.1 YJ_VERSION_CODE=10301 \
ANDROID_KEYSTORE_FILE=$PWD/yellowjacket-release.jks \
ANDROID_KEYSTORE_PASSWORD=... ANDROID_KEY_ALIAS=yellowjacket \
make android # what CI produces
Running it is a separate tier — see
.pi/skills/yellowjacket-dev/references/android-tier.md, and read its
first section before you try, because a failing Android build looks
exactly like a working one.