neiam
/feedpug
feedpub
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Build and publish the Android client from CI
017d162 · Gabriel Morell · 2026-09-12 20:26
Message
{commit_body(@commit)}
Files changed
added
.forgejo/workflows/android.yml
+124
−0
@@ -0,0 +1,124 @@
| 1 | +# Publishes the FeedPug 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 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 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
+9
−0
@@ -35,3 +35,12 @@ feed_pug-*.tar
| 35 | 35 | npm-debug.log |
| 36 | 36 | /assets/node_modules/ |
| 37 | 37 | |
| 38 | + | |
| 39 | +# Android release signing material. The APK signing key cannot be | |
| 40 | +# rotated — Android checks its certificate on every update — so a | |
| 41 | +# commit of one is not recoverable by deleting it later. Keep it in | |
| 42 | +# OpenBao KV and in the CI secret, never here. | |
| 43 | +# See android/README.md#the-release-keystore. | |
| 44 | +*.jks | |
| 45 | +*.keystore | |
| 46 | +keystore.pass |
added
android/Dockerfile
+114
−0
@@ -0,0 +1,114 @@
| 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 android/Dockerfile . | |
| 13 | +# | |
| 14 | +# Run from the repository root — the build context is the repo, so the | |
| 15 | +# app 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` is a moving target under a cache that has no | |
| 22 | +# way to notice it moved. | |
| 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 | +# | |
| 87 | +# No --key-pass. apksigner reads each `file:` password as a *sequential* | |
| 88 | +# read from that file, so passing the same one-line file to both | |
| 89 | +# --ks-pass and --key-pass makes the second read hit EOF: | |
| 90 | +# | |
| 91 | +# Failed to read Key "…" password: end of file reached | |
| 92 | +# | |
| 93 | +# Omitted, the key password defaults to the store password, which is | |
| 94 | +# what PKCS12 keystores use anyway. | |
| 95 | +RUN --mount=type=secret,id=keystore,target=/tmp/release.jks \ | |
| 96 | + --mount=type=secret,id=keystore_password,target=/tmp/keystore.pass \ | |
| 97 | + set -eu; \ | |
| 98 | + tools="${ANDROID_HOME}/build-tools/${BUILD_TOOLS}"; \ | |
| 99 | + unsigned=app/build/outputs/apk/release/app-release-unsigned.apk; \ | |
| 100 | + mkdir -p /out; \ | |
| 101 | + "${tools}/zipalign" -p -f 4 "$unsigned" /tmp/aligned.apk; \ | |
| 102 | + "${tools}/apksigner" sign \ | |
| 103 | + --ks /tmp/release.jks \ | |
| 104 | + --ks-pass "file:/tmp/keystore.pass" \ | |
| 105 | + --out /out/app-release.apk \ | |
| 106 | + /tmp/aligned.apk; \ | |
| 107 | + "${tools}/apksigner" verify --print-certs /out/app-release.apk | |
| 108 | + | |
| 109 | +# ── export ─────────────────────────────────────────────────────────── | |
| 110 | +# | |
| 111 | +# FROM scratch, so `--output type=local` lands the APK and nothing else. | |
| 112 | + | |
| 113 | +FROM scratch AS android-export | |
| 114 | +COPY --from=build /out/app-release.apk / |
modified
android/README.md
+102
−0
@@ -67,3 +67,105 @@ app/src/main/java/org/neiam/feedpug/app/
| 67 | 67 | ui/theme/Theme.kt # FeedPugTheme wrapper (palette + type) |
| 68 | 68 | res/font/ # b612_regular.ttf, b612_bold.ttf |
| 69 | 69 | ``` |
| 70 | + | |
| 71 | +## Release builds | |
| 72 | + | |
| 73 | +CI builds and signs the release APK in a container | |
| 74 | +(`android/Dockerfile`) and publishes it to the forge's own F-Droid | |
| 75 | +repository — nothing is installed on the runner, and the same command | |
| 76 | +reproduces it locally: | |
| 77 | + | |
| 78 | +```sh | |
| 79 | +docker buildx build --target android-export \ | |
| 80 | + --secret id=keystore,src=./release.jks \ | |
| 81 | + --secret id=keystore_password,src=./keystore.pass \ | |
| 82 | + --output type=local,dest=./apk --file android/Dockerfile . | |
| 83 | +``` | |
| 84 | + | |
| 85 | +The keystore is a BuildKit secret, so it never reaches a Gradle script, | |
| 86 | +a Gradle cache, or an image layer. Signing is a separate step from | |
| 87 | +Gradle for the same reason. | |
| 88 | + | |
| 89 | +`.forgejo/workflows/android.yml` runs that on every push to `master` | |
| 90 | +and `POST`s the APK to `/pkg/fdroid/<owner>/<repo>/upload` with the | |
| 91 | +job's own token — there is no publishing credential to create. | |
| 92 | + | |
| 93 | +`versionCode` comes from the CI run number and `versionName` from the | |
| 94 | +commit, so every published build sorts after the last one. Local builds | |
| 95 | +stay at `versionCode = 1` / `…+dev`; they are never published. | |
| 96 | + | |
| 97 | +Install from the repository by adding it in F-Droid: | |
| 98 | + | |
| 99 | +``` | |
| 100 | +<forge>/pkg/fdroid/<owner>/_all?fingerprint=<fingerprint> | |
| 101 | +curl -fsSL <forge>/pkg/_keys/fdroid/<owner> # prints the fingerprint | |
| 102 | +``` | |
| 103 | + | |
| 104 | +One entry covers every Android app the owner publishes, this one | |
| 105 | +included. | |
| 106 | + | |
| 107 | +**Two different keys, deliberately.** The *APK* key below is what | |
| 108 | +Android checks on every update. The *index* key is per-owner, lives in | |
| 109 | +the forge's OpenBao transit engine, and never comes near CI. | |
| 110 | + | |
| 111 | +## The release keystore | |
| 112 | + | |
| 113 | +Generate it once. Android verifies this certificate on every update, so | |
| 114 | +**losing it means every install has to be uninstalled and redone by | |
| 115 | +hand** — there is no recovery and no rotation. | |
| 116 | + | |
| 117 | +```sh | |
| 118 | +keytool -genkeypair -v \ | |
| 119 | + -keystore release.jks \ | |
| 120 | + -storetype PKCS12 \ | |
| 121 | + -alias feedpug \ | |
| 122 | + -keyalg RSA -keysize 4096 \ | |
| 123 | + -validity 10000 \ | |
| 124 | + -dname "CN=FeedPug, O=neiam, C=US" | |
| 125 | +``` | |
| 126 | + | |
| 127 | +It prompts for the password rather than taking `-storepass`, which | |
| 128 | +keeps it out of shell history. PKCS12 uses one password for the store | |
| 129 | +and the key, which is what `android/Dockerfile` assumes — it passes the | |
| 130 | +one password file to `--ks-pass` and lets the key password default to | |
| 131 | +it. | |
| 132 | + | |
| 133 | +### Back it up before it exists anywhere else | |
| 134 | + | |
| 135 | +So there is never a window where your laptop holds the only copy: | |
| 136 | + | |
| 137 | +```sh | |
| 138 | +export BAO_TOKEN=<root token> | |
| 139 | +E() { kubectl -n openbao exec -i statefulset/openbao -- env BAO_TOKEN="$BAO_TOKEN" "$@"; } | |
| 140 | + | |
| 141 | +E bao kv put kv/feedpug/android \ | |
| 142 | + keystore="$(base64 -w0 release.jks)" \ | |
| 143 | + password='<the password>' | |
| 144 | +``` | |
| 145 | + | |
| 146 | +`kv-v2` versions writes, so a later overwrite doesn't destroy this one. | |
| 147 | +Recover with: | |
| 148 | + | |
| 149 | +```sh | |
| 150 | +E bao kv get -field=keystore kv/feedpug/android | base64 -d > release.jks | |
| 151 | +``` | |
| 152 | + | |
| 153 | +### Hand it to CI | |
| 154 | + | |
| 155 | +On the repository: **Settings → Actions → Secrets**. | |
| 156 | + | |
| 157 | +| Secret | Value | | |
| 158 | +|---|---| | |
| 159 | +| `ANDROID_KEYSTORE` | `base64 -w0 release.jks` | | |
| 160 | +| `ANDROID_KEYSTORE_PASS` | the password | | |
| 161 | + | |
| 162 | +The `android` job skips cleanly while these are unset, so nothing | |
| 163 | +breaks before you get to it. It refuses to fall back to a debug-signed | |
| 164 | +APK: that key is shared by everyone with an SDK, so publishing one | |
| 165 | +would let anyone ship an "update" to it. | |
| 166 | + | |
| 167 | +CI does not read the keystore out of OpenBao. `apksigner` has no | |
| 168 | +external-signer hook, so the key has to reach the build container | |
| 169 | +whichever store it comes from, and the runner is outside the cluster | |
| 170 | +while OpenBao has no ingress on purpose. Bao holds the durable copy; | |
| 171 | +the Forgejo secret is the operational one. |
modified
android/app/build.gradle.kts
+32
−2
@@ -5,6 +5,10 @@ plugins {
| 5 | 5 | alias(libs.plugins.kotlin.serialization) |
| 6 | 6 | } |
| 7 | 7 | |
| 8 | +// Bumped by hand for a real release; the build metadata after it comes | |
| 9 | +// from CI. | |
| 10 | +val baseVersion = "0.1.0" | |
| 11 | + | |
| 8 | 12 | android { |
| 9 | 13 | namespace = "org.neiam.feedpug.app" |
| 10 | 14 | compileSdk = 34 |
@@ -13,8 +17,21 @@ android {
| 13 | 17 | applicationId = "org.neiam.feedpug.app" |
| 14 | 18 | minSdk = 26 |
| 15 | 19 | targetSdk = 34 |
| 16 | − versionCode = 1 | |
| 17 | − versionName = "0.1.0" | |
| 20 | + // Android refuses an update whose versionCode isn't higher than | |
| 21 | + // what's installed, and F-Droid orders versions by it — so a | |
| 22 | + // hardcoded 1 means no phone ever sees a second release. It also | |
| 23 | + // means every CI build publishes the same filename with | |
| 24 | + // different bytes, which the registry correctly refuses with a | |
| 25 | + // 409. | |
| 26 | + // | |
| 27 | + // The CI run number is monotonic and needs no state kept | |
| 28 | + // anywhere. Local builds stay at 1, which is fine: they are | |
| 29 | + // never published. | |
| 30 | + versionCode = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 1 | |
| 31 | + | |
| 32 | + // The commit is what you want when someone reports a bug | |
| 33 | + // against a build. Local builds just say "dev". | |
| 34 | + versionName = "$baseVersion+" + ((System.getenv("GITHUB_SHA") ?: "dev").take(7)) | |
| 18 | 35 | } |
| 19 | 36 | |
| 20 | 37 | buildTypes { |
@@ -34,6 +51,19 @@ android {
| 34 | 51 | buildFeatures { |
| 35 | 52 | compose = true |
| 36 | 53 | } |
| 54 | + | |
| 55 | + lint { | |
| 56 | + // `assembleRelease` is a packaging step here, not a review gate. | |
| 57 | + // | |
| 58 | + // lintVital doubles the release build and fails for reasons that | |
| 59 | + // have nothing to do with the code: lint throws on JDK 25 with | |
| 60 | + // only the version string ("25.0.4.1") as the error, so a release | |
| 61 | + // built on a modern local JDK fails while the same commit builds | |
| 62 | + // fine in the container's JDK. | |
| 63 | + // | |
| 64 | + // Lint still runs on demand: ./gradlew :app:lint | |
| 65 | + checkReleaseBuilds = false | |
| 66 | + } | |
| 37 | 67 | packaging { |
| 38 | 68 | resources { |
| 39 | 69 | excludes += "/META-INF/{AL2.0,LGPL2.1}" |
Parents: 07b612e