name: Build & publish the Android APK # The fifth workflow, and the second that publishes. It builds a signed # arm64-v8a APK on every version tag and puts it in # Gitea's *generic* package registry, which — unlike the repository — is # readable without credentials. That is what lets an Obtainium client # poll a plain URL with no token and no public mirror of the source. # # **Why its own file rather than a job in ci.yml.** `ci.yml` runs on # every branch push and is the workflow that gates; this one runs on # tags only, takes tens of minutes on a cold cache, and the runner has # capacity 1. Hanging it off the gate would put every push behind an # SDK download. # # **Why it is keyed on the tag.** The ljos pipeline this is modelled on # computes a version in CI and cuts the release itself, then gates the # 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. # # It deliberately does **not** carry `continue-on-error`. In ljos the # Android job shared 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 would be # strictly worse than one that fails visibly. on: push: tags: ["v*"] workflow_dispatch: inputs: version: description: "Version to build (default: the latest v* tag)" required: false concurrency: group: android-${{ github.ref }} cancel-in-progress: true jobs: apk: runs-on: ubuntu-latest timeout-minutes: 60 container: image: ubuntu:24.04 # /cache/tool holds the Go toolchain ci.yml already downloads. # The other three are this workflow's own and are ~4 GB between # them, which is most of its wall clock on a cold run: # android-sdk the SDK, the NDK and the platform (~2 GB) # gradle GRADLE_USER_HOME — the wrapper distribution and # the AGP dependency graph (~700 MB) # pnpm-store shared with ci.yml # Every path must be inside the runner's `valid_volumes` allowlist: # a directory outside it makes the job **fail to start**, rather # than silently skipping the mount. volumes: - /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool - /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk - /home/logan/docker/gitea/data/runner/cache/gradle:/cache/gradle - /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store env: PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} SERVER_URL: ${{ github.server_url }} REPO: ${{ github.repository }} OWNER: ${{ github.repository_owner }} SHA: ${{ github.sha }} REF_NAME: ${{ github.ref_name }} DEBIAN_FRONTEND: noninteractive GO_VERSION: '1.25.0' npm_config_store_dir: /cache/pnpm-store # The Go half wants the NDK; the Gradle half wants a platform. ANDROID_HOME: /cache/android-sdk ANDROID_SDK_ROOT: /cache/android-sdk GRADLE_USER_HOME: /cache/gradle # Pinned, not "whatever sdkmanager installs": newer NDKs have # broken the Wails Android build before, and r26d is what plan # 015 phase 0 was verified against. NDK_VERSION: 26.3.11579264 # The registry package name. Obtainium watches # /api/packages//generic/yellowjacket-android/latest/yellowjacket.apk PACKAGE_NAME: yellowjacket-android steps: # libgtk-4-dev and libwebkitgtk-6.0-dev are here even though # nothing in this job builds a desktop app: `wails3` is the task # runner the whole Android build goes through, and the CLI links # the GTK/WebKit bindings, so `go tool wails3` cannot compile # without them. libasound2-dev is oto's `pkg-config -- alsa` # probe, for the same reason (the *Android* build uses oboe, not # ALSA — this is the host toolchain only). - name: System packages run: | set -eu apt-get update -qq apt-get install -y -qq --no-install-recommends \ ca-certificates curl git jq unzip zip \ build-essential pkg-config \ libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \ openjdk-21-jdk-headless # By hand rather than actions/checkout: that is a JS action and # needs node inside the container before any step has installed # it. Same approach as the other four workflows. - name: Clone repo at this commit run: | set -eu git clone --quiet \ "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src git -C /src checkout --quiet --detach "$SHA" git config --global --add safe.directory /src git -C /src log --oneline -1 # A tag push carries the version in its own name. A manual run has # no tag, so it takes the input or falls back to the latest v* tag, # which is what a hand-triggered rebuild wants anyway. - name: Resolve the version id: version working-directory: /src run: | set -eu v="${{ inputs.version }}" if [ -z "$v" ]; then case "$REF_NAME" in v*) v="$REF_NAME" ;; *) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "v0.0.0") ;; esac fi v="${v#v}" # v0.0.0 is semantic-release's version floor, not a shipment — # see the bootstrap step in release.yml. It is skipped cleanly # rather than failing the guard below, because a 45-minute red # run against a tag that was never meant to ship is noise, and # this is the most expensive of the four workflows a tag fires. if [ "$v" = "0.0.0" ]; then echo "v0.0.0 is the version floor, not a release; nothing to build" echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "skip=false" >> "$GITHUB_OUTPUT" # Android orders releases by an integer and refuses anything # not greater than what is installed. 1.3.1 -> 10301, which # increases as long as minor and patch stay below 100. IFS=. read -r maj min pat <&2 exit 1 fi echo "version=$v" >> "$GITHUB_OUTPUT" echo "code=$code" >> "$GITHUB_OUTPUT" echo "tag=v$v" >> "$GITHUB_OUTPUT" echo "building $v (versionCode $code)" # Releases restarted at 0.0.1 when they became automatic (plan # 017), so versionCode restarted at 1 — *below* the 10300 an # installed 1.3.0 build carries. Android refuses a downgrade # outright, and the only remedy is an uninstall, which takes the # user's library with it. Said here because this is the file # that computes the number. if [ "$code" -lt 10600 ]; then echo echo "note: versionCode $code is below the 10600 that v1.6.0 shipped." echo " An existing install must be removed before this one will" echo " install, and that removal takes its library with it." fi - name: Go toolchain if: steps.version.outputs.skip == 'false' run: | set -eu if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then mkdir -p /cache/tool && rm -rf /cache/tool/go curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz fi echo "/cache/tool/go/bin" >> "$GITHUB_PATH" /cache/tool/go/bin/go version - name: Node toolchain if: steps.version.outputs.skip == 'false' run: | set -eu curl -fsSL https://deb.nodesource.com/setup_22.x | bash - apt-get install -y -qq --no-install-recommends nodejs corepack enable node --version # Idempotent by directory check. sdkmanager is itself idempotent # but still spends minutes verifying, so the guards are what make # this cheap on every run after the first. - name: Android SDK and NDK (cached) if: steps.version.outputs.skip == 'false' run: | set -eu mkdir -p "$ANDROID_HOME/cmdline-tools" if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then echo "command line tools: installing" cd /tmp curl -fsSL -o tools.zip \ https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip unzip -q tools.zip rm -rf "$ANDROID_HOME/cmdline-tools/latest" mv cmdline-tools "$ANDROID_HOME/cmdline-tools/latest" else echo "command line tools: cached" fi export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" yes | sdkmanager --licenses >/dev/null 2>&1 || true install_if_missing() { if [ -d "$ANDROID_HOME/$2" ]; then echo "$1: cached" else echo "$1: installing" yes | sdkmanager --install "$1" >/dev/null fi } # android-35 matches compileSdk/targetSdk in # build/android/app/build.gradle. No system image and no # emulator: this job builds, it does not run. install_if_missing "platform-tools" "platform-tools" install_if_missing "platforms;android-35" "platforms/android-35" install_if_missing "build-tools;34.0.0" "build-tools/34.0.0" install_if_missing "ndk;${NDK_VERSION}" "ndk/${NDK_VERSION}" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/${NDK_VERSION}" >> "$GITHUB_ENV" du -sh "$ANDROID_HOME" || true # **Signing is not optional past the first install.** Android # refuses to update an app whose signing key changed and the only # remedy is an uninstall, which takes the user's library with it. # build.gradle falls back to the *debug* keystore when these are # absent, and that key differs between every machine and every # runner — so publishing an unsigned build is a decision to # reinstall by hand for ever. Fail instead. # **Signing is not optional past the first install.** Android # refuses to update an app whose signing key changed and the only # remedy is an uninstall, which takes the user's library with it. # build.gradle falls back to the *debug* keystore when these are # absent, and that key differs between every machine and every # runner — so publishing an unsigned build is a decision to # reinstall by hand for ever. Fail instead. # # Decode, check and build are one step on purpose. Splitting them # would mean either handing the password to a later step through # `$GITHUB_ENV` — where the `env:` dump is only masked for values # that are *verbatim* a secret, so a trimmed one could print in # clear — or repeating the trimming logic in both. - name: Build the signed APK if: steps.version.outputs.skip == 'false' working-directory: /src env: KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} YJ_VERSION: ${{ steps.version.outputs.version }} YJ_VERSION_CODE: ${{ steps.version.outputs.code }} run: | set -eu if [ -z "${KEYSTORE_B64:-}" ]; then echo "ANDROID_KEYSTORE_B64 is not set." echo echo "Building without it signs with the debug key, and every future" echo "update then fails with a signature mismatch. See" echo "docs/android-release.md for the keytool command and the secrets." exit 1 fi if [ -z "${KEYSTORE_PASSWORD:-}" ]; then echo "ANDROID_KEYSTORE_PASSWORD is not set — see docs/android-release.md" >&2 exit 1 fi # The path is decided here rather than composed in an `env:` # block: `${{ env.HOME }}` evaluates to an empty string in # Gitea's expression context, which turns "$HOME/x.jks" into # "/x.jks" — reported by Gradle as a missing file, a minute in. keystore="${RUNNER_TEMP:-/tmp}/yellowjacket-release.jks" printf '%s' "$KEYSTORE_B64" | base64 -d > "$keystore" chmod 600 "$keystore" # **A secret pasted into a web form very often carries a # trailing newline**, and a password is compared byte for byte. # Trim CR and LF from all three, and say so when it mattered — # "the keystore did not open" with a correct password is an # unpleasant thing to debug blind. pass=$(printf '%s' "$KEYSTORE_PASSWORD" | tr -d '\r\n') if [ "${#pass}" -ne "${#KEYSTORE_PASSWORD}" ]; then echo "note: stripped newline(s) from ANDROID_KEYSTORE_PASSWORD" fi alias_want=$(printf '%s' "${KEY_ALIAS:-yellowjacket}" | tr -d '\r\n') keypass=$(printf '%s' "${KEY_PASSWORD:-$pass}" | tr -d '\r\n') # Describe the artifact before trying to open it. A truncated # or mis-pasted base64 yields a file that is the wrong size or # has no keystore header at all, and that is a different # problem from a wrong password. size=$(stat -c %s "$keystore") magic=$(od -An -N4 -tx1 "$keystore" | tr -s ' ' | sed 's/^ //') echo "keystore: $size bytes, first four bytes: $magic" # The fingerprint of the decoded file, so "is the secret the # keystore I have locally?" is answerable without guessing. # A hash of a *public* certificate store gives nothing away, # and the alternative is comparing byte counts by eye. # # sha256sum ~/path/to/yellowjacket-release.jks # # A password that is right for one keystore and wrong for # another is indistinguishable from a wrong password, and this # is the line that distinguishes them. echo " sha256: $(sha256sum "$keystore" | cut -d' ' -f1)" case "$magic" in "30 82"*) echo " header: PKCS12 (keytool's default since JDK 9)" ;; "fe ed fe ed") echo " header: legacy JKS" ;; *) echo " WARNING: not a keystore header. Is the secret the base64 of the .jks?" ;; esac # Open it here rather than letting Gradle discover the problem # at :app:validateSigningRelease, a minute of build time in and # reported as a missing file rather than a bad password. if ! keytool -list -keystore "$keystore" -storepass "$pass" >/tmp/ks.txt 2>/tmp/ks.err; then echo "the keystore did not open with ANDROID_KEYSTORE_PASSWORD." >&2 echo " password length after trimming: ${#pass}" >&2 sed 's/^/ keytool: /' /tmp/ks.err | head -5 >&2 echo >&2 # A password pasted *with its shell quotes* is the one # remaining cause that looks identical to a wrong password: # the secret is two characters longer than the password and # nothing in the error says so. Naming it is safe -- # stripping the quotes and carrying on would not be, since a # password may legitimately contain them. unquoted=$(printf '%s' "$pass" | sed "s/^['\"]//;s/['\"]$//") if [ "$unquoted" != "$pass" ] && keytool -list -keystore "$keystore" -storepass "$unquoted" >/dev/null 2>&1; then echo " ** it opens with the surrounding quotes removed. **" >&2 echo " Re-paste ANDROID_KEYSTORE_PASSWORD without them." >&2 echo >&2 fi echo "Check it locally with the same two values:" >&2 echo " printf %s \"\$SECRET_B64\" | base64 -d > /tmp/k.jks" >&2 echo " keytool -list -keystore /tmp/k.jks -storepass ''" >&2 exit 1 fi echo "keystore opens with the supplied password" # And check the alias now, for the same reason. It defaults to # `yellowjacket`, so a keystore created with any other alias # would otherwise fail deep inside Gradle. if ! keytool -list -keystore "$keystore" -storepass "$pass" -alias "$alias_want" >/dev/null 2>&1; then echo "alias '$alias_want' is not in this keystore. It holds:" >&2 sed -n 's/^\([^,]*\),.*Entry.*$/ \1/p' /tmp/ks.txt >&2 echo "Set ANDROID_KEY_ALIAS to one of those." >&2 exit 1 fi echo "alias '$alias_want': present" ANDROID_KEYSTORE_FILE="$keystore" ANDROID_KEYSTORE_PASSWORD="$pass" ANDROID_KEY_ALIAS="$alias_want" ANDROID_KEY_PASSWORD="$keypass" export ANDROID_KEYSTORE_FILE ANDROID_KEYSTORE_PASSWORD export ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD # ANDROID_SDK is passed explicitly: the Makefile defaults it to # ~/Android/Sdk, which is the developer-machine layout and not # this container's. make android ANDROID_SDK="$ANDROID_HOME" ANDROID_NDK="$ANDROID_NDK_HOME" - name: Verify the APK id: apk if: steps.version.outputs.skip == 'false' working-directory: /src run: | set -eu apk=bin/yellowjacket.apk [ -s "$apk" ] || { echo "no APK was produced" >&2; ls -la bin || true; exit 1; } bt="$ANDROID_HOME/build-tools/34.0.0" ls -la "$apk" "$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p' # arm64 and *only* arm64. x86_64 Android cannot run this app # (modernc's raw lstat against Android's seccomp filter, which # is every x86_64 device and not merely the emulator), so an # x86_64 slice would be ~31 MB that runs nowhere -- and its # reappearance would mean someone had put the ABI back in # app/build.gradle without knowing that. "$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a'$" || { echo "the APK's ABI set is not exactly arm64-v8a" >&2; exit 1; } # The identity the pipeline exists to keep stable. "$bt/aapt2" dump badging "$apk" | grep -q "versionCode='${{ steps.version.outputs.code }}'" || { echo "versionCode is not ${{ steps.version.outputs.code }}" >&2; exit 1; } echo "$bt/apksigner" verify --print-certs "$apk" | grep -E 'Signer #1 certificate (DN|SHA-256 digest)' # A build signed with the debug key installs once and can never # be updated. It must never reach the registry. if "$bt/apksigner" verify --print-certs "$apk" | grep -q 'CN=Android Debug'; then echo "REFUSING TO PUBLISH: signed with the debug keystore" >&2 exit 1 fi echo echo "Record that SHA-256. If it ever changes, updates will fail." # Two copies: a versioned one for history and a fixed `latest` URL # for Obtainium to watch. Gitea refuses to overwrite an existing # file, so `latest` is deleted first. Credentials are the same # OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with. - name: Publish to the Gitea package registry if: steps.version.outputs.skip == 'false' working-directory: /src env: VERSION: ${{ steps.version.outputs.version }} run: | set -eu base="${SERVER_URL}/api/packages/${OWNER}/generic/${PACKAGE_NAME}" apk=bin/yellowjacket.apk put() { code=$(curl -s -o /tmp/put.out -w '%{http_code}' \ --user "${OWNER}:${PACKAGE_TOKEN}" \ --upload-file "$apk" "$1") echo " -> $1 : $code" # 409 is "already there", which is the correct outcome for a # re-run of the same tag and not a failure. if [ "$code" != "201" ] && [ "$code" != "409" ]; then cat /tmp/put.out >&2 return 1 fi } echo "publishing the versioned copy" put "$base/$VERSION/yellowjacket-$VERSION.apk" echo "clearing the previous latest" curl -s -o /dev/null -w ' -> delete latest: %{http_code}\n' \ --user "${OWNER}:${PACKAGE_TOKEN}" \ -X DELETE "$base/latest/yellowjacket.apk" || true echo "publishing latest" put "$base/latest/yellowjacket.apk" echo echo "Obtainium URL:" echo " $base/latest/yellowjacket.apk" # The generic registry is what Obtainium polls; the release page is # what a person looks at. Same file, already built and already # verified by the step above — so this cannot publish something the # signature check would have refused. - name: Attach the APK to the release if: steps.version.outputs.skip == 'false' working-directory: /src env: TAG: ${{ steps.version.outputs.tag }} VERSION: ${{ steps.version.outputs.version }} run: | set -eu ./scripts/release-asset.sh "$TAG" bin/yellowjacket.apk \ "yellowjacket-${VERSION}-android-arm64.apk"