Build, sign and publish the Android release APK

e380962 · Gabriel Morell · 2026-09-16 03:59

8 files +358 -4
Message
{commit_body(@commit)}

Files changed

modified .github/workflows/ci.yml
+124 −0
@@ -177,3 +177,127 @@ jobs:
177 177 labels: ${{ steps.meta.outputs.labels }}
178 178 cache-from: ${{ steps.forge.outputs.image != '' && format('type=registry,ref={0}:buildcache', steps.forge.outputs.image) || '' }}
179 179 cache-to: ${{ steps.forge.outputs.image != '' && github.event_name != 'pull_request' && format('type=registry,ref={0}:buildcache,mode=max', steps.forge.outputs.image) || '' }}
180 +
181 + # The Android client, published to the forge's own F-Droid repository.
182 + #
183 + # Needs two secrets, and skips cleanly without them rather than failing
184 + # a build for a package most forks will not publish:
185 + #
186 + # ANDROID_KEYSTORE base64 of the release keystore (.jks)
187 + # ANDROID_KEYSTORE_PASS its password
188 + #
189 + # Generate one once and keep it. Android checks the signing certificate
190 + # on every update, so losing it means every install has to be
191 + # uninstalled and redone by hand -- there is no rotation:
192 + #
193 + # keytool -genkeypair -v -keystore release.jks -storetype PKCS12 \
194 + # -alias diogramos -keyalg RSA -keysize 4096 -validity 10000 \
195 + # -dname "CN=Diogramos, O=neiam, C=US"
196 + # base64 -w0 release.jks
197 + #
198 + # This is the APK signing key, not the F-Droid index key -- that one is
199 + # per-owner, lives in OpenBao, and never comes near CI.
200 + android:
201 + needs: test
202 + runs-on: arc-runner-set
203 + steps:
204 + - uses: actions/checkout@v4
205 + - uses: docker/setup-buildx-action@v3
206 +
207 + - name: Resolve the forge URL
208 + env:
209 + CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
210 + run: |
211 + set -euo pipefail
212 + forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}
213 +
214 + if [ -z "$forge_url" ]; then
215 + echo "No forge URL available; set a CI_FORGE_URL variable." >&2
216 + exit 1
217 + fi
218 +
219 + echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"
220 +
221 + # A secret cannot be read in a job-level `if`, so the gate is a step
222 + # output that everything after it checks.
223 + - name: Unpack the signing keystore
224 + id: keystore
225 + env:
226 + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
227 + KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
228 + run: |
229 + set -euo pipefail
230 +
231 + if [ -z "${KEYSTORE:-}" ] || [ -z "${KEYSTORE_PASS:-}" ]; then
232 + echo "No ANDROID_KEYSTORE / ANDROID_KEYSTORE_PASS set -- skipping."
233 + echo "An unsigned or debug-signed APK is worse than none: the debug"
234 + echo "key is shared by every developer with an SDK, so publishing"
235 + echo "one would let anyone ship an 'update' to it."
236 + echo "skip=true" >> "$GITHUB_OUTPUT"
237 + exit 0
238 + fi
239 +
240 + umask 077
241 + printf '%s' "$KEYSTORE" | base64 -d > release.jks
242 + printf '%s' "$KEYSTORE_PASS" > keystore.pass
243 + echo "skip=false" >> "$GITHUB_OUTPUT"
244 +
245 + - name: Build and sign the APK
246 + if: steps.keystore.outputs.skip != 'true'
247 + env:
248 + RUN_NUMBER: ${{ github.run_number }}
249 + run: |
250 + set -euo pipefail
251 +
252 + # One stamp for the whole run, computed here rather than inside
253 + # Gradle, so every artifact of a run agrees on its version even
254 + # if the build is retried a step at a time.
255 + stamp=$(date -u +%s)
256 + echo "version: 0.1.0-dev-$stamp (versionCode $RUN_NUMBER)"
257 +
258 + docker buildx build \
259 + --target android-export \
260 + --build-arg GITHUB_RUN_NUMBER="$RUN_NUMBER" \
261 + --build-arg SOURCE_DATE_EPOCH="$stamp" \
262 + --secret id=keystore,src=./release.jks \
263 + --secret id=keystore_password,src=./keystore.pass \
264 + --output type=local,dest=./apk \
265 + --file android/Dockerfile android
266 +
267 + # Gone before anything else runs, so a later step or a failure
268 + # mid-job cannot leave it on a persistent runner.
269 + - name: Shred the keystore
270 + if: always()
271 + run: shred -u release.jks keystore.pass 2>/dev/null || rm -f release.jks keystore.pass
272 +
273 + - name: Publish to the F-Droid repository
274 + if: steps.keystore.outputs.skip != 'true'
275 + env:
276 + REPOSITORY: ${{ github.repository }}
277 + TOKEN: ${{ github.token }}
278 + run: |
279 + set -euo pipefail
280 + curl --fail-with-body -sS \
281 + -u "diogramos-actions:$TOKEN" \
282 + -H "Content-Type: application/vnd.android.package-archive" \
283 + --data-binary "@apk/app-release.apk" \
284 + "$FORGE_URL/pkg/fdroid/$REPOSITORY/upload"
285 + echo
286 +
287 + - name: How to install it
288 + if: steps.keystore.outputs.skip != 'true'
289 + env:
290 + OWNER: ${{ github.repository_owner }}
291 + run: |
292 + set -euo pipefail
293 + fingerprint=$(curl -fsSL "$FORGE_URL/pkg/_keys/fdroid/$OWNER")
294 +
295 + cat <<EOF
296 + Add this repository in F-Droid (Settings -> Repositories -> +):
297 +
298 + $FORGE_URL/pkg/fdroid/$OWNER/_all?fingerprint=$fingerprint
299 +
300 + One entry covers every Android app $OWNER publishes. The
301 + fingerprint pins the index signing certificate; F-Droid refuses
302 + an index signed by anything else.
303 + EOF
added android/Dockerfile
+134 −0
@@ -0,0 +1,134 @@
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 +# --progress=plain \
13 +# --file android/Dockerfile android
14 +#
15 +# Run from the repository root. The build context is `android`, not the
16 +# repository: the app needs nothing outside it, and handing BuildKit the
17 +# whole repo would ship the Elixir tree, deps/ and _build/ across the
18 +# wire for every build.
19 +
20 +ARG JDK_IMAGE=eclipse-temurin:17-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-11076708_latest.zip
27 +ARG ANDROID_PLATFORM=34
28 +ARG BUILD_TOOLS=34.0.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 gradle ./gradle
62 +COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
63 +COPY 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=34.0.0
78 +
79 +# A container build inherits nothing from the runner's environment, so
80 +# the version has to be handed in explicitly. Without these, Gradle's
81 +# getenv lookups come back empty inside here and every run builds
82 +# versionCode 1 — the same filename with different bytes, which the
83 +# registry refuses with a 409.
84 +#
85 +# Declared in this stage rather than at the top so they don't invalidate
86 +# the SDK and dependency layers, which is most of the build time.
87 +ARG GITHUB_RUN_NUMBER
88 +ARG SOURCE_DATE_EPOCH
89 +ARG RELEASE_VERSION
90 +ENV GITHUB_RUN_NUMBER=${GITHUB_RUN_NUMBER} \
91 + SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} \
92 + RELEASE_VERSION=${RELEASE_VERSION}
93 +
94 +COPY app ./app
95 +
96 +RUN echo "building versionCode=${GITHUB_RUN_NUMBER:-1}" \
97 + && ./gradlew --no-daemon :app:assembleRelease
98 +
99 +# Sign as a separate step from Gradle so the keystore never reaches a
100 +# build script, a Gradle cache, or an image layer. BuildKit secrets are
101 +# mounted for the life of one RUN and are not committed.
102 +#
103 +# zipalign before apksigner, not after: aligning rewrites offsets, and
104 +# doing it to an already-signed APK invalidates the v2 signature that
105 +# covers them.
106 +#
107 +# No --key-pass. apksigner reads each `file:` password as a *sequential*
108 +# read from that file, so passing the same one-line file to both
109 +# --ks-pass and --key-pass makes the second read hit EOF:
110 +#
111 +# Failed to read Key "…" password: end of file reached
112 +#
113 +# Omitted, the key password defaults to the store password, which is
114 +# what PKCS12 keystores use anyway.
115 +RUN --mount=type=secret,id=keystore,target=/tmp/release.jks \
116 + --mount=type=secret,id=keystore_password,target=/tmp/keystore.pass \
117 + set -eu; \
118 + tools="${ANDROID_HOME}/build-tools/${BUILD_TOOLS}"; \
119 + unsigned=app/build/outputs/apk/release/app-release-unsigned.apk; \
120 + mkdir -p /out; \
121 + "${tools}/zipalign" -p -f 4 "$unsigned" /tmp/aligned.apk; \
122 + "${tools}/apksigner" sign \
123 + --ks /tmp/release.jks \
124 + --ks-pass "file:/tmp/keystore.pass" \
125 + --out /out/app-release.apk \
126 + /tmp/aligned.apk; \
127 + "${tools}/apksigner" verify --print-certs /out/app-release.apk
128 +
129 +# ── export ───────────────────────────────────────────────────────────
130 +#
131 +# FROM scratch, so `--output type=local` lands the APK and nothing else.
132 +
133 +FROM scratch AS android-export
134 +COPY --from=build /out/app-release.apk /
modified android/README.md
+77 −2
@@ -182,10 +182,85 @@ a server round trip, a broadcast to every peer and a WAL row, so the
182 182 in-flight geometry is a render-time override and ops go out about ten
183 183 times a second plus once at the end.
184 184
185 +## Releases
186 +
187 +CI builds and signs the release APK in a container
188 +(`android/Dockerfile`) and publishes it to the forge's own F-Droid
189 +repository. Nothing is installed on the runner, and the same command
190 +reproduces it locally:
191 +
192 +```sh
193 +docker buildx build --target android-export \
194 + --secret id=keystore,src=./release.jks \
195 + --secret id=keystore_password,src=./keystore.pass \
196 + --output type=local,dest=./apk \
197 + --file android/Dockerfile android
198 +```
199 +
200 +The keystore is a BuildKit secret, so it never reaches a Gradle script, a
201 +Gradle cache, or an image layer — and signing is a separate step from
202 +Gradle for the same reason. `versionCode` comes from the CI run number
203 +and `versionName` from a per-run timestamp, so every published build
204 +sorts after the last one; local builds stay at `versionCode = 1` and are
205 +never published.
206 +
207 +The `android` job skips cleanly while `ANDROID_KEYSTORE` and
208 +`ANDROID_KEYSTORE_PASS` are unset, and refuses to fall back to a
209 +debug-signed APK: that key is shared by everyone with an SDK, so
210 +publishing one would let anyone ship an "update" to it.
211 +
212 +### The release keystore
213 +
214 +Generate it once — Android verifies this certificate on every update, so
215 +**losing it means every install has to be uninstalled and redone by
216 +hand**. There is no recovery and no rotation.
217 +
218 +```sh
219 +keytool -genkeypair -v \
220 + -keystore release.jks \
221 + -storetype PKCS12 \
222 + -alias diogramos \
223 + -keyalg RSA -keysize 4096 \
224 + -validity 10000 \
225 + -dname "CN=Diogramos, O=neiam, C=US"
226 +```
227 +
228 +It prompts for the password rather than taking `-storepass`, which keeps
229 +it out of shell history. PKCS12 uses one password for the store and the
230 +key, which is what the Dockerfile assumes.
231 +
232 +Back it up before it exists anywhere else:
233 +
234 +```sh
235 +export BAO_TOKEN=<root token>
236 +E() { kubectl -n openbao exec -i statefulset/openbao -- env BAO_TOKEN="$BAO_TOKEN" "$@"; }
237 +
238 +E bao kv put kv/diogramos/android \
239 + keystore="$(base64 -w0 release.jks)" \
240 + password='<the password>'
241 +```
242 +
243 +Then hand it to CI as repository secrets `ANDROID_KEYSTORE` (the base64)
244 +and `ANDROID_KEYSTORE_PASS`. CI does not read it out of OpenBao:
245 +`apksigner` has no external-signer hook, so the key has to reach the
246 +build container whichever store it comes from, and the runner is outside
247 +the cluster. Bao holds the durable copy; the CI secret is the
248 +operational one.
249 +
250 +Installing from the repository, once a build has published:
251 +
252 +```
253 +<forge>/pkg/fdroid/<owner>/_all?fingerprint=<fingerprint>
254 +curl -fsSL <forge>/pkg/_keys/fdroid/<owner> # prints the fingerprint
255 +```
256 +
257 +One entry covers every Android app the owner publishes. Note that the
258 +APK signing key above and the F-Droid *index* key are different keys:
259 +the index key is per-owner, lives in OpenBao, and never comes near CI.
260 +
185 261 ## Not yet shipped
186 262
187 −Export and the theme picker (phase 5), and the launcher icon and
188 −release/F-Droid pipeline (phase 6). See
263 +Export and the theme picker (phase 5), and the launcher icon. See
189 264 [docs/android.md](../docs/android.md).
190 265
191 266 Nothing has yet been seen running on a screen: the unit tests cover the
modified docs/android.md
+23 −2
@@ -610,8 +610,29 @@ button at all.
610 610 - Export (JSON + mermaid) to the share sheet, canvas create/rename/theme,
611 611 app theme picker, launcher icon, App Links + `assetlinks.json`.
612 612
613 −### Phase 6 — Release pipeline
614 −- `android/Dockerfile`, CI job, keystore, F-Droid publication.
613 +### Phase 6 — Release pipeline ✅ shipped (unpublished)
614 +- `android/Dockerfile`: pinned SDK, a dependency layer that survives
615 + source changes, `assembleRelease`, then zipalign + apksigner as a step
616 + separate from Gradle with the keystore mounted as a BuildKit secret.
617 + `FROM scratch` export stage, so `--output type=local` lands the APK and
618 + nothing else.
619 +- An `android` job in the forge workflow: resolve the forge URL, unpack
620 + the keystore (skipping cleanly when the secrets are unset), build,
621 + shred the keystore, publish to `/pkg/fdroid/<repo>/upload` with the
622 + per-job token, and print the install instructions.
623 +
624 +Verified locally end to end: the container builds and `apksigner verify`
625 +prints the signing certificate. Nothing is published yet — that needs
626 +the `ANDROID_KEYSTORE` / `ANDROID_KEYSTORE_PASS` secrets, and until they
627 +exist the job skips.
628 +
629 +The build context is `android/`, not the repository: the app needs
630 +nothing outside it, and a repo-root context ships the Elixir tree,
631 +`deps/` and `_build/` over the wire on every build.
632 +
633 +Remaining before a first publish: the launcher icon (the manifest
634 +currently ships no `android:icon`, so it gets the system default) and
635 +`assetlinks.json` for App Links.
615 636
616 637 ---
617 638

Parents: 731ef80