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. Ports the setup Calendula already runs on, adapted where Agendula genuinely differs. **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 · 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. CI also gains three fixes: an explicit `SKIP_RE` skip-list that names the build-relevant files in the log, base-ref normalisation, and a fully-qualified `android-actions/setup-android` — Codeberg resolves bare `uses:` refs against `data.forgejo.org`, which does not carry that action. ### 2 · Three release-pipeline safety changes - `detect` and the Renovate job get 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 these 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. - **The Codeberg publish step pushes the tag itself** instead of waiting for it to arrive by mirror. That wait was correct while Gitea mirrored *to* Codeberg; under Codeberg-canonical the mirror runs the other way and it would never resolve. Attaching the release to an already-pushed ref (no `target_commitish`) is what avoids the empty-bodied 500s, and the create call retries with backoff because Codeberg 500s on a tag it has only just received. The step stays **fail-loud**, not `continue-on-error` — it reported green through 0.2.1–0.3.2 while never once publishing, and that must not be possible again. ### 3 · Renovate `renovate.json5` plus a Gitea-side job targeting Codeberg's API. `managerFilePatterns` covers **both** workflow directories, so the pinned Renovate image tag and the action versions in either file keep getting bumped. Needs two new Gitea secrets: `RENOVATE_TOKEN` (Codeberg bot, repo read/write + PR scope) and `GITHUB_COM_TOKEN` (read-only github.com PAT, for changelog lookups). ### 4 · Weblate A parity check (`scripts/check_translations.py`) runs on every PR without a path filter, so the required `Translations` status is always reported. Partial translations are expected, so `MissingTranslation` and `MissingQuantity` become informational — `ExtraTranslation` stays fatal. Agendula had no `lint` block at all, so the first locale to land would otherwise have failed the build. **Settings → App language** now opens a picker carrying a "Help translate" header. That is why it drops floret-kit's `LanguagePickerRow` for a local row: the shared recipe has no `header` slot, and the framing is app-specific rather than a family primitive. ### 5 · Links repointed In-app Source / License / report-issue URLs, F-Droid metadata, README (now with a Codeberg CI badge), and the docs. `floret-kit` follows suit — `.gitmodules` points at `codeberg.org/jlmakiola/floret-kit`, so a clone no longer needs to reach the personal Gitea instance to resolve it. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve. ### 6 · Housekeeping Drops `release-notes.md` — a release-pipeline scratch file that got committed — and gitignores the five others the release job writes into the workspace. ### Not in this PR The Codeberg → Gitea push mirror, the Weblate component, and the Codeberg bot account (all browser-side). Until the mirror is flipped, merging this does **not** reach the Gitea runner. Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/2
12 KiB
Agendula — releasing
Agendula is distributed through a self-hosted F-Droid repo (on Hetzner) and a
Codeberg release per version carrying the signed APK as a direct download.
Both 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
-
Assemble the release branch. Create
release/vX.Y.Zand merge the feature/fix branches for this release into it. Everything below happens on that branch, before it reachesmain. -
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 both the Gitea release notes and the F-Droid per-version "What's New". The heading must match the version exactly. -
Bump the committed
versionName(andversionCode) inapp/build.gradle.kts. 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 listing (which harvests the changelog from the tagged source tree) show this version. 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:
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 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.
-
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.
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(.forgejo/workflows/, onpull_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 greenCIcheck.translations.yaml(.forgejo/workflows/, onpull_request, Codeberg) — an SDK-free parity check (scripts/check_translations.py) overvalues-*/strings.xml, so Weblate PRs get fast feedback. Runs on every PR without a path filter, so the requiredTranslations / checkstatus is always reported.renovate.yml(.gitea/workflows/, Mondays 05:00 UTC plusworkflow_dispatch, Gitea) — the dependency sweep. Runs the pinned Renovate image as its job container and targets Codeberg's API; the cadence lives here, not inrenovate.json5, so the two don't double-gate.release.yaml(.gitea/workflows/, on push tomainplusworkflow_dispatch, Gitea) — a cheapdetectjob readsversionNameand checks Codeberg for a tag for it. Only when there isn't one does thereleasejob run: unit tests on the merged commit, pinversionCode, 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, uploadrepo/+metadata/, then create thevX.Y.Ztag + Gitea release (CHANGELOG section as notes, flagged pre-release whileMAJORis 0), attach the R8mapping.txt, and publish the release on Codeberg with the signed APK + a SHA-256 checksum. Ordinary merges with no version bump fall throughdetectand do nothing.
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.
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:
detectreads tags from Codeberg, not from the Gitea instance it runs on. Push mirroring isgit 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. |
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/).
F-Droid metadata (single source of truth)
Store-listing text lives in fastlane/metadata/android/<locale>/ — the same
tree the official F-Droid repo harvests from source. At release time
scripts/fastlane_to_fdroid_localized.sh transforms it into the F-Droid repo's
"localized" layout, so there is no second copy to maintain. The app-level control
file (Categories/License/links) stays in
fdroid-metadata/de.jeanlucmakiola.agendula.yml. Per-version changelogs are
seeded into fastlane/.../en-US/changelogs/<versionCode>.txt by
scripts/sync_changelog_to_fastlane.sh (step 3 above) and carried across by the
transform.
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.