Files
yellowjacket/docs/android-release.md
T
logan 0c6ca72cf1 ci(android): publish a signed APK on every version tag
Builds the fat APK and puts it in Gitea's *generic* package registry,
which unlike the repository is readable without credentials -- the
reason an Obtainium client can poll a plain URL with no token and no
public mirror of the source. A versioned copy for history, a fixed
`latest` URL to watch.

**Its own workflow, not a job in ci.yml.** That workflow runs on every
branch push and is the one that gates; this takes tens of minutes on a
cold cache and the runner has capacity 1, so hanging it off the gate
would put every push behind an SDK download.

**Keyed on the tag.** The ljos pipeline this is modelled on computes a
version in CI and cuts the release itself, then gates its Android job
on needs.release.outputs.version with an always() whose absence
silently kills the manual path. This repo has no release automation --
tags are pushed by hand and homebrew-formula.yml already keys on v* --
so the tag is the version and none of that machinery, or its failure
modes, is needed.

**No continue-on-error**, which that pipeline does carry: there the
Android job shares a workflow 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 would be
strictly worse than one that fails visibly.

Four gates before anything is published, each checked against a real
APK: a non-empty artifact, both ABIs present, a versionCode equal to
the one derived from the tag, and -- verified by pointing it at a
deliberately debug-signed build, which it refused -- **not signed with
the debug key**. 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 also refuses to *build* without the
keystore secret rather than falling through to Gradle's debug default.

The keystore is 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. And nothing
pipes into `head`: under pipefail it exits after one line, the producer
takes SIGPIPE and the step fails with 141 having already printed a
perfectly good APK.

Two secrets, not four. keytool has produced PKCS12 by default since
JDK 9 regardless of the .jks extension, and PKCS12 cannot hold a key
password distinct from the store password -- given one it says so and
ignores it. So ANDROID_KEY_PASSWORD defaults to the store password and
the alias to a documented default.

The Wails CLI needs no caching hack here: it is a vendored `go tool`
and the runner already bind-mounts GOCACHE for every job, so it is warm
from ci.yml's own bindings-check. A fourth cache volume for
GRADLE_USER_HOME saves ~700MB a run.
2026-08-16 15:31:18 -04:00

5.5 KiB

Releasing the Android APK

.gitea/workflows/android-apk.yml builds a signed fat APK (arm64-v8a + x86_64) 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.110301, 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 both ABIs (native-code: 'arm64-v8a' 'x86_64'), or it is not the fat APK it claims to be;
  • its versionCode is 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.