Make fastlane/metadata/android/ the single source of truth for store
listing metadata, consumed directly by the official F-Droid repo and
transformed into the self-hosted repo's localized layout at release time.
- Move fdroid-metadata/<appid>/<locale>/ -> fastlane/metadata/android/<locale>/
(git-tracked renames: summary->short_description, description->full_description,
+ title.txt, images/icon.png, images/phoneScreenshots/); keep the app-level
.yml control file for the self-hosted `fdroid update`.
- Add scripts/fastlane_to_fdroid_localized.sh (fastlane -> F-Droid localized,
incl. changelogs) and scripts/sync_changelog_to_fastlane.sh (CHANGELOG.md ->
fastlane changelog); verified byte-identical to the previous metadata.
- release.yaml: build self-hosted metadata from the fastlane tree and sync the
per-version changelog before the transform (one changelog source for both
channels).
- Disable AGP VCS-info embedding on release builds (vcsInfo { include = false })
so builds reproduce byte-for-byte vs the distributed APK — the only file that
otherwise differed (META-INF/version-control-info.textproto). Effective from
the next release.
- Add docs/fdroid-official/ (draft fdroiddata recipe: reproducible build +
AllowedAPKSigningKeys + Binaries + notes).
- Repoint README screenshots/icon, update docs/README + RELEASING, and skip the
Android build on fastlane-only changes (ci.yaml).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.2 KiB
Releasing Calendula
Calendula is distributed through a self-hosted F-Droid repository. A release is
built, signed, and published automatically by .gitea/workflows/release.yaml
when a bumped versionName reaches main — the pipeline then creates the
matching vX.Y.Z tag and Gitea release itself.
Versioning — the committed version is the source of truth
A release is defined by the versionName/versionCode committed in
app/build.gradle.kts:
versionName=MAJOR.MINOR.PATCH(e.g.2.1.0)versionCode=MAJOR*10000 + MINOR*100 + PATCH(2.1.0→20100)
So MINOR and PATCH each have room for 0–99. The release pipeline reads
versionName, pins versionCode to the derived value, builds, and — once the
APK is published — creates the tag v<versionName> at that commit. The tag is
an output of a successful release, not its trigger, so a tag always marks a
fully-shipped version (and a failure before publish leaves no tag, so re-running
the workflow safely retries).
Published version codes so far: v0.1.0→100 … v1.0.0→10000 … v2.0.0→20000.
Cutting a release
-
Assemble the release branch. Create
release/vX.Y.Zand merge the feature/fix branches that make up this release into it. This branch is the release candidate — everything below happens on it, before it reachesmain. -
Move the
## [Unreleased]section ofCHANGELOG.mdunder a new## [X.Y.Z] — <date>heading (Keep a Changelog format). The text between that heading and the next## [becomes both the Gitea release notes and the F-Droid per-version changelog. -
Bump the committed
versionName(andversionCode) inapp/build.gradle.ktsto the new version. This bump is what triggers the release when the branch merges tomain. Then runscripts/sync_changelog_to_fastlane.shand commit the generated
fastlane/metadata/android/en-US/changelogs/<versionCode>.txt. This is what makes the official F-Droid repo show this version's changelog (it reads the changelog from the tagged source tree). The self-hosted pipeline regenerates it regardless, so forgetting only affects the official listing. -
Verify the release build on a real device — the mandatory gate. The shipped APK is R8-shrunk/obfuscated, and bugs that only appear there, or only on first run, never show up in the debug build or on a device that already granted the calendar permission (this is how the v2.7.0 launch crash slipped through). Run:
scripts/verify-release.shIt builds the
releaseTestvariant (same R8 config asrelease, debug-signed with a.releasetestsuffix so it installs alongside the real app) and resets it to a first-run state. Then, on the device:- launch from a clean / permission-not-granted state — the permission screen must appear, no crash;
- grant access — the calendar must load;
- add both home-screen widgets and confirm they render;
- exercise this release's headline changes.
Only proceed once all of that passes on-device.
-
Merge
release/vX.Y.Zintomain. That's it — no manual tagging. The merge triggersrelease.yaml, which detects the new version, builds, signs, publishes to F-Droid, and creates thevX.Y.Ztag + Gitea release. Hold UI releases for on-device review and explicit go-ahead before merging.
The
releaseTestbuild type exists only for step 4 — it is never published. The pipeline always builds and signs the realreleasevariant.
What the pipeline does
CI and release are split so a change is built once on its PR and only does release work when a merge actually cuts a release:
ci.yaml(onpull_request) — lint + unit tests + a debug assemble (and a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but still report a greenCIcheck.release.yaml(on push tomain, plusworkflow_dispatch) — a cheapdetectjob readsversionNameand checks whether a tag for it already exists. Only when it doesn't does thereleasejob run: unit tests on the merged commit, build & sign the release APK with the app key, copy it into the F-Droid repo, generate the per-version changelog, re-sign the index with the repo key, uploadrepo/+metadata/, then create thevX.Y.Ztag + Gitea release (CHANGELOG section as notes) and attach the R8mapping.txt(best-effort). Ordinary merges with no version bump fall throughdetectand do nothing.
Manual re-sign / recovery
A manual workflow_dispatch of the release workflow runs a re-sign-only
path: detect reports it's not a release, so the release job skips the APK
build, the version bump, and tag/release creation, and just re-signs the
existing F-Droid index with the configured repo key and re-uploads. Use this
for key rotation or repo recovery without publishing a new app version.
Secrets (Gitea → repo Settings → Actions → Secrets)
| Secret | Purpose |
|---|---|
KEYSTORE_BASE64, KEY_PASSWORD, KEY_ALIAS |
App signing key — signs the APK. Losing it means existing installs can't be updated. |
FDROID_KEYSTORE_BASE64 |
F-Droid repo signing key (keystore.p12, base64). Signs the repo index. |
FDROID_CONFIG_BASE64 |
F-Droid config.yml (base64) — repo metadata + keystore passwords. |
HETZNER_HOST, HETZNER_USER, HETZNER_PASS |
Upload target for the F-Droid repo. |
GITHUB_TOKEN |
Provided by Gitea Actions; used to create the release + attach assets. |
The two keys are independent: the app key signs APKs; the repo key
signs the index (its fingerprint is what users pin). Neither key nor the
F-Droid config.yml is ever uploaded to the server — they live only in CI
secrets and are reconstructed in-runner. If FDROID_KEYSTORE_BASE64 /
FDROID_CONFIG_BASE64 are unset the workflow fails loudly rather than
minting a new repo key (which would break every user's pinned fingerprint).
Key custody & recovery
- Offline backups of both keys (and passwords) live in a password manager. These are the only safe copies — losing them is unrecoverable.
- App key lost → no existing install can be updated again; you'd have to ship a new app under a new applicationId.
- Repo key lost or compromised → rotate it, publish the new fingerprint in
the README, and have users remove + re-add the repo. To rotate: generate a
new
keystore.p12+config.yml, set them as theFDROID_*secrets, update the README fingerprint, and run the manual re-sign dispatch above.
F-Droid repo
- URL:
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo - Fingerprint (current):
C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425 - Served from the Hetzner storage box. nginx serves only
…/fdroid/repo/— the working dir (key, config, metadata) sits above it and must never be web-reachable. After any webserver change, verifykeystore.p12andconfig.ymlreturn 404 whilerepo/index-v2.jsonreturns 200.
Crash deobfuscation
Each release attaches mapping-<version>.txt.gz (the R8 mapping) to its Gitea
release. To deobfuscate a user stacktrace, download the mapping for that
version and run it through retrace.