CI: build, sign and publish the APK to the forge's F-Droid registry

aba5cd6 · Gabriel Morell · 2026-09-28 15:56

8 files +292 -2
Message
{commit_body(@commit)}

Files changed

added .dockerignore
+9 −0
@@ -0,0 +1,9 @@
1 +.git
2 +.gradle
3 +.idea
4 +.kotlin
5 +**/build
6 +local.properties
7 +*.jks
8 +*.pass
9 +apk
added .forgejo/workflows/build.yml
+126 −0
@@ -0,0 +1,126 @@
1 +name: build
2 +on:
3 + push:
4 + branches: [master]
5 +
6 +jobs:
7 + # Builds, signs, and publishes the APK to this repository's own F-Droid
8 + # registry. Adapted from GitGud's `android` job.
9 + #
10 + # Needs two secrets, and skips cleanly without them:
11 + #
12 + # ANDROID_KEYSTORE base64 of the release keystore (.jks)
13 + # ANDROID_KEYSTORE_PASS its password
14 + #
15 + # Generate one once, and keep it: the signing certificate is what
16 + # Android checks on every update, so losing it means every install
17 + # has to be uninstalled and redone by hand.
18 + #
19 + # keytool -genkeypair -v -keystore release.jks -alias ova \
20 + # -keyalg RSA -keysize 4096 -validity 10000 \
21 + # -dname "CN=Ova, O=neiam, C=US"
22 + # base64 -w0 release.jks
23 + android:
24 + runs-on: self-hosted
25 + steps:
26 + - uses: actions/checkout@v4
27 +
28 + - uses: docker/setup-buildx-action@v3
29 +
30 + - name: Resolve the forge URL
31 + env:
32 + CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
33 + run: |
34 + set -euo pipefail
35 + forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}
36 +
37 + if [ -z "$forge_url" ]; then
38 + echo "No forge URL available." >&2
39 + exit 1
40 + fi
41 +
42 + echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"
43 +
44 + # A secret can't be read in a job-level `if`, so the gate is a
45 + # step output that everything after checks.
46 + - name: Unpack the signing keystore
47 + id: keystore
48 + env:
49 + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
50 + KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
51 + run: |
52 + set -euo pipefail
53 +
54 + if [ -z "${KEYSTORE:-}" ] || [ -z "${KEYSTORE_PASS:-}" ]; then
55 + echo "No ANDROID_KEYSTORE / ANDROID_KEYSTORE_PASS set — skipping."
56 + echo "An unsigned or debug-signed APK is worse than none: the"
57 + echo "debug key is shared by every developer, so publishing one"
58 + echo "would let anyone ship an 'update' to it."
59 + echo "skip=true" >> "$GITHUB_OUTPUT"
60 + exit 0
61 + fi
62 +
63 + umask 077
64 + printf '%s' "$KEYSTORE" | base64 -d > release.jks
65 + printf '%s' "$KEYSTORE_PASS" > keystore.pass
66 + echo "skip=false" >> "$GITHUB_OUTPUT"
67 +
68 + - name: Build and sign the APK
69 + if: steps.keystore.outputs.skip != 'true'
70 + env:
71 + RUN_NUMBER: ${{ github.run_number }}
72 + run: |
73 + set -euo pipefail
74 +
75 + # One stamp for the whole run, computed here rather than
76 + # inside Gradle, so every artifact a run produces agrees on
77 + # its version even if the build is retried a step at a time.
78 + stamp=$(date -u +%s)
79 + echo "version: 0.1.0-dev-$stamp (versionCode $RUN_NUMBER)"
80 +
81 + docker buildx build \
82 + --target android-export \
83 + --build-arg GITHUB_RUN_NUMBER="$RUN_NUMBER" \
84 + --build-arg SOURCE_DATE_EPOCH="$stamp" \
85 + --secret id=keystore,src=./release.jks \
86 + --secret id=keystore_password,src=./keystore.pass \
87 + --output type=local,dest=./apk \
88 + --file Dockerfile .
89 +
90 + # The keystore is gone before anything else runs, so a later step
91 + # or a failure mid-job can't leave it on a persistent runner.
92 + - name: Shred the keystore
93 + if: always()
94 + run: shred -u release.jks keystore.pass 2>/dev/null || rm -f release.jks keystore.pass
95 +
96 + - name: Publish to the F-Droid repository
97 + if: steps.keystore.outputs.skip != 'true'
98 + env:
99 + REPOSITORY: ${{ github.repository }}
100 + TOKEN: ${{ github.token }}
101 + run: |
102 + set -euo pipefail
103 + curl --fail-with-body -sS \
104 + -u "gitgud-actions:$TOKEN" \
105 + -H "Content-Type: application/vnd.android.package-archive" \
106 + --data-binary "@apk/app-release.apk" \
107 + "$FORGE_URL/pkg/fdroid/$REPOSITORY/upload"
108 + echo
109 +
110 + - name: How to install it
111 + if: steps.keystore.outputs.skip != 'true'
112 + env:
113 + OWNER: ${{ github.repository_owner }}
114 + run: |
115 + set -euo pipefail
116 + fingerprint=$(curl -fsSL "$FORGE_URL/pkg/_keys/fdroid/$OWNER")
117 +
118 + cat <<EOF
119 + Add this repository in F-Droid (Settings -> Repositories -> +):
120 +
121 + $FORGE_URL/pkg/fdroid/$OWNER/_all?fingerprint=$fingerprint
122 +
123 + One entry covers every Android app $OWNER publishes. The
124 + fingerprint pins the index signing certificate; F-Droid
125 + refuses an index signed by anything else.
126 + EOF
modified .gitignore
+3 −0
@@ -13,3 +13,6 @@
13 13 .externalNativeBuild
14 14 .cxx
15 15 local.properties
16 +*.jks
17 +*.pass
18 +/apk
added Dockerfile
+127 −0
@@ -0,0 +1,127 @@
1 +# Builds and signs the release APK. Adapted from GitGud's android/Dockerfile.
2 +#
3 +# In a container rather than on the runner: the runner needs docker and
4 +# nothing else, and a developer can reproduce exactly what CI did without
5 +# 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 +
13 +# 21 to match gradle/gradle-daemon-jvm.properties; anything else and
14 +# Gradle goes off to download a second JDK.
15 +ARG JDK_IMAGE=eclipse-temurin:21-jdk
16 +
17 +FROM ${JDK_IMAGE} AS sdk
18 +
19 +# Pinned. `…_latest.zip` would make the toolchain drift under a cache
20 +# that has no way to notice.
21 +ARG CMDLINE_TOOLS=commandlinetools-linux-14742923_latest.zip
22 +# compileSdk is release(36) { minorApiLevel = 1 }, and AGP 9.2 builds
23 +# with build-tools 36.0.0.
24 +ARG ANDROID_PLATFORM=36.1
25 +ARG BUILD_TOOLS=36.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 Gradle and Maven downloads cache as one layer
53 +# that survives every change to the Kotlin sources.
54 +
55 +FROM sdk AS deps
56 +
57 +WORKDIR /src
58 +COPY gradle ./gradle
59 +COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
60 +COPY 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=36.0.0
75 +
76 +# A container build inherits nothing from the runner's environment, so
77 +# the version has to be handed in explicitly. Without these every run
78 +# builds versionCode 1 -- the same filename with different bytes, which
79 +# the registry refuses with a 409.
80 +#
81 +# Declared in this stage so they don't invalidate the SDK and
82 +# dependency layers, which are most of the build time.
83 +ARG GITHUB_RUN_NUMBER
84 +ARG SOURCE_DATE_EPOCH
85 +ARG RELEASE_VERSION
86 +ENV GITHUB_RUN_NUMBER=${GITHUB_RUN_NUMBER} \
87 + SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} \
88 + RELEASE_VERSION=${RELEASE_VERSION}
89 +
90 +COPY app ./app
91 +
92 +# AGP 9 only wires unit tests to the debug variant; they are pure JVM
93 +# logic, so that still gates what gets signed.
94 +RUN echo "building versionCode=${GITHUB_RUN_NUMBER:-1}" \
95 + && ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleRelease
96 +
97 +# Sign as a separate step from Gradle so the keystore never reaches a
98 +# build script, a Gradle cache, or an image layer. BuildKit secrets are
99 +# mounted for the life of one RUN and are not committed.
100 +#
101 +# zipalign before apksigner, not after: aligning rewrites offsets, and
102 +# doing it to an already-signed APK invalidates the v2 signature.
103 +#
104 +# No --key-pass: apksigner reads each `file:` password sequentially, so
105 +# passing the same one-line file twice hits EOF on the second read.
106 +# Omitted, the key password defaults to the store password, which is
107 +# what PKCS12 keystores use anyway.
108 +RUN --mount=type=secret,id=keystore,target=/tmp/release.jks \
109 + --mount=type=secret,id=keystore_password,target=/tmp/keystore.pass \
110 + set -eu; \
111 + tools="${ANDROID_HOME}/build-tools/${BUILD_TOOLS}"; \
112 + unsigned=app/build/outputs/apk/release/app-release-unsigned.apk; \
113 + mkdir -p /out; \
114 + "${tools}/zipalign" -p -f 4 "$unsigned" /tmp/aligned.apk; \
115 + "${tools}/apksigner" sign \
116 + --ks /tmp/release.jks \
117 + --ks-pass "file:/tmp/keystore.pass" \
118 + --out /out/app-release.apk \
119 + /tmp/aligned.apk; \
120 + "${tools}/apksigner" verify --print-certs /out/app-release.apk
121 +
122 +# ── export ───────────────────────────────────────────────────────────
123 +#
124 +# FROM scratch, so `--output type=local` lands the APK and nothing else.
125 +
126 +FROM scratch AS android-export
127 +COPY --from=build /out/app-release.apk /
modified app/build.gradle.kts
+27 −2
@@ -3,6 +3,23 @@ plugins {
3 3 alias(libs.plugins.kotlin.compose)
4 4 }
5 5
6 +// Bumped by hand; everything after it is generated.
7 +val baseVersion = "0.1.0"
8 +
9 +// Seconds, from the environment when it's there. CI sets it once per run
10 +// so every artifact from that run agrees; a local build computes it,
11 +// which is fine because local builds are never published.
12 +val buildTimestamp: Long =
13 + (System.getenv("SOURCE_DATE_EPOCH") ?: System.getenv("BUILD_TIMESTAMP"))
14 + ?.toLongOrNull()
15 + ?: (System.currentTimeMillis() / 1000)
16 +
17 +// A real release overrides the whole string; until there is one, every
18 +// build says so in its name.
19 +val appVersionName: String =
20 + System.getenv("RELEASE_VERSION")?.takeIf { it.isNotBlank() }
21 + ?: "$baseVersion-dev-$buildTimestamp"
22 +
6 23 android {
7 24 namespace = "io.neiam.ova"
8 25 compileSdk {
@@ -15,8 +32,11 @@ android {
15 32 applicationId = "io.neiam.ova"
16 33 minSdk = 24
17 34 targetSdk = 36
18 − versionCode = 1
19 − versionName = "1.0"
35 + // Android refuses an update whose versionCode isn't higher than
36 + // what's installed, and F-Droid orders versions by it. The CI run
37 + // number is monotonic and needs no state; local builds stay at 1.
38 + versionCode = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 1
39 + versionName = appVersionName
20 40
21 41 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
22 42 }
@@ -35,6 +55,11 @@ android {
35 55 buildFeatures {
36 56 compose = true
37 57 }
58 + lint {
59 + // `assembleRelease` is a packaging step, not a review gate; lint
60 + // still runs on demand with ./gradlew :app:lint.
61 + checkReleaseBuilds = false
62 + }
38 63 }
39 64
40 65 dependencies {

Parents: abe73d0