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

17 KiB
Raw Permalink Blame History

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

    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).

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), 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:

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.