feat(release): publish to the Google Play internal track (#36)

Liseur ships as an APK on GitHub releases and through F-Droid. Play is a
third channel, and the point of this change is that it is the least
load-bearing of the three: the tag that publishes a release now also
builds an app bundle and pushes it to internal testing, and every part of
that is arranged so it cannot take the other two down with it.

Nothing about the APK path moves. F-Droid's recipe builds
assembleRelease and never sees fastlane/Fastfile, no Gradle publishing
plugin is applied, and bundleRelease is an additional output rather than
a replacement. The workflow step skips itself when the service account
secret is absent, so forks still cut releases, and is continue-on-error
so a rejected upload is not what fails one.

Only the changelog is pushed from here. The store listing stays in the
console, because Play holds declarations that no file in this repository
describes -- data safety, content rating, target audience -- and an
accidental metadata push is a listing change under review.

Play also requires a privacy policy at a public URL, so docs/PRIVACY.md
says what the app actually does: no collection, no trackers, network only
to the book server you enter and to a dictionary lookup that is off until
you turn it on. GitHub Pages serves it from main /docs, which makes it a
document that changes in a commit rather than in a console text box.

The upload credential is the existing account-level service account,
already in pass; hack/release --sync-secrets carries it into the release
environment alongside the signing key.
This commit is contained in:
Chmouel Boudjnah 2026-08-19 16:37:07 +02:00 • committed by GitHub
parent 744fca3594
commit 36050777f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 273 additions and 6 deletions

View file

@ -13,7 +13,7 @@ permissions:
jobs:
release:
name: Build signed APK
name: Build and publish the release
runs-on: ubuntu-latest
environment: release
timeout-minutes: 30
@ -106,3 +106,45 @@ jobs:
--title "Liseur $RELEASE_TAG" \
--notes-file "$RELEASE_NOTES" \
"$RELEASE_APK"
# Everything below is Google Play, and only Google Play. It runs
# after the release above on purpose: the GitHub artefact and the
# F-Droid submission are the primary channels, so a rejected upload
# here must never be what makes a release fail.
- name: Check for the Play credential
id: play
env:
SERVICE_ACCOUNT: ${{ secrets.LISEUR_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
set -euo pipefail
if [[ -n "$SERVICE_ACCOUNT" ]]; then
printf 'enabled=true\n' >> "$GITHUB_OUTPUT"
else
printf 'enabled=false\n' >> "$GITHUB_OUTPUT"
printf 'No Play service account configured; skipping.\n'
fi
- name: Build the release bundle
if: steps.play.outputs.enabled == 'true'
run: ./gradlew bundleRelease --console=plain
- name: Set up Ruby
if: steps.play.outputs.enabled == 'true'
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: "3.3"
- name: Upload to the Play internal track
if: steps.play.outputs.enabled == 'true'
continue-on-error: true
env:
SERVICE_ACCOUNT: ${{ secrets.LISEUR_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
set -euo pipefail
key="$RUNNER_TEMP/play-service-account.json"
# The trap covers the failure paths too: the credential must not
# outlive this step whatever fastlane makes of it.
trap 'rm -f "$key"' EXIT
(umask 077 && printf '%s' "$SERVICE_ACCOUNT" > "$key")
gem install fastlane -v 2.238.0 --no-document
SUPPLY_JSON_KEY="$key" fastlane android internal

6
.gitignore vendored
View file

@ -16,3 +16,9 @@ keystore.properties
*.jks
*.keystore
*.p12
# fastlane writes these next to the configuration it is given.
fastlane/report.xml
fastlane/README.md
fastlane/Preview.html
fastlane/play-service-account.json

View file

@ -15,9 +15,11 @@ Manager package list).
```bash
./gradlew assembleDebug # unsigned debug APK, installable as-is
./gradlew assembleRelease # minified release APK
./gradlew bundleRelease # minified release AAB, for Google Play only
```
Output APKs land in `app/build/outputs/apk/{debug,release}/`.
Output APKs land in `app/build/outputs/apk/{debug,release}/`, and the
bundle in `app/build/outputs/bundle/release/`.
The debug build is signed with the standard Android debug key, so
`app-debug.apk` can be installed directly with `adb install` or by
@ -181,7 +183,8 @@ Pushing the tag is what starts `.github/workflows/release.yml`, which
builds and signs the APK in the `release` GitHub environment and
attaches it to the release. That environment must hold
`LISEUR_KEYSTORE_BASE64`, `LISEUR_KEYSTORE_PASSWORD`,
`LISEUR_KEY_ALIAS` and `LISEUR_KEY_PASSWORD`; `hack/release` creates the
`LISEUR_KEY_ALIAS`, `LISEUR_KEY_PASSWORD` and
`LISEUR_PLAY_SERVICE_ACCOUNT_JSON`; `hack/release` creates the
environment and uploads whichever of them are missing straight from
`pass`, so an unlocked password store is the only setup a fresh clone
needs. To refresh them all, after rotating the key for instance:
@ -193,6 +196,68 @@ hack/release --sync-secrets
Signing a build on your own machine is a separate matter, covered under
[Signing a release build](#signing-a-release-build-optional) above.
### Google Play
Play is the third channel, after the GitHub release and F-Droid, and it
is deliberately the least load-bearing of the three. The same tag that
publishes the release also builds an app bundle and pushes it to the
**internal testing** track:
```bash
make bundle # ./gradlew bundleRelease, for Play only
```
Nothing about the APK path changes. F-Droid's recipe builds
`assembleRelease` and never sees `fastlane/Fastfile`, no Gradle
publishing plugin is applied, and the bundle is an extra output rather
than a replacement — which is also why the Play step in
`.github/workflows/release.yml` is `continue-on-error` and skips itself
entirely when the service account secret is absent. A fork, or a rejected
upload, must not be what makes a release fail.
The upload runs `fastlane android internal` with
`SUPPLY_JSON_KEY` pointing at a service account credential written to
`$RUNNER_TEMP` and removed by a trap in the same step. The credential is
`android/google.play.service.serviceaccount` in `pass`, shared with the
other apps on the account; it needs *Release to testing tracks* on Liseur
under Users and permissions.
Only the changelog is pushed from the repository. The store listing is
edited in the console, because Play holds declarations that no file here
describes — data safety, content rating, target audience, app access, ads
— and those have to be revisited whenever the app gains a permission,
talks to something new, or changes what it stores. The privacy policy
Play links to is `docs/PRIVACY.md`, served by GitHub Pages from `main`
`/docs`; it is a published legal document, so change it in a commit and
not in the console.
Promotion out of internal testing is a manual action in the console, on
purpose. Nothing in this repository can put a build in front of the
public.
**The signing key.** Play App Signing holds the same key as the GitHub
release, enrolled from `pass` through Google's PEPK tool rather than
generated by Google. That means a build installed from Play and a build
downloaded from the release page carry the same signature, so a reader
can move between them without uninstalling and losing their library. It
also means the key cannot be swapped without a rotation request. F-Droid
signs with its own key unless reproducible builds are configured, so that
third artefact is unaffected either way.
Recreating the PEPK export, should it ever be needed:
```bash
d=$(mktemp -d) && umask 077
pass show android/liseur.keystore.p12 | base64 -d > "$d/release.p12"
java -jar pepk.jar --keystore="$d/release.p12" \
--alias="$(pass show android/liseur.keystore-alias)" \
--output="$d/output.zip" --include-cert \
--rsa-aes-encryption --encryption-key-path=encryption_public_key.pem
```
`pepk.jar` and `encryption_public_key.pem` are downloaded from the
console, and the temporary directory goes away afterwards.
### Release notes
Two things are written for every release, and they are not the same

View file

@ -12,12 +12,13 @@ PACKAGE := com.chmouel.liseur
ACTIVITY := $(PACKAGE)/.MainActivity
DEBUG_APK := app/build/outputs/apk/debug/app-debug.apk
.PHONY: help build debug release test lint check clean emulator stop shutdown install run run-bg reset screenshots icon feature-graphic
.PHONY: help build debug release bundle test lint check clean emulator stop shutdown install run run-bg reset screenshots icon feature-graphic
help:
@printf '%s\n' \
'make build Build the debug APK' \
'make release Build the release APK' \
'make bundle Build the release AAB for Google Play' \
'make test Run JVM unit tests' \
'make lint Run Android Lint' \
'make check Run tests, lint, and debug build' \
@ -41,6 +42,11 @@ build debug:
release:
$(GRADLE) assembleRelease
# Google Play only. F-Droid and the GitHub release both build the APK
# above, so this target is additive and nothing else depends on it.
bundle:
$(GRADLE) bundleRelease
test:
$(GRADLE) testDebugUnitTest

View file

@ -78,7 +78,8 @@ When you switch between devices, you have the choice to have your place travel w
Everything runs privately and without distraction. There are no trackers, no
analytics, no ads, and no attempts to sell you a monthly subscription for books
you already own. Liseur only talks to the book servers and dictionary sources
you choose to add.
you choose to add. The [privacy policy](https://chmouel.github.io/liseur/PRIVACY)
spells out exactly what that means.
## Install

100
docs/PRIVACY.md Normal file
View file

@ -0,0 +1,100 @@
---
title: Liseur Privacy Policy
---
# Privacy Policy
**App:** Liseur (`com.chmouel.liseur`)
**Developer:** Chmouel Boudjnah
**Last updated:** 19 August 2026
Liseur is an open-source ebook reader. It has no account, no advertising,
no analytics and no trackers, and it collects nothing about you. Its
source code is public at
<https://github.com/chmouel/liseur>, so every claim on this page can be
checked rather than taken on trust.
## What Liseur collects
Nothing. There is no server run by the developer, no crash reporting, no
usage measurement, no advertising identifier, and no account to sign up
for. No data leaves your device except to the servers described below,
which are ones you choose and enter yourself.
## What Liseur stores on your device
All of this stays in the app's own private storage:
- Your library: the books you added, their metadata and their covers.
- Where you are in each book, along with your reading history and the
time you have spent reading.
- Your highlights, notes and bookmarks.
- Your reading preferences: theme, font, size, spacing, margins.
- If you connect a book server, its address and the credentials or token
it issued. Those are encrypted with a key held in the Android Keystore,
which cannot be exported from the device.
Uninstalling the app removes all of it.
Liseur reads EPUB files from a folder you pick through Android's own
document picker. It only ever sees the folder you granted, and it does
not copy your books anywhere.
## Network access
Liseur talks to exactly two kinds of address, both of which are yours to
choose:
**Your book server.** If, and only if, you connect one, Liseur talks to
the calibre-web, Komga or liseur-sync server whose address you typed in.
It sends the credentials that server asked for, downloads the books and
covers you request, and exchanges your reading position so the same book
resumes in the right place on another device. That server is operated by
you or by whoever you chose to trust; this policy does not cover what it
does with the data it receives.
**A dictionary site, if you enable it.** Looking a word up online is off
by default. When you switch it on, Liseur sends the single selected word
to the dictionary site configured in Settings — by default the public
Wiktionary API — over HTTPS. No identifier, no book title and no account
accompanies it. You may point this at any Wiktionary edition or mirror,
or leave the feature off and hand words to an offline dictionary app
installed on your device instead.
Liseur never contacts any other host. It requests the `INTERNET` and
`ACCESS_NETWORK_STATE` permissions for the two purposes above and for
nothing else.
## Android backup
Liseur takes part in Android's standard backup, so your library, reading
positions, highlights, notes and settings can follow you to a new device.
That backup is handled by Android and stored in your own Google account,
under Google's terms, not the developer's — Liseur has no access to it.
Downloaded book files and generated covers are deliberately excluded.
Server credentials are included but arrive unreadable on a new device,
because the key that encrypts them never leaves the old one; Liseur
notices this and asks you to sign in again.
You can turn this off in your device's backup settings.
## Children
Liseur is not directed at children and collects no personal information
from anyone, of any age.
## Sharing
Nothing is shared or sold, because nothing is collected. The developer
receives no data from the app whatsoever.
## Changes
Any change to this policy will be published on this page with a new date
above, and its history is visible in the repository.
## Contact
Questions, or a problem with this policy: open an issue at
<https://github.com/chmouel/liseur/issues> or write to
<chmouel@chmouel.com>.

4
docs/_config.yml Normal file
View file

@ -0,0 +1,4 @@
theme: jekyll-theme-minimal
title: Liseur
description: An open-source ebook reader for Android
show_downloads: false

2
fastlane/Appfile Normal file
View file

@ -0,0 +1,2 @@
json_key_file(ENV["SUPPLY_JSON_KEY"].to_s)
package_name("com.chmouel.liseur")

36
fastlane/Fastfile Normal file
View file

@ -0,0 +1,36 @@
# Google Play uploads only. Nothing here is part of building Liseur:
# F-Droid and the GitHub release both build the APK with plain Gradle and
# never see this directory.
#
# The metadata under fastlane/metadata/android is written for F-Droid and
# happens to be supply's native layout, so Play reads the same files. Only
# the changelog is pushed from here: the store listing itself is edited in
# the console, because Play holds the declarations (data safety, content
# rating, target audience) that no file in this repository describes, and
# an accidental metadata push is a listing change under review.
default_platform(:android)
platform :android do
desc "Upload the release bundle to the Play internal testing track"
lane :internal do
bundle = "app/build/outputs/bundle/release/app-release.aab"
UI.user_error!("no bundle at #{bundle} — run ./gradlew bundleRelease") unless File.exist?(bundle)
upload_to_play_store(
track: "internal",
aab: bundle,
# A track upload is a release; the APK path belongs to GitHub.
skip_upload_apk: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
skip_upload_changelogs: false,
# The first upload of all is done by hand, because enrolling the
# signing key happens inside the console's release wizard. Every
# upload after that goes straight to the testers.
release_status: "completed",
validate_only: ENV["PLAY_VALIDATE_ONLY"] == "true"
)
end
end

View file

@ -18,6 +18,7 @@ readonly PASS_KEYSTORE="android/liseur.keystore.p12"
readonly PASS_KEYSTORE_PASSWORD="android/liseur.keystore-password"
readonly PASS_KEY_ALIAS="android/liseur.keystore-alias"
readonly PASS_GEMINI_KEY="google/gemini-api"
readonly PASS_PLAY_SERVICE_ACCOUNT="android/google.play.service.serviceaccount"
readonly NOTES_MODEL="gemini-3.6-flash"
readonly REQUIRED_SECRETS=(
LISEUR_KEYSTORE_BASE64
@ -25,6 +26,7 @@ readonly REQUIRED_SECRETS=(
LISEUR_KEY_ALIAS
LISEUR_KEY_PASSWORD
GEMINI_API_KEY
LISEUR_PLAY_SERVICE_ACCOUNT_JSON
)
usage() {
@ -51,7 +53,8 @@ release wants --no-fdroid. Once the inclusion request is merged, run
hack/release --fdroid-only VERSION to catch that release up.
The signing key lives in pass, under android/liseur.keystore.p12,
android/liseur.keystore-password and android/liseur.keystore-alias. Any
android/liseur.keystore-password and android/liseur.keystore-alias, and the
Play upload credential under android/google.play.service.serviceaccount. Any
release secret missing from the GitHub release environment is uploaded
from there, so a fresh clone needs nothing but an unlocked password store.
EOF
@ -132,6 +135,8 @@ sync_release_secrets() {
set_release_secret LISEUR_KEY_PASSWORD \
"$(pass_show "$PASS_KEYSTORE_PASSWORD")"
set_release_secret GEMINI_API_KEY "$(pass_show "$PASS_GEMINI_KEY")"
set_release_secret LISEUR_PLAY_SERVICE_ACCOUNT_JSON \
"$(pass_show "$PASS_PLAY_SERVICE_ACCOUNT")"
}
github_release_is_published() {