Files
agendula/docs/RELEASING.md
T
Jean-Luc Makiolaandmakiolaj ac01993d41 Release 1.0.0 (#20)
First stable release. Merging this bumps versionName to 1.0.0 and triggers the release pipeline (F-Droid, Codeberg, Play).

**App**
- CalDAV sync built in, with Agendula's own task store; OpenTasks / tasks.org stay available and can be copied over in Settings → Storage
- repeating tasks, several reminders per task, lists managed in the app, iCalendar import/export, widget and Quick Settings tile
- a list can be kept out of the smart lists (#18) and gets its own notification channel (#17)
- duplicate a task with its subtasks (#16)
- HTML descriptions shown as plain text (#15)
- relative day words in reminder notifications (#14)
- asks for exact-alarm access instead of claiming USE_EXACT_ALARM, and re-arms reminders when that access changes

**Release plumbing**
- floret-kit bumped to v0.4.0; the old pin was never pushed, so a clean clone couldn't check out the submodule. 0.4.0 drops CrashConfig.issueTitle (crash issues are always filed in English)
- prebuilt .so files ship unstripped, so the build no longer depends on whether an NDK is installed; now checked by check_reproducible_release.sh
- official F-Droid recipe in docs/fdroid-official/, to submit to fdroiddata once v1.0.0 is tagged
- Google Play: fastlane uploads the AAB and every locale's What's New after the F-Droid release; a separate listing lane pushes text and graphics from the fastlane tree, which CI now checks against Play's limits
- store listing: title "Agendula: Tasks" in every locale, icon, feature graphic, screenshots and 1.0.0 changelogs in en-US, en-GB, de-DE and pt-BR

crash_report_issue_title is now unused but stays until Weblate removes the translated copies.

Closes #14, closes #15, closes #16, closes #17, closes #18

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/20
2026-09-24 16:36:49 +02:00

324 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agendula — releasing
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner), a
**Codeberg release** per version carrying the signed APK as a direct download,
and **Google Play**. All three are produced automatically by `.gitea/workflows/release.yaml` when a
**bumped `versionName` reaches `main`** — the pipeline builds and publishes that
version, then creates the matching `vX.Y.Z` tag and the releases itself. The
parallel **Gitea release** is the changelog of record on the build instance and
carries no APK assets.
Codeberg is the canonical forge; Gitea is build infrastructure. See
[Two forges, one repo](#two-forges-one-repo) for how the two are wired.
While Agendula is pre-1.0 (`versionName` starts with `0.`), every release is
flagged as a **pre-release**. This happens automatically and graduates to a
stable release at `1.0.0` — no manual toggling.
---
## The source of truth: the committed version
A release is defined by the `versionName`/`versionCode` committed in
`app/build.gradle.kts` — **not** by a hand-pushed tag:
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0` → `200`,
`1.3.4` → `10304`)
So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads
`versionName`, pins `versionCode` to the derived value, builds, publishes, and —
once the APK is live — 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).
---
## Cutting a release
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
feature/fix branches for this release into it. Everything below happens on
that branch, before it reaches `main`.
2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new
`## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
and the next `## [` becomes the Gitea and Codeberg release notes. The
heading **must** match the version exactly.
3. **Bump the committed `versionName`** (and `versionCode`) in
`app/build.gradle.kts`. **This bump is what triggers the release** when the
branch merges to `main`.
Then write this version's **"What's New"** by hand:
`fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt`, one per
shipped locale, each a short summary **under 500 characters** — F-Droid and
Play both publish these files, and Play rejects a longer one. It is not a
copy of the CHANGELOG.md section. `scripts/sync_changelog_to_fastlane.sh`
keeps a committed `en-US` file as it is and only generates one from
CHANGELOG.md when it is missing; CI fails a PR whose version has no
committed `en-US` changelog.
4. **Verify the release build on a real device** — the mandatory gate:
```sh
scripts/verify-release.sh
```
It builds the `releaseTest` variant (same R8 config as `release`, debug-signed
with a `.releasetest` suffix 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 tasks access — the task list must load;
- create a task with a due reminder and confirm the notification fires;
- exercise this release's headline changes.
Only proceed once all of that passes on-device.
5. **Merge `release/vX.Y.Z` into `main`.** That's it — no manual tagging. The
merge triggers `release.yaml`, which detects the new version, builds, signs,
publishes to F-Droid, creates the `vX.Y.Z` tag + releases, and uploads the
App Bundle to Play.
> The `releaseTest` build type exists only for step 4 — it is never published.
> The pipeline always builds and signs the real `release` variant.
---
## 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`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — the
reproducible-release invariant guard
(`scripts/check_reproducible_release.sh`), then 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 green `CI` check.
- **`translations.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**)
— an SDK-free parity check (`scripts/check_translations.py`) over
`values-*/strings.xml`, so Weblate PRs get fast feedback. Runs on every PR
without a path filter, so the required `Translations / check` status is always
reported.
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC plus
`workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the pinned
Renovate image as its job container and targets Codeberg's API; the cadence
lives here, not in `renovate.json5`, so the two don't double-gate.
- **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus
`workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and
checks **Codeberg** for a tag for it. Only when there isn't one does the
`release` job run: unit tests on the merged commit, pin `versionCode`, build &
sign the release APK with the **app key**, copy it into the F-Droid repo,
generate the per-version changelog from the fastlane tree, re-sign the index
with the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z`
tag + Gitea release (CHANGELOG section as notes, flagged pre-release while
`MAJOR` is 0), attach the R8 `mapping.txt`, and publish the release on
**Codeberg** with the signed APK + a SHA-256 checksum, and finally builds the
App Bundle. Ordinary merges with no version bump fall through `detect` and do
nothing.
- **`play` job** (same workflow, after `release`) — uploads the App Bundle and
every locale's "What's New" to Google Play. Runs last and separately so a Play
rejection can't endanger a release that already shipped; skips cleanly until
Play is configured.
### Codeberg direct-download channel
Alongside F-Droid, each release is published on the Codeberg repo
(`jlmakiola/agendula`) as a plain download for users who don't want F-Droid.
Releases aren't git objects and don't sync with the push mirror in either
direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates
the release over the Codeberg API, and attaches `agendula_v<version>.apk` + its
`.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it
adds no trust surface. It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset,
but it is **not** `continue-on-error`: through 0.2.1–0.3.2 this step reported
green while never once publishing, which is how a crash-fix release reached
F-Droid but not the Codeberg/Obtainium users who needed it. A broken mirror
fails the release loudly.
### Google Play channel
**Artifact.** Play gets an **App Bundle** (`bundleRelease`), never the APK. It is
built from the same source and signing config at the very end of the `release`
job, `continue-on-error`, and handed to the `play` job as a workflow artifact
(via a commit-pinned fork of `upload-artifact`/`download-artifact`; the official
v4 actions refuse any non-github.com forge). The APK and the F-Droid
reproducibility guarantee are untouched.
**Signature.** Play App Signing treats the app key as the **upload key** only
and re-signs with Google's key. A Play install and an F-Droid/Codeberg install
therefore have **different signatures** and cannot update each other; switching
channels means uninstalling. Losing the app key is recoverable for Play (upload
key reset) but not for F-Droid.
**fastlane** is only the Play Developer API client (`supply`), never the build.
The `deploy` lane uploads the bundle plus `changelogs/<versionCode>.txt` for
every locale that has one. It never touches the listing; that is the `listing`
lane's job (see [Store listings](#store-listings-single-source-of-truth)).
**Track.** Uploads go straight to `production` at full rollout — the release
branch review is already the gate. Set the `PLAY_TRACK` variable (e.g.
`internal`) to stage, or `PLAY_RELEASE_STATUS=draft` to hold it unpublished.
**First-time setup.**
1. Create the app in the Play Console with **English (United States) – en-US**
as the default language, so Play and F-Droid fall back to the same locale.
2. Upload the first AAB **by hand** — the API can't create an app's first
release. Enrol in Play App Signing on the way.
3. Create a Google Cloud service account, grant it release permissions for the
app in the Play Console, and store its JSON key as
`PLAY_SERVICE_ACCOUNT_JSON`.
4. Push the listing once with the `listing` lane (see
[Store listings](#store-listings-single-source-of-truth)), so every locale
that has a changelog also exists on Play before `deploy` sends its
"What's New".
5. Rehearse with `PLAY_DRY_RUN=true`, then clear it.
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope
— it pushes the tag as well as creating the release) added to Gitea Actions.
### 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 pin, 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.
---
## Two forges, one repo
**Codeberg (`jlmakiola/agendula`) is canonical** — git, issues, PRs, tags and
releases. The self-hosted Gitea instance is build infrastructure: it holds the
signing key, publishes the F-Droid repo, and runs the release pipeline. Codeberg
push-mirrors `main` and tags to Gitea, and a bumped `versionName` arriving there
triggers `release.yaml` exactly as before.
Workflows are separated by **directory**, not by conditionals. Forgejo looks in
`.forgejo/workflows` → `.gitea/workflows` → `.github/workflows` and stops at the
first that exists; Gitea doesn't know `.forgejo/` at all:
| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | signing key, F-Droid, Hetzner, bot tokens |
The line is drawn at **secrets, not at CI-vs-release**. That's what makes fork
PRs safe: everything a contributor can trigger lives in `.forgejo/` and can
reference no secret. Renovate stays on the Gitea runner *even though it opens
PRs on Codeberg* — it talks to Codeberg's API rather than moving its token onto
the contributor-facing runner. `detect` (and the Renovate job) additionally
carries a
`github.repository_owner == 'makiolaj'` guard, because the directory split only
holds while `.forgejo/` is non-empty — empty it and Codeberg would fall back to
`.gitea/` and run the release pipeline on the contributor-facing runner.
Two consequences worth remembering:
- **`detect` reads tags from Codeberg**, not from the Gitea instance it runs on.
Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by
the next sync until the Codeberg tag push propagates back. Asking Gitea inside
that window would re-cut a shipped release.
- **Any ref that exists only on Gitea gets deleted** by the mirror. That's
correct under Codeberg-canonical, but don't debug a "vanished" branch without
remembering it.
`floret-kit` is a submodule of this repo and follows the same move: `.gitmodules`
points at `https://codeberg.org/jlmakiola/floret-kit.git`, so a clone resolves
without reaching the personal Gitea instance. The Gitea copy is **kept** — every
existing tag records the old submodule URL, so rebuilds of past releases still
resolve.
---
## Secrets (Gitea → repo Settings → Actions → Secrets)
The workflow fails loudly if the F-Droid ones are missing — it will **never**
auto-generate a repo key (that would rotate the repo fingerprint and break every
user's pinned repo).
| 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. |
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — pushes the tag to Codeberg, creates the release there and uploads the APK/checksum. If unset the step skips; if set and failing, the release fails. |
| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console release access. Uploads the AAB. If unset, the `play` job skips cleanly. |
| `RENOVATE_TOKEN` | Codeberg bot-account token — repo read/write + PR scope on `jlmakiola/agendula`. Used only by `renovate.yml`. |
| `GITHUB_COM_TOKEN` | Read-only github.com PAT (no scopes). Without it Renovate's changelog lookups hit the 60/h anonymous rate limit and PRs arrive with empty release notes. |
The app key signs APKs; the repo key signs the index (its fingerprint is what
users pin). Neither key nor `config.yml` is ever uploaded to the server — they
live only in CI secrets and are reconstructed in-runner (nginx serves only
`repo/`).
### Variables (Gitea → repo Settings → Actions → Variables)
| Variable | Default | Purpose |
| --- | --- | --- |
| `PLAY_TRACK` | `production` | Play track for the bundle; `internal`/`alpha`/`beta` to stage. |
| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. |
| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit and discards it. |
---
## Store listings (single source of truth)
Everything both stores show — title, short and full description, "What's New",
icon, feature graphic, screenshots — lives in
**`fastlane/metadata/android/<locale>/`**. Nothing is edited in the Play Console
or kept in a second copy for F-Droid.
```
fastlane/metadata/android/
en-US/ fallback of both stores; holds every graphic
title.txt ≤ 30 chars
short_description.txt ≤ 80 chars
full_description.txt ≤ 4000 chars
changelogs/<code>.txt ≤ 500 chars
images/
icon.png 512×512, 32-bit PNG
featureGraphic.png 1024×500, no alpha
phoneScreenshots/ 2–8, no alpha, 320–3840 px, long side ≤ 2× short
sevenInchScreenshots/ optional, same rules
tenInchScreenshots/ optional, same rules
en-GB/, de-DE/, pt-BR/, … text only; one per shipped values-* language
```
`en-US` is the fallback for both stores: F-Droid always uses it, and it is the
Play app's default language. So text and graphics committed there cover every
locale without its own. Graphics are committed **once, under `en-US`**; add them
to another locale only when they actually differ (e.g. localized screenshots).
A raw phone capture (1080×2400, 20:9) breaks Play's 2:1 aspect cap. Frame or
crop screenshots to at most 2:1, e.g. 1080×2160.
**Validation.** `scripts/check_store_listing.py` checks the whole tree against
Play's limits (the stricter of the two stores) and runs on every PR. Add
`--complete` to also require what a live Play listing needs: icon, feature
graphic, ≥ 2 phone screenshots and this version's `en-US` changelog.
**F-Droid.** The official repo harvests the tree from the tagged source. The
self-hosted repo gets it through `scripts/fastlane_to_fdroid_localized.sh` at
release time. The app-level control file (`Categories`/`License`/links) stays in
`fdroid-metadata/de.jeanlucmakiola.agendula.yml`.
**Play.** `fastlane/Fastfile`'s `listing` lane pushes text and graphics
(`supply`, only changed images are re-uploaded). It is run by hand, never by the
release pipeline, because overwriting a live listing triggers a Play review:
```sh
bundle install
SUPPLY_JSON_KEY=/path/to/play-service-account.json \
bundle exec fastlane listing dry_run:true # validate, discard
SUPPLY_JSON_KEY=/path/to/play-service-account.json \
bundle exec fastlane listing
```
The lane refuses to run unless `check_store_listing.py --complete` passes.
If the Play app's default language is ever not `en-US`, set
`PLAY_DEFAULT_LOCALE` and the lane copies the graphics into that locale.
## 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`.