publish to fdroid through CI
All checks were successful
Build / Engine, CLI and TUI (push) Successful in 53s
Build / Android (push) Successful in 4m29s
Build / Sign (push) Successful in 2m18s
Build / Publish (push) Successful in 12s
Build / F-Droid repo (push) Successful in 36s

This commit is contained in:
Emil Lerch 2026-10-04 16:32:56 -07:00
parent 3c9ec72b88
commit c22ed56806
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 112 additions and 4 deletions

View file

@ -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 }}

View file

@ -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+<run>`), 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 "<FDROID_IMAGE from the workflow>" -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`.

View file

@ -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