From c22ed56806aca5371ff6f3fa58383fbd296d2961 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 4 Oct 2026 16:32:56 -0700 Subject: [PATCH] publish to fdroid through CI --- .forgejo/workflows/build.yaml | 71 ++++++++++++++++++++++++++++++++++- README.md | 36 ++++++++++++++++++ android/app/build.gradle.kts | 9 ++++- 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 40df8a1..d283165 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -142,8 +142,9 @@ jobs: - name: Unit test and build the app working-directory: android # The release APK is unsigned here; signing happens on the HSM runner. Lint's - # release checks run as part of assembleRelease. - run: gradle --no-daemon testDebugUnitTest assembleDebug assembleRelease + # release checks run as part of assembleRelease. The run number is the + # versionCode, so each published build is an update to the one before. + run: gradle --no-daemon -PtallyVersionCode=${{ github.run_number }} testDebugUnitTest assembleDebug assembleRelease - name: Upload the unsigned release APK uses: actions/upload-artifact@v3 with: @@ -270,3 +271,69 @@ jobs: status: ${{ job.status }} user: ${{ secrets.NTFY_USER }} password: ${{ secrets.NTFY_PASSWORD }} + + fdroid: + name: F-Droid repo + # The signed APK into https://fdroid.lerch.org/repo, which is /data/fdroid on the HSM + # runner's host: this label is what puts the job on that host, as it does the + # signing. The repo itself (config.yml, metadata, the index signing keystore) lives + # there, not here; README, "Install with F-Droid". + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest-with-hsm + needs: sign + env: + # fdroidserver, pinned: `fdroid update` regenerates and signs the repo's index. + FDROID_IMAGE: registry.gitlab.com/fdroid/docker-executable-fdroidserver@sha256:75f6b88ef13a63fc5c49aa4c4dde35607c4a364d22cf57d534aa75c54effa56d + FDROID_REPO: /data/fdroid + # How many tally builds the repo offers; older ones are deleted. + FDROID_KEEP: 3 + steps: + - name: Download the signed release + uses: actions/download-artifact@v3 + with: + name: tally-release + path: dist + - name: Publish to the F-Droid repo + # In fdroidserver's container on the host's daemon, as the repo's owner (uid + # 1000). The repo is a bind mount of a host path, so the APK cannot be copied in + # from this job's workspace by path: it goes over stdin. `--mount` rather than + # `-v`, so that on a host without the repo the run fails instead of creating an + # empty one. The versionCode is the run number (the android job), and so is the + # file name, which is what orders the builds for pruning. + # + # The index signing keystore's password (the store's and the key's are the same) + # is a secret, and only that: it is not on the host beside the keystore, and a + # pull request from a fork gets no secrets. It reaches fdroid as environment + # variables, passed to docker by name so the value is never on a command line; + # the repo's config.yml reads them with {env: ...}. + env: + APK: dev.lerch.tally_${{ github.run_number }}.apk + FDROID_KEY_STORE_PASS: ${{ secrets.FDROID_KEYSTORE_PASS }} + FDROID_KEY_PASS: ${{ secrets.FDROID_KEYSTORE_PASS }} + run: | + set -eu + [ -n "$FDROID_KEY_STORE_PASS" ] || { echo "the FDROID_KEYSTORE_PASS secret is not set" >&2; exit 1; } + docker run --rm -i -u 1000:1000 -e HOME=/tmp -e APK -e FDROID_KEEP \ + -e FDROID_KEY_STORE_PASS -e FDROID_KEY_PASS \ + -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory \ + -e GIT_CONFIG_VALUE_0=/home/vagrant/fdroidserver \ + --mount "type=bind,source=${FDROID_REPO},target=/repo" \ + --entrypoint sh "$FDROID_IMAGE" -euc ' + [ -f config.yml ] || { echo "no F-Droid repo at /repo (no config.yml)" >&2; exit 1; } + cat > "repo/${APK}.part" + mv "repo/${APK}.part" "repo/${APK}" + ls repo/dev.lerch.tally_*.apk | sort -t _ -k 2 -n | head -n "-${FDROID_KEEP}" | + while read -r old; do echo "removing ${old}"; rm -f "${old}"; done + . /etc/profile.d/bsenv.sh + "${fdroidserver}/fdroid" update + ls repo/dev.lerch.tally_*.apk + ' < dist/tally.apk + - name: Notify + uses: https://git.lerch.org/lobo/action-notify-ntfy@v2 + if: always() + with: + host: ${{ secrets.NTFY_HOST }} + topic: ${{ secrets.NTFY_TOPIC }} + status: ${{ job.status }} + user: ${{ secrets.NTFY_USER }} + password: ${{ secrets.NTFY_PASSWORD }} diff --git a/README.md b/README.md index 2128c5c..9df3f52 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,41 @@ Android ties an app to its signing key permanently, so a published APK cannot up copy you built yourself (which is signed with your debug key), and the reverse. Uninstall one before installing the other; the app's saved state goes with it. +### Install with F-Droid + +Every master build is also published to an F-Droid repository, so the phone keeps itself +up to date. In F-Droid (or Droid-ify, or Obtainium's "F-Droid third-party repo" source), +add this repository; the fingerprint in the URL is what lets the client check the index +it downloads: + +``` +https://fdroid.lerch.org/repo?fingerprint=99387004A55002CF54F291D7ECA6A83AD7C7945E8E633160CC76790AD12E2D6E +``` + +Then install Tally from it. Each build's versionCode is the CI run number (its version +name is `0.1.0+`), so every master build is an update to the one before, and the +repo keeps the newest three. The APK is the same one the registry publishes, signed by +the HSM key with the certificate in +[android/signing-cert.pem](android/signing-cert.pem). + +The repository itself is not in this repo. It is `/data/fdroid` on the HSM runner's host, +served by nginx (only its `repo/`), with its `config.yml`, the app metadata, and the +keystore that signs the index. The keystore's password is kept off that host: CI has it +as the `FDROID_KEYSTORE_PASS` repository secret. The `fdroid` job in the workflow adds +each build and runs `fdroid update` there, in fdroidserver's container (pinned in the +workflow). To regenerate the index by hand on that host, after editing the metadata say: + +``` +read -rs FDROID_KEY_STORE_PASS && export FDROID_KEY_STORE_PASS FDROID_KEY_PASS="$FDROID_KEY_STORE_PASS" +docker run --rm -u 1000:1000 -e HOME=/tmp -e FDROID_KEY_STORE_PASS -e FDROID_KEY_PASS \ + -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory \ + -e GIT_CONFIG_VALUE_0=/home/vagrant/fdroidserver \ + --mount type=bind,source=/data/fdroid,target=/repo \ + --entrypoint sh "" -euc ' + . /etc/profile.d/bsenv.sh + "$fdroidserver/fdroid" update' +``` + ### With nix This repository is a flake, for the CLI and TUI (the Android app stays with gradle). To @@ -242,6 +277,7 @@ punctuation: the source is ASCII. | Android | `ubuntu-latest` | `zig build android`, the library audit, the JVM unit tests, debug and unsigned release APKs (with lint's release checks) | | Sign | `ubuntu-latest-with-hsm` | master only: signs the APK with the HSM, then every release file with a detached signature logged to sigstore | | Publish | `ubuntu-latest` | master only: uploads the signed files to the generic package registry under the short SHA and `latest` | +| F-Droid repo | `ubuntu-latest-with-hsm` | master only: adds the signed APK to the F-Droid repository on the HSM runner's host and regenerates its index | Every job reports to ntfy. Zlint and Gradle are downloaded at pinned versions and checked against pinned SHA-256 digests; Zig's version comes from `build.zig.zon`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d134e64..dc0e48d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -12,8 +12,13 @@ android { applicationId = "dev.lerch.tally" minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "0.1.0" + // Every build an updater should offer needs a larger versionCode than the one + // installed: Android, F-Droid and Obtainium all compare it, not the name. CI + // passes its run number (`-PtallyVersionCode=N`); a local build is 1, below + // anything CI publishes. The name carries the same number, so it is visible. + val tallyVersionCode = (findProperty("tallyVersionCode") as String?)?.toInt() + versionCode = tallyVersionCode ?: 1 + versionName = if (tallyVersionCode != null) "0.1.0+$tallyVersionCode" else "0.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // Only the ABIs `zig build android` produces. Anything else would package an