Add F-Droid publishing CI

cf9168b · gmorell · 2026-09-13 03:35

5 files +262 -0
Message
{commit_body(@commit)}

Files changed

added .dockerignore
+18 −0
@@ -0,0 +1,18 @@
1 +# The build context is the repository root, so keep build output and
2 +# caches out of it: a local app/build is ~150 MB, and shipping it to
3 +# BuildKit on every run costs more than the build itself. Nothing
4 +# excluded here is a build input — the image builds from sources.
5 +.git/
6 +.gradle/
7 +.kotlin/
8 +build/
9 +**/build/
10 +.idea/
11 +*.iml
12 +local.properties
13 +.DS_Store
14 +
15 +# CI definitions, not build inputs; editing them should not bust the
16 +# context cache.
17 +.forgejo/
18 +.github/
added .forgejo/workflows/android.yml
+125 −0
@@ -0,0 +1,125 @@
1 +# Publishes Scropple to the forge's own F-Droid repository, the same way
2 +# 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 the Dockerfile.
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 + --progress=plain \
84 + --file Dockerfile .
85 +
86 + # The keystore is gone before anything else runs, so a later step
87 + # or a failure mid-job can't leave it on a persistent runner.
88 + - name: Shred the keystore
89 + if: always()
90 + run: shred -u release.jks keystore.pass 2>/dev/null || rm -f release.jks keystore.pass
91 +
92 + # `${{ github.token }}` is minted for this job and authorized to
93 + # publish to THIS repository's packages and nowhere else, so there
94 + # is no secret to create first.
95 + - name: Publish to the F-Droid repository
96 + if: steps.keystore.outputs.skip != 'true'
97 + env:
98 + REPOSITORY: ${{ github.repository }}
99 + TOKEN: ${{ github.token }}
100 + run: |
101 + set -euo pipefail
102 + curl --fail-with-body -sS \
103 + -u "ci:$TOKEN" \
104 + -H "Content-Type: application/vnd.android.package-archive" \
105 + --data-binary "@apk/app-release.apk" \
106 + "$FORGE_URL/pkg/fdroid/$REPOSITORY/upload"
107 + echo
108 +
109 + - name: How to install it
110 + if: steps.keystore.outputs.skip != 'true'
111 + env:
112 + OWNER: ${{ github.repository_owner }}
113 + run: |
114 + set -euo pipefail
115 + fingerprint=$(curl -fsSL "$FORGE_URL/pkg/_keys/fdroid/$OWNER")
116 +
117 + cat <<EOF
118 + Add this repository in F-Droid (Settings -> Repositories -> +):
119 +
120 + $FORGE_URL/pkg/fdroid/$OWNER/_all?fingerprint=$fingerprint
121 +
122 + One entry covers every Android app $OWNER publishes. The
123 + fingerprint pins the index signing certificate; F-Droid
124 + refuses an index signed by anything else.
125 + EOF
added Dockerfile
+119 −0
@@ -0,0 +1,119 @@
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 Dockerfile .
14 +#
15 +# Run from the repository root, which is also the build context: this
16 +# repository is the Gradle project, with nothing else in it. (Adapted
17 +# from waxx, where the app is a subdirectory and the context is
18 +# `android` instead.)
19 +
20 +# 17, matching .github/workflows/release.yml so the two CI paths build
21 +# against the same JDK. AGP 8.5.2 wants 17; don't raise it casually.
22 +ARG JDK_IMAGE=eclipse-temurin:17-jdk
23 +
24 +FROM ${JDK_IMAGE} AS sdk
25 +
26 +# Pinned. `…_latest.zip` is a moving target under a cache that has no
27 +# way to notice it moved.
28 +ARG CMDLINE_TOOLS=commandlinetools-linux-11076708_latest.zip
29 +ARG ANDROID_PLATFORM=34
30 +ARG BUILD_TOOLS=34.0.0
31 +
32 +ENV ANDROID_HOME=/opt/android-sdk \
33 + DEBIAN_FRONTEND=noninteractive
34 +
35 +RUN apt-get update \
36 + && apt-get install -y --no-install-recommends unzip curl ca-certificates \
37 + && rm -rf /var/lib/apt/lists/*
38 +
39 +RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
40 + && curl -fsSL "https://dl.google.com/android/repository/${CMDLINE_TOOLS}" -o /tmp/tools.zip \
41 + && unzip -q /tmp/tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
42 + && mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
43 + && rm /tmp/tools.zip
44 +
45 +ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
46 +
47 +# `yes |` because sdkmanager prompts per licence and there is no
48 +# non-interactive flag that covers all of them.
49 +RUN yes | sdkmanager --licenses > /dev/null \
50 + && sdkmanager --install \
51 + "platforms;android-${ANDROID_PLATFORM}" \
52 + "build-tools;${BUILD_TOOLS}" \
53 + "platform-tools" > /dev/null
54 +
55 +# ── dependencies ─────────────────────────────────────────────────────
56 +#
57 +# Build files only, so the ~200 MB of Gradle and Maven downloads cache
58 +# as one layer that survives every change to the Kotlin sources.
59 +
60 +FROM sdk AS deps
61 +
62 +WORKDIR /src
63 +COPY gradle ./gradle
64 +COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
65 +COPY app/build.gradle.kts ./app/
66 +
67 +RUN echo "sdk.dir=${ANDROID_HOME}" > local.properties
68 +
69 +# A task that resolves the whole dependency graph without needing the
70 +# sources. `--no-daemon` because a daemon outliving the RUN is just a
71 +# process the image has to reap.
72 +RUN ./gradlew --no-daemon :app:dependencies --configuration releaseRuntimeClasspath > /dev/null \
73 + || echo "dependency warm-up failed; the build below will fetch them"
74 +
75 +# ── build ────────────────────────────────────────────────────────────
76 +
77 +FROM deps AS build
78 +
79 +ARG BUILD_TOOLS=34.0.0
80 +COPY app ./app
81 +
82 +RUN ./gradlew --no-daemon :app:assembleRelease
83 +
84 +# Sign as a separate step from Gradle so the keystore never reaches a
85 +# build script, a Gradle cache, or an image layer. BuildKit secrets are
86 +# mounted for the life of one RUN and are not committed.
87 +#
88 +# zipalign before apksigner, not after: aligning rewrites offsets, and
89 +# doing it to an already-signed APK invalidates the v2 signature that
90 +# covers them.
91 +#
92 +# No --key-pass. apksigner reads each `file:` password as a *sequential*
93 +# read from that file, so passing the same one-line file to both
94 +# --ks-pass and --key-pass makes the second read hit EOF:
95 +#
96 +# Failed to read Key "…" password: end of file reached
97 +#
98 +# Omitted, the key password defaults to the store password, which is
99 +# what PKCS12 keystores use anyway.
100 +RUN --mount=type=secret,id=keystore,target=/tmp/release.jks \
101 + --mount=type=secret,id=keystore_password,target=/tmp/keystore.pass \
102 + set -eu; \
103 + tools="${ANDROID_HOME}/build-tools/${BUILD_TOOLS}"; \
104 + unsigned=app/build/outputs/apk/release/app-release-unsigned.apk; \
105 + mkdir -p /out; \
106 + "${tools}/zipalign" -p -f 4 "$unsigned" /tmp/aligned.apk; \
107 + "${tools}/apksigner" sign \
108 + --ks /tmp/release.jks \
109 + --ks-pass "file:/tmp/keystore.pass" \
110 + --out /out/app-release.apk \
111 + /tmp/aligned.apk; \
112 + "${tools}/apksigner" verify --print-certs /out/app-release.apk
113 +
114 +# ── export ───────────────────────────────────────────────────────────
115 +#
116 +# FROM scratch, so `--output type=local` lands the APK and nothing else.
117 +
118 +FROM scratch AS android-export
119 +COPY --from=build /out/app-release.apk /

Parents: df81ca5