Build and publish the Android client from CI

d90390e · Gabriel Morell · 2026-09-12 17:28

12 files +295 -18
Message
{commit_body(@commit)}

Files changed

modified .forgejo/workflows/build.yml
+114 −0
@@ -150,3 +150,117 @@ jobs:
150 150 echo "deb [signed-by=/etc/apt/keyrings/$OWNER.asc] $FORGE_URL/pkg/deb/$REPOSITORY \$(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/gitgud.list
151 151 sudo apt update && sudo apt install gitgud
152 152 EOF
153 +
154 + # The forge publishing its own Android client to its own F-Droid
155 + # repository — the same shape as `deb`, one registry over.
156 + #
157 + # Needs two secrets, and skips cleanly without them rather than
158 + # failing a build for a package most forks won't publish:
159 + #
160 + # ANDROID_KEYSTORE base64 of the release keystore (.jks)
161 + # ANDROID_KEYSTORE_PASS its password
162 + #
163 + # Generate one once, and keep it: the signing certificate is what
164 + # Android checks on every update, so losing it means every install
165 + # has to be uninstalled and redone by hand.
166 + #
167 + # keytool -genkeypair -v -keystore release.jks -alias gitgud \
168 + # -keyalg RSA -keysize 4096 -validity 10000 \
169 + # -dname "CN=GitGud, O=neiam, C=US"
170 + # base64 -w0 release.jks
171 + #
172 + # This is the APK signing key. It is NOT the F-Droid index key, which
173 + # lives in OpenBao and never comes near CI — see docs/fdroid-registry.md.
174 + android:
175 + runs-on: self-hosted
176 + needs: [build]
177 + steps:
178 + - uses: actions/checkout@v4
179 +
180 + - uses: docker/setup-buildx-action@v3
181 +
182 + - name: Resolve the forge URL
183 + env:
184 + CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
185 + run: |
186 + set -euo pipefail
187 + forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}
188 +
189 + if [ -z "$forge_url" ]; then
190 + echo "No forge URL available." >&2
191 + exit 1
192 + fi
193 +
194 + echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"
195 +
196 + # A secret can't be read in a job-level `if`, so the gate is a
197 + # step output that everything after checks.
198 + - name: Unpack the signing keystore
199 + id: keystore
200 + env:
201 + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
202 + KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
203 + run: |
204 + set -euo pipefail
205 +
206 + if [ -z "${KEYSTORE:-}" ] || [ -z "${KEYSTORE_PASS:-}" ]; then
207 + echo "No ANDROID_KEYSTORE / ANDROID_KEYSTORE_PASS set — skipping."
208 + echo "An unsigned or debug-signed APK is worse than none: the"
209 + echo "debug key is shared by every developer, so publishing one"
210 + echo "would let anyone ship an 'update' to it."
211 + echo "skip=true" >> "$GITHUB_OUTPUT"
212 + exit 0
213 + fi
214 +
215 + umask 077
216 + printf '%s' "$KEYSTORE" | base64 -d > release.jks
217 + printf '%s' "$KEYSTORE_PASS" > keystore.pass
218 + echo "skip=false" >> "$GITHUB_OUTPUT"
219 +
220 + - name: Build and sign the APK
221 + if: steps.keystore.outputs.skip != 'true'
222 + run: |
223 + docker buildx build \
224 + --target android-export \
225 + --secret id=keystore,src=./release.jks \
226 + --secret id=keystore_password,src=./keystore.pass \
227 + --output type=local,dest=./apk \
228 + --file android/Dockerfile .
229 +
230 + # The keystore is gone before anything else runs, so a later step
231 + # or a failure mid-job can't leave it on a persistent runner.
232 + - name: Shred the keystore
233 + if: always()
234 + run: shred -u release.jks keystore.pass 2>/dev/null || rm -f release.jks keystore.pass
235 +
236 + - name: Publish to the F-Droid repository
237 + if: steps.keystore.outputs.skip != 'true'
238 + env:
239 + REPOSITORY: ${{ github.repository }}
240 + TOKEN: ${{ github.token }}
241 + run: |
242 + set -euo pipefail
243 + curl --fail-with-body -sS \
244 + -u "gitgud-actions:$TOKEN" \
245 + -H "Content-Type: application/vnd.android.package-archive" \
246 + --data-binary "@apk/app-release.apk" \
247 + "$FORGE_URL/pkg/fdroid/$REPOSITORY/upload"
248 + echo
249 +
250 + - name: How to install it
251 + if: steps.keystore.outputs.skip != 'true'
252 + env:
253 + OWNER: ${{ github.repository_owner }}
254 + run: |
255 + set -euo pipefail
256 + fingerprint=$(curl -fsSL "$FORGE_URL/pkg/_keys/fdroid/$OWNER")
257 +
258 + cat <<EOF
259 + Add this repository in F-Droid (Settings -> Repositories -> +):
260 +
261 + $FORGE_URL/pkg/fdroid/$OWNER/_all?fingerprint=$fingerprint
262 +
263 + One entry covers every Android app $OWNER publishes. The
264 + fingerprint pins the index signing certificate; F-Droid
265 + refuses an index signed by anything else.
266 + EOF
added android/Dockerfile
+106 −0
@@ -0,0 +1,106 @@
1 +# Builds and signs the release APK.
2 +#
3 +# In a container rather than on the runner, for the same reason the OTP
4 +# release is: the runner needs docker and nothing else, and a developer
5 +# can reproduce exactly what CI did 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 android/Dockerfile .
13 +#
14 +# Run from the repository root — the build context is the repo, so the
15 +# `android/` sources are at `android/`.
16 +
17 +ARG JDK_IMAGE=eclipse-temurin:17-jdk
18 +
19 +FROM ${JDK_IMAGE} AS sdk
20 +
21 +# Pinned. `…_latest.zip` would make the toolchain drift under a cache
22 +# that has no way to notice.
23 +ARG CMDLINE_TOOLS=commandlinetools-linux-11076708_latest.zip
24 +ARG ANDROID_PLATFORM=34
25 +ARG BUILD_TOOLS=34.0.0
26 +
27 +ENV ANDROID_HOME=/opt/android-sdk \
28 + DEBIAN_FRONTEND=noninteractive
29 +
30 +RUN apt-get update \
31 + && apt-get install -y --no-install-recommends unzip curl ca-certificates \
32 + && rm -rf /var/lib/apt/lists/*
33 +
34 +RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
35 + && curl -fsSL "https://dl.google.com/android/repository/${CMDLINE_TOOLS}" -o /tmp/tools.zip \
36 + && unzip -q /tmp/tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
37 + && mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
38 + && rm /tmp/tools.zip
39 +
40 +ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
41 +
42 +# `yes |` because sdkmanager prompts per licence and there is no
43 +# non-interactive flag that covers all of them.
44 +RUN yes | sdkmanager --licenses > /dev/null \
45 + && sdkmanager --install \
46 + "platforms;android-${ANDROID_PLATFORM}" \
47 + "build-tools;${BUILD_TOOLS}" \
48 + "platform-tools" > /dev/null
49 +
50 +# ── dependencies ─────────────────────────────────────────────────────
51 +#
52 +# Build files only, so the ~200 MB of Gradle and Maven downloads cache
53 +# as one layer that survives every change to the Kotlin sources.
54 +
55 +FROM sdk AS deps
56 +
57 +WORKDIR /src
58 +COPY android/gradle ./gradle
59 +COPY android/gradlew android/settings.gradle.kts android/build.gradle.kts android/gradle.properties ./
60 +COPY android/app/build.gradle.kts ./app/
61 +
62 +RUN echo "sdk.dir=${ANDROID_HOME}" > local.properties
63 +
64 +# A task that resolves the whole dependency graph without needing the
65 +# sources. `--no-daemon` because a daemon outliving the RUN is just a
66 +# process the image has to reap.
67 +RUN ./gradlew --no-daemon :app:dependencies --configuration releaseRuntimeClasspath > /dev/null \
68 + || echo "dependency warm-up failed; the build below will fetch them"
69 +
70 +# ── build ────────────────────────────────────────────────────────────
71 +
72 +FROM deps AS build
73 +
74 +ARG BUILD_TOOLS=34.0.0
75 +COPY android/app ./app
76 +
77 +RUN ./gradlew --no-daemon :app:assembleRelease
78 +
79 +# Sign as a separate step from Gradle so the keystore never reaches a
80 +# build script, a Gradle cache, or an image layer. BuildKit secrets are
81 +# mounted for the life of one RUN and are not committed.
82 +#
83 +# zipalign before apksigner, not after: aligning rewrites offsets, and
84 +# doing it to an already-signed APK invalidates the v2 signature that
85 +# covers them.
86 +RUN --mount=type=secret,id=keystore,target=/tmp/release.jks \
87 + --mount=type=secret,id=keystore_password,target=/tmp/keystore.pass \
88 + set -eu; \
89 + tools="${ANDROID_HOME}/build-tools/${BUILD_TOOLS}"; \
90 + unsigned=app/build/outputs/apk/release/app-release-unsigned.apk; \
91 + mkdir -p /out; \
92 + "${tools}/zipalign" -p -f 4 "$unsigned" /tmp/aligned.apk; \
93 + "${tools}/apksigner" sign \
94 + --ks /tmp/release.jks \
95 + --ks-pass "file:/tmp/keystore.pass" \
96 + --key-pass "file:/tmp/keystore.pass" \
97 + --out /out/app-release.apk \
98 + /tmp/aligned.apk; \
99 + "${tools}/apksigner" verify --print-certs /out/app-release.apk
100 +
101 +# ── export ───────────────────────────────────────────────────────────
102 +#
103 +# FROM scratch, so `--output type=local` lands the APK and nothing else.
104 +
105 +FROM scratch AS android-export
106 +COPY --from=build /out/app-release.apk /
modified android/README.md
+29 −0
@@ -155,6 +155,35 @@ would mean two things that disagree about the same text.
155 155 The cost: tables and images flatten to text. Every repo screen has an
156 156 "open on the web" action for those.
157 157
158 +## Release builds
159 +
160 +CI builds and signs the release APK in a container (`android/Dockerfile`)
161 +and publishes it to the forge's own F-Droid repository — nothing is
162 +installed on the runner, and the same command reproduces it locally:
163 +
164 +```sh
165 +docker buildx build --target android-export \
166 + --secret id=keystore,src=./release.jks \
167 + --secret id=keystore_password,src=./keystore.pass \
168 + --output type=local,dest=./apk --file android/Dockerfile .
169 +```
170 +
171 +The keystore is a BuildKit secret, so it never reaches a Gradle script,
172 +a Gradle cache, or an image layer. Signing is a separate step from
173 +Gradle for the same reason.
174 +
175 +Install from the repository by adding it in F-Droid:
176 +
177 +```
178 +<forge>/pkg/fdroid/<owner>/_all?fingerprint=<fingerprint>
179 +curl -fsSL <forge>/pkg/_keys/fdroid/<owner> # prints the fingerprint
180 +```
181 +
182 +**Two different keys, deliberately.** The *APK* key lives in CI secrets
183 +and is what Android checks on every update — lose it and every install
184 +has to be removed and redone by hand. The *index* key lives in OpenBao
185 +and never comes near CI. See [docs/fdroid-registry.md](../docs/fdroid-registry.md).
186 +
158 187 ## What's not shipped yet
159 188
160 189 - **Wiki editing** — the wiki is a git repo of markdown files, and
modified docs/fdroid-registry.md
+31 −18
@@ -245,23 +245,36 @@ META-INF/CERT.RSA PKCS#7 / CMS detached SignedData over CERT.SF
245 245 `:public_key` speaks X.509 and PKCS but **not CMS SignedData**. That is
246 246 precisely the hole `native/gitgud_apt` was built to fill for OpenPGP.
247 247
248 −**Route taken: a Rustler NIF**, `native/gitgud_apk`, using RustCrypto's
249 −`cms`, `x509-cert`, `rsa`, `sha2` and `zip`. All pure Rust. Measured
250 −against the standard `GitGud.Packages.Apt.Native` sets out:
251 −
252 −```
253 −gitgud_apt (sequoia) builder: libclang-dev runtime: —
254 −gitgud_apk (RustCrypto) builder: — runtime: —
255 −```
256 −
257 −Cheaper than the backend already accepted for apt — nothing is added to
258 −either image stage.
259 −
260 −The repo's self-signed X.509 certificate does **not** need the NIF:
261 −`:public_key.pkix_sign/2` builds it, over the RSA keypair
262 −`Packages.Token.generate_keypair/0` already produces. So
263 −`generate_keypair(owner, "fdroid")` is one new clause returning PEM,
264 −and the NIF only does CMS.
248 +**Route taken: neither.** The NIF turned out to be unnecessary once
249 +OpenBao landed, and `Formats.Fdroid.Pkcs7` emits the DER directly.
250 +
251 +Two things decided it. **The key is not ours to hold** — transit means
252 +a NIF could not simply sign; it would hand bytes back to be signed
253 +elsewhere and be called again to assemble, splitting the work exactly
254 +where the hard part was already done. And **a JAR signature needs no
255 +`signedAttrs`**: `jarsigner` has always also emitted the form where the
256 +signature covers the signed content directly, which here is the bytes
257 +of `CERT.SF`. Java's verifier accepts both, and the simpler form is one
258 +call to `Signing.sign/4` over a buffer we already have.
259 +
260 +What was left is a fixed DER structure. No build dependency, no system
261 +library, nothing added to either image stage — cheaper still than the
262 +sequoia backend accepted for apt.
263 +
264 +**`jarsigner -verify` is the test that matters.** Hand-written ASN.1
265 +fails by looking correct, so checking our own arithmetic proves
266 +nothing. It caught three real bugs: `subject` must precede
267 +`subjectPublicKeyInfo` in a `TBSCertificate`; the certificate must be
268 +*deterministic*, since a random serial and a `now` validity window mint
269 +a different fingerprint on every call and the fingerprint is what users
270 +pin; and a repository scope has to resolve to its namespace, or
271 +`/acme/tools` and `/acme/_all` would be signed by different keys.
272 +
273 +The repository's self-signed certificate is built in `Formats.Fdroid.Jar`
274 +and signed through the same backend, so under OpenBao even the
275 +certificate's own signature comes from transit. Serial and validity are
276 +derived rather than generated, because its SHA-256 is the repository
277 +fingerprint and that must never move.
265 278
266 279 ### Sign at publish, not at read
267 280
@@ -277,7 +290,7 @@ instead of once per poll.
277 290
278 291 This also keeps the fallback open (below) without a rewrite.
279 292
280 −### If RustCrypto goes wrong
293 +### If this had gone wrong
281 294
282 295 `jarsigner`/`apksigner` in their own container, the way zot is the
283 296 fallback-shaped dependency for OCI and aptly was named for apt. It
modified lib/git_gud_web/controllers/package_registry_controller.ex
+15 −0
@@ -58,6 +58,21 @@ defmodule GitGudWeb.PackageRegistryController do
58 58 through `/orgs` and `/u/:handle`, and a public key tells an attacker
59 59 nothing further.
60 60 """
61 + # F-Droid pins a repository by the SHA-256 of its signing
62 + # certificate, not by a public key — a client has no use for the PEM
63 + # every other format publishes here. So this serves the fingerprint,
64 + # which is the thing that actually goes in the repo URL.
65 + def owner_key(conn, %{"format" => "fdroid", "owner" => owner}) do
66 + with {:ok, namespace} <- Repositories.resolve_namespace(owner),
67 + {:ok, fingerprint} <- GitGud.Packages.Formats.Fdroid.fingerprint(namespace) do
68 + conn
69 + |> put_resp_content_type_raw("text/plain; charset=utf-8")
70 + |> send_resp(200, fingerprint)
71 + else
72 + _ -> send_error(conn, "fdroid", 404, "no signing key published for #{owner}")
73 + end
74 + end
75 +
61 76 def owner_key(conn, %{"format" => format, "owner" => owner}) do
62 77 with {:ok, _mod} <- adapter(format),
63 78 {:ok, namespace} <- Repositories.resolve_namespace(owner),

Parents: 1a40fc9