neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Build and publish the Android client from CI
d90390e · Gabriel Morell · 2026-09-12 17:28
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