Files
calendula/docs/RELEASING.md
Jean-Luc Makiola db7094c54e
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 14s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Move the canonical forge to Codeberg (#86)
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline.

**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.

### 1 · floret-kit moved

Mirrored to `jlmakiola/floret-kit` (6 branches, 3 tags, every SHA verified identical) and `.gitmodules` repointed. This is what actually unblocks contributors — a clone previously could not resolve its submodule without reaching the personal Gitea instance.

The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases (including F-Droid reproducible rebuilds) still resolve.

### 2 · Workflows split by directory

Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:

| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |

The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.

### 3 · Two release-pipeline safety changes

- `release.yaml`'s `detect` gets an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running the release pipeline on the contributor-facing runner, without secrets.
- `detect` now 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 reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.

### 4 · Links repointed

In-app Source/License links, README badge, both F-Droid metadata files. **`Repo:` in `docs/fdroid-official/` deliberately stays on Gitea** — it keeps receiving `main` and every tag, so it remains a complete build source, and leaving it alone means no fdroiddata MR and no reproducible-build risk.

### Not in this PR

Renovate + Weblate repointing, and the Codeberg → Gitea push mirror (browser-side).

Supersedes Gitea PR #104.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/86
2026-07-29 20:33:56 +02:00

10 KiB
Raw Blame History

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

So MINOR and PATCH each have room for 099. 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

  1. Assemble the release branch. Create release/vX.Y.Z and 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 reaches main.

  2. Move the ## [Unreleased] section of CHANGELOG.md under 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.

  3. Bump the committed versionName (and versionCode) in app/build.gradle.kts to the new version. This bump is what triggers the release when the branch merges to main. Then run

    scripts/sync_changelog_to_fastlane.sh
    

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

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

  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, and creates the vX.Y.Z tag + Gitea release. Hold UI releases for on-device review and explicit go-ahead before merging.

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) — 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.
  • release.yaml (on push to main, plus workflow_dispatch) — a cheap detect job reads versionName and checks whether a tag for it already exists. Only when it doesn't does the release job 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, upload repo/ + metadata/, then create the vX.Y.Z tag + Gitea release (CHANGELOG section as notes), attach the R8 mapping.txt, and mirror the release to Codeberg with the signed APK + a SHA-256 checksum (both best-effort). Ordinary merges with no version bump fall through detect and do nothing.

Codeberg direct-download channel

Alongside F-Droid, each release is mirrored to the Codeberg repo (jlmakiola/calendula) as a plain download for users who don't want F-Droid. Codeberg push-mirrors branches and tags to Gitea, but releases aren't git objects and don't sync in either direction, so the pipeline creates the release over the Codeberg API and attaches calendula_v<version>.apk + its .sha256. It's the same APK the F-Droid repo serves (same app key), so it adds no trust surface. The step is best-effort: a Codeberg outage never fails an already-published F-Droid release, and it skips cleanly if CODEBERG_RELEASE_TOKEN is unset. One-time setup: the repo's Releases unit must be enabled and a CODEBERG_RELEASE_TOKEN secret (Codeberg access token, write:repository scope) 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 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.

Two forges, one repo

Codeberg (jlmakiola/calendula) 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, Play, 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.

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.

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.
CODEBERG_RELEASE_TOKEN Codeberg access token (write:repository scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips.

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 the FDROID_* 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, verify keystore.p12 and config.yml return 404 while repo/index-v2.json returns 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.