Build and publish the Android client from CI

2c049d8 · Gabriel Morell · 2026-09-12 20:26

10 files +387 -2
Message
{commit_body(@commit)}

Files changed

added .forgejo/workflows/android.yml
+124 −0
@@ -0,0 +1,124 @@
1 +# Publishes the Apollo's Crib Android client to the forge's own F-Droid
2 +# repository, the same way the forge publishes its own client.
3 +#
4 +# Needs two secrets, and skips cleanly without them rather than failing
5 +# a build for a package that isn't set up yet:
6 +#
7 +# ANDROID_KEYSTORE base64 of the release keystore (.jks)
8 +# ANDROID_KEYSTORE_PASS its password
9 +#
10 +# Generate one once, and keep it: the signing certificate is what
11 +# Android checks on every update, so losing it means every install has
12 +# to be uninstalled and redone by hand. See clients/android/README.md.
13 +#
14 +# This is the APK signing key. It is NOT the F-Droid index key — that
15 +# one is per-owner, lives in the forge's OpenBao, and never comes near
16 +# CI.
17 +name: android
18 +on:
19 + push:
20 + branches: [master]
21 +
22 +jobs:
23 + android:
24 + runs-on: self-hosted
25 + steps:
26 + - uses: actions/checkout@v4
27 +
28 + - uses: docker/setup-buildx-action@v3
29 +
30 + # `vars.CI_FORGE_URL` is a forge built-in, but an older forge
31 + # build won't inject it. Fall back to what the runner already
32 + # knows, and say so plainly if neither resolves rather than
33 + # handing curl an empty URL.
34 + - name: Resolve the forge URL
35 + env:
36 + CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
37 + run: |
38 + set -euo pipefail
39 + forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}
40 +
41 + if [ -z "$forge_url" ]; then
42 + echo "No forge URL available." >&2
43 + echo "Set a CI_FORGE_URL variable on this repository or its org," >&2
44 + echo "or upgrade the forge to a build that injects it." >&2
45 + exit 1
46 + fi
47 +
48 + echo "using forge URL: $forge_url"
49 + echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"
50 +
51 + # A secret can't be read in a job-level `if`, so the gate is a
52 + # step output that everything after checks.
53 + - name: Unpack the signing keystore
54 + id: keystore
55 + env:
56 + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
57 + KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
58 + run: |
59 + set -euo pipefail
60 +
61 + if [ -z "${KEYSTORE:-}" ] || [ -z "${KEYSTORE_PASS:-}" ]; then
62 + echo "No ANDROID_KEYSTORE / ANDROID_KEYSTORE_PASS set — skipping."
63 + echo "An unsigned or debug-signed APK is worse than none: the"
64 + echo "debug key is shared by every developer, so publishing one"
65 + echo "would let anyone ship an 'update' to it."
66 + echo "skip=true" >> "$GITHUB_OUTPUT"
67 + exit 0
68 + fi
69 +
70 + umask 077
71 + printf '%s' "$KEYSTORE" | base64 -d > release.jks
72 + printf '%s' "$KEYSTORE_PASS" > keystore.pass
73 + echo "skip=false" >> "$GITHUB_OUTPUT"
74 +
75 + - name: Build and sign the APK
76 + if: steps.keystore.outputs.skip != 'true'
77 + run: |
78 + docker buildx build \
79 + --target android-export \
80 + --secret id=keystore,src=./release.jks \
81 + --secret id=keystore_password,src=./keystore.pass \
82 + --output type=local,dest=./apk \
83 + --file clients/android/Dockerfile .
84 +
85 + # The keystore is gone before anything else runs, so a later step
86 + # or a failure mid-job can't leave it on a persistent runner.
87 + - name: Shred the keystore
88 + if: always()
89 + run: shred -u release.jks keystore.pass 2>/dev/null || rm -f release.jks keystore.pass
90 +
91 + # `${{ github.token }}` is minted for this job and authorized to
92 + # publish to THIS repository's packages and nowhere else, so there
93 + # is no secret to create first.
94 + - name: Publish to the F-Droid repository
95 + if: steps.keystore.outputs.skip != 'true'
96 + env:
97 + REPOSITORY: ${{ github.repository }}
98 + TOKEN: ${{ github.token }}
99 + run: |
100 + set -euo pipefail
101 + curl --fail-with-body -sS \
102 + -u "ci:$TOKEN" \
103 + -H "Content-Type: application/vnd.android.package-archive" \
104 + --data-binary "@apk/app-release.apk" \
105 + "$FORGE_URL/pkg/fdroid/$REPOSITORY/upload"
106 + echo
107 +
108 + - name: How to install it
109 + if: steps.keystore.outputs.skip != 'true'
110 + env:
111 + OWNER: ${{ github.repository_owner }}
112 + run: |
113 + set -euo pipefail
114 + fingerprint=$(curl -fsSL "$FORGE_URL/pkg/_keys/fdroid/$OWNER")
115 +
116 + cat <<EOF
117 + Add this repository in F-Droid (Settings -> Repositories -> +):
118 +
119 + $FORGE_URL/pkg/fdroid/$OWNER/_all?fingerprint=$fingerprint
120 +
121 + One entry covers every Android app $OWNER publishes. The
122 + fingerprint pins the index signing certificate; F-Droid
123 + refuses an index signed by anything else.
124 + EOF
modified .gitignore
+12 −0
@@ -27,3 +27,15 @@ erl_crash.dump
27 27
28 28 # Server logs
29 29 *.log
30 +
31 +# Local credentials for development and investigation. Never committed.
32 +.env
33 +
34 +# Android release signing material. The APK signing key cannot be
35 +# rotated — Android checks its certificate on every update — so a
36 +# commit of one is not recoverable by deleting it later. Keep it in
37 +# OpenBao KV and in the CI secret, never here.
38 +# See clients/android/README.md#the-release-keystore.
39 +*.jks
40 +*.keystore
41 +keystore.pass
added clients/android/Dockerfile
+117 −0
@@ -0,0 +1,117 @@
1 +# Builds and signs the release APK.
2 +#
3 +# In a container rather than on the runner, so the runner needs docker
4 +# and nothing else, and a developer can reproduce exactly what CI did
5 +# without installing an SDK.
6 +#
7 +# docker buildx build \
8 +# --target android-export \
9 +# --secret id=keystore,src=./release.jks \
10 +# --secret id=keystore_password,src=./keystore.pass \
11 +# --output type=local,dest=./apk \
12 +# --file clients/android/Dockerfile .
13 +#
14 +# Run from the repository root — the build context is the repo, so the
15 +# app sources are at `clients/android/`.
16 +
17 +# JDK 21 rather than 17, and a current cmdline-tools: AGP 9.2 / Gradle
18 +# 9.4 here, compiling against android-36.1. The 2024-era tools pinned
19 +# by the other apps in this family predate that platform.
20 +ARG JDK_IMAGE=eclipse-temurin:21-jdk
21 +
22 +FROM ${JDK_IMAGE} AS sdk
23 +
24 +# Pinned. `…_latest.zip` is a moving target under a cache that has no
25 +# way to notice it moved.
26 +ARG CMDLINE_TOOLS=commandlinetools-linux-13114758_latest.zip
27 +ARG ANDROID_PLATFORM=36.1
28 +ARG BUILD_TOOLS=36.1.0
29 +
30 +ENV ANDROID_HOME=/opt/android-sdk \
31 + DEBIAN_FRONTEND=noninteractive
32 +
33 +RUN apt-get update \
34 + && apt-get install -y --no-install-recommends unzip curl ca-certificates \
35 + && rm -rf /var/lib/apt/lists/*
36 +
37 +RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
38 + && curl -fsSL "https://dl.google.com/android/repository/${CMDLINE_TOOLS}" -o /tmp/tools.zip \
39 + && unzip -q /tmp/tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
40 + && mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
41 + && rm /tmp/tools.zip
42 +
43 +ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
44 +
45 +# `yes |` because sdkmanager prompts per licence and there is no
46 +# non-interactive flag that covers all of them.
47 +RUN yes | sdkmanager --licenses > /dev/null \
48 + && sdkmanager --install \
49 + "platforms;android-${ANDROID_PLATFORM}" \
50 + "build-tools;${BUILD_TOOLS}" \
51 + "platform-tools" > /dev/null
52 +
53 +# ── dependencies ─────────────────────────────────────────────────────
54 +#
55 +# Build files only, so the ~200 MB of Gradle and Maven downloads cache
56 +# as one layer that survives every change to the Kotlin sources.
57 +
58 +FROM sdk AS deps
59 +
60 +WORKDIR /src
61 +COPY clients/android/gradle ./gradle
62 +COPY clients/android/gradlew clients/android/settings.gradle.kts clients/android/build.gradle.kts clients/android/gradle.properties ./
63 +COPY clients/android/app/build.gradle.kts ./app/
64 +
65 +RUN echo "sdk.dir=${ANDROID_HOME}" > local.properties
66 +
67 +# A task that resolves the whole dependency graph without needing the
68 +# sources. `--no-daemon` because a daemon outliving the RUN is just a
69 +# process the image has to reap.
70 +RUN ./gradlew --no-daemon :app:dependencies --configuration releaseRuntimeClasspath > /dev/null \
71 + || echo "dependency warm-up failed; the build below will fetch them"
72 +
73 +# ── build ────────────────────────────────────────────────────────────
74 +
75 +FROM deps AS build
76 +
77 +ARG BUILD_TOOLS=36.1.0
78 +COPY clients/android/app ./app
79 +
80 +RUN ./gradlew --no-daemon :app:assembleRelease
81 +
82 +# Sign as a separate step from Gradle so the keystore never reaches a
83 +# build script, a Gradle cache, or an image layer. BuildKit secrets are
84 +# mounted for the life of one RUN and are not committed.
85 +#
86 +# zipalign before apksigner, not after: aligning rewrites offsets, and
87 +# doing it to an already-signed APK invalidates the v2 signature that
88 +# covers them.
89 +#
90 +# No --key-pass. apksigner reads each `file:` password as a *sequential*
91 +# read from that file, so passing the same one-line file to both
92 +# --ks-pass and --key-pass makes the second read hit EOF:
93 +#
94 +# Failed to read Key "…" password: end of file reached
95 +#
96 +# Omitted, the key password defaults to the store password, which is
97 +# what PKCS12 keystores use anyway.
98 +RUN --mount=type=secret,id=keystore,target=/tmp/release.jks \
99 + --mount=type=secret,id=keystore_password,target=/tmp/keystore.pass \
100 + set -eu; \
101 + tools="${ANDROID_HOME}/build-tools/${BUILD_TOOLS}"; \
102 + unsigned=app/build/outputs/apk/release/app-release-unsigned.apk; \
103 + mkdir -p /out; \
104 + "${tools}/zipalign" -p -f 4 "$unsigned" /tmp/aligned.apk; \
105 + "${tools}/apksigner" sign \
106 + --ks /tmp/release.jks \
107 + --ks-pass "file:/tmp/keystore.pass" \
108 + --out /out/app-release.apk \
109 + /tmp/aligned.apk; \
110 + "${tools}/apksigner" verify --print-certs /out/app-release.apk
111 +
112 +# ── export ───────────────────────────────────────────────────────────
113 +#
114 +# FROM scratch, so `--output type=local` lands the APK and nothing else.
115 +
116 +FROM scratch AS android-export
117 +COPY --from=build /out/app-release.apk /
modified clients/android/README.md
+102 −0
@@ -182,3 +182,105 @@ Font Awesome Free icons are CC BY 4.0; the vendored `LICENSE.txt` covers them.
182 182 A Smartspace list row is plain text, so the glyphs are the app's alone. Rows
183 183 carry a text form for that surface -- `8b 1e 7d` rather than `8 1 7`, which is
184 184 not a number of bikes, e-bikes and docks to anybody reading it.
185 +
186 +## Release builds
187 +
188 +CI builds and signs the release APK in a container
189 +(`clients/android/Dockerfile`) and publishes it to the forge's own F-Droid
190 +repository — nothing is installed on the runner, and the same command
191 +reproduces it locally:
192 +
193 +```sh
194 +docker buildx build --target android-export \
195 + --secret id=keystore,src=./release.jks \
196 + --secret id=keystore_password,src=./keystore.pass \
197 + --output type=local,dest=./apk --file clients/android/Dockerfile .
198 +```
199 +
200 +The keystore is a BuildKit secret, so it never reaches a Gradle script,
201 +a Gradle cache, or an image layer. Signing is a separate step from
202 +Gradle for the same reason.
203 +
204 +`.forgejo/workflows/android.yml` runs that on every push to `master`
205 +and `POST`s the APK to `/pkg/fdroid/<owner>/<repo>/upload` with the
206 +job's own token — there is no publishing credential to create.
207 +
208 +`versionCode` comes from the CI run number and `versionName` from the
209 +commit, so every published build sorts after the last one. Local builds
210 +stay at `versionCode = 1` / `…+dev`; they are never published.
211 +
212 +Install from the repository by adding it in F-Droid:
213 +
214 +```
215 +<forge>/pkg/fdroid/<owner>/_all?fingerprint=<fingerprint>
216 +curl -fsSL <forge>/pkg/_keys/fdroid/<owner> # prints the fingerprint
217 +```
218 +
219 +One entry covers every Android app the owner publishes, this one
220 +included.
221 +
222 +**Two different keys, deliberately.** The *APK* key below is what
223 +Android checks on every update. The *index* key is per-owner, lives in
224 +the forge's OpenBao transit engine, and never comes near CI.
225 +
226 +## The release keystore
227 +
228 +Generate it once. Android verifies this certificate on every update, so
229 +**losing it means every install has to be uninstalled and redone by
230 +hand** — there is no recovery and no rotation.
231 +
232 +```sh
233 +keytool -genkeypair -v \
234 + -keystore release.jks \
235 + -storetype PKCS12 \
236 + -alias apolloscrib \
237 + -keyalg RSA -keysize 4096 \
238 + -validity 10000 \
239 + -dname "CN=Apollo's Crib, O=neiam, C=US"
240 +```
241 +
242 +It prompts for the password rather than taking `-storepass`, which
243 +keeps it out of shell history. PKCS12 uses one password for the store
244 +and the key, which is what `clients/android/Dockerfile` assumes — it passes the
245 +one password file to `--ks-pass` and lets the key password default to
246 +it.
247 +
248 +### Back it up before it exists anywhere else
249 +
250 +So there is never a window where your laptop holds the only copy:
251 +
252 +```sh
253 +export BAO_TOKEN=<root token>
254 +E() { kubectl -n openbao exec -i statefulset/openbao -- env BAO_TOKEN="$BAO_TOKEN" "$@"; }
255 +
256 +E bao kv put kv/apollos_crib/android \
257 + keystore="$(base64 -w0 release.jks)" \
258 + password='<the password>'
259 +```
260 +
261 +`kv-v2` versions writes, so a later overwrite doesn't destroy this one.
262 +Recover with:
263 +
264 +```sh
265 +E bao kv get -field=keystore kv/apollos_crib/android | base64 -d > release.jks
266 +```
267 +
268 +### Hand it to CI
269 +
270 +On the repository: **Settings → Actions → Secrets**.
271 +
272 +| Secret | Value |
273 +|---|---|
274 +| `ANDROID_KEYSTORE` | `base64 -w0 release.jks` |
275 +| `ANDROID_KEYSTORE_PASS` | the password |
276 +
277 +The `android` job skips cleanly while these are unset, so nothing
278 +breaks before you get to it. It refuses to fall back to a debug-signed
279 +APK: that key is shared by everyone with an SDK, so publishing one
280 +would let anyone ship an "update" to it.
281 +
282 +CI does not read the keystore out of OpenBao. `apksigner` has no
283 +external-signer hook, so the key has to reach the build container
284 +whichever store it comes from, and the runner is outside the cluster
285 +while OpenBao has no ingress on purpose. Bao holds the durable copy;
286 +the Forgejo secret is the operational one.
modified clients/android/app/build.gradle.kts
+32 −2
@@ -4,6 +4,10 @@ plugins {
4 4 alias(libs.plugins.kotlin.serialization)
5 5 }
6 6
7 +// Bumped by hand for a real release; the build metadata after it comes
8 +// from CI.
9 +val baseVersion = "0.1.0"
10 +
7 11 android {
8 12 namespace = "io.neiam.apolloscrib"
9 13 compileSdk {
@@ -18,8 +22,21 @@ android {
18 22 // declarations below are 14-era. 31 keeps the manifest honest.
19 23 minSdk = 31
20 24 targetSdk = 36
21 − versionCode = 1
22 − versionName = "0.1.0"
25 + // Android refuses an update whose versionCode isn't higher than
26 + // what's installed, and F-Droid orders versions by it — so a
27 + // hardcoded 1 means no phone ever sees a second release. It also
28 + // means every CI build publishes the same filename with
29 + // different bytes, which the registry correctly refuses with a
30 + // 409.
31 + //
32 + // The CI run number is monotonic and needs no state kept
33 + // anywhere. Local builds stay at 1, which is fine: they are
34 + // never published.
35 + versionCode = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 1
36 +
37 + // The commit is what you want when someone reports a bug
38 + // against a build. Local builds just say "dev".
39 + versionName = "$baseVersion+" + ((System.getenv("GITHUB_SHA") ?: "dev").take(7))
23 40 }
24 41
25 42 buildTypes {
@@ -42,6 +59,19 @@ android {
42 59 testOptions {
43 60 unitTests.isIncludeAndroidResources = true
44 61 }
62 +
63 + lint {
64 + // `assembleRelease` is a packaging step here, not a review gate.
65 + //
66 + // lintVital doubles the release build and fails for reasons that
67 + // have nothing to do with the code: lint throws on JDK 25 with
68 + // only the version string ("25.0.4.1") as the error, so a release
69 + // built on a modern local JDK fails while the same commit builds
70 + // fine in the container's JDK.
71 + //
72 + // Lint still runs on demand: ./gradlew :app:lint
73 + checkReleaseBuilds = false
74 + }
45 75 packaging {
46 76 resources {
47 77 excludes += "META-INF/INDEX.LIST"

Parents: 400c82c