neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Build, sign and publish the Android release APK
e380962 · Gabriel Morell · 2026-09-16 03:59
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