ci: the full release pipeline, Codeberg-canonical from commit one

Both siblings' pipelines are near-identical; the real difference between them
is which forge is canonical. This is Agendula's spine — it pushes the tag to
Codeberg itself and flags pre-1.0 releases as pre-releases — with Calendula's
`play` job grafted on unchanged.

Taking Agendula's spine means there is no Gitea-canonical phase to migrate out
of later, which is the one thing Agendula had to unwind.

The Codeberg publish step stays NOT continue-on-error, inherited that way
deliberately: in Agendula it reported green through five consecutive releases
while never once publishing, which is how a crash-fix release reached F-Droid
but not the users who needed it. Comments that recount that history now name
Agendula, so an inherited scar isn't misread as ours.

The `play` job runs last and isolated, and skips cleanly until
PLAY_SERVICE_ACCOUNT_JSON exists — so it stays dormant through the whole
pre-1.0 run, which is the correct behaviour anyway.

Templates, the contributing guide and verify-release.sh are rewritten for this
app's domain rather than renamed: the architectural rule here is that Room
types stay in the data layer, and the on-device release check is an alarm that
survives a lock screen and a reboot, not a task list that loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L94fydiJC37LtxVusNQBDy
This commit is contained in:
Jean-Luc Makiola
2026-09-11 11:47:07 +02:00
co-authored by Claude Opus 5
parent 5ddec0f248
commit 97ed6cf6f5
25 changed files with 1963 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
# Clockula — releasing
Clockula 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](#two-forges-one-repo) for how the two are wired.
While Clockula 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 099. 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 both the Gitea release notes and the F-Droid
per-version "What's New". 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 run
```sh
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 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.
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, and creates the `vX.Y.Z` tag + Gitea release.
> 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. Ordinary merges with no
version bump fall through `detect` and do nothing.
The pipeline ends with a separate `play` job that uploads the App Bundle to
Google Play. It runs last and in isolation so a Play rejection cannot endanger
a release that has already shipped to F-Droid and Codeberg, and it skips
cleanly until Play is configured.
### Codeberg direct-download channel
Alongside F-Droid, each release is published on the Codeberg repo
(`jlmakiola/clockula`) 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 `clockula_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.10.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.
### Google Play channel
Play is a third channel alongside F-Droid and the Codeberg download, and it is
the only one that gets a different artifact and a different signature.
**Artifact.** Play takes an **App Bundle** (`bundleRelease`), not the APK. It is
a second output of the same source and the same signing config — never a
repackage of the published APK, which stays untouched so the F-Droid
reproducibility guarantee is unaffected. The bundle is built at the very **end**
of the `release` job, after everything else has shipped, and both it and the
handoff to the `play` job are `continue-on-error` — nothing Play-related may
take down a release that is already published. The handoff uses a patched
`upload-artifact`/`download-artifact` fork pinned to a commit: the official v4
actions read any non-github.com forge as an unsupported GHES instance and refuse
to run on Gitea (go-gitea/gitea#36024). `dependenciesInfo` stays disabled for
the bundle too; Play's "app dependencies" report is optional and re-enabling it
would break reproducibility.
**Signature — read this before assuming an update path exists.** Play App
Signing is mandatory for new apps, and Google generates and holds the app
signing key. The release keystore in CI is registered only as the **upload
key**: Play verifies uploads with it, then re-signs with Google's key before
delivery. Consequences, accepted deliberately:
- A Play install and an F-Droid install have **different signatures** and
**cannot update each other**. Switching channels requires uninstall +
reinstall, which loses nothing (all data lives in the system calendar
provider) but must be stated wherever both channels are advertised.
- Losing the upload key is **recoverable** — request an upload-key reset in the
Play Console. Losing the app key still is not, for F-Droid.
**Build and signing are not fastlane's job.** Gradle does both, exactly as
before. fastlane appears only as the Play Developer API client (`supply`),
because the store listing already lives in `fastlane/metadata/android/` — the
same tree the official F-Droid repo harvests. One metadata source, two stores.
**What gets uploaded per release:** the AAB, plus the per-version "What's New"
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — the
hand-written summary from step 3, which is why it must stay **under 500
characters**: Play rejects a longer one. Listing text is
**not** touched — an accidental overwrite of a live listing triggers a Play
policy review. Sync it deliberately with `bundle exec fastlane listing`.
**Screenshots and graphics are skipped**, because the committed assets satisfy
F-Droid but not Play:
| Asset | Committed | Play requires |
| --- | --- | --- |
| `phoneScreenshots/*.png` | 1280×2856, 32-bit RGBA | long edge ≤ 2× short edge (so ≤ 2560), 24-bit PNG, no alpha |
| `icon.png` | 512×512, 24-bit RGB | 512×512, 32-bit PNG |
| `featureGraphic.png` | *missing* | required, exactly 1024×500 |
Until those are fixed, Play's graphics are managed by hand in the console. Then
pass `images:true` to the `listing` lane.
**Track.** Uploads go straight to `production` at a full rollout
(`PLAY_RELEASE_STATUS=completed`). The merge to `main` is already the human
gate — a release only gets there after on-device review — so a second manual
promotion in the Play Console added delay without adding a decision. Set the
`PLAY_TRACK` repo variable (e.g. `internal`) to stage a release instead, or
`PLAY_RELEASE_STATUS=draft` to hold it unpublished.
### 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/clockula`) 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 access — uploads the AAB. If unset, the `play` job skips cleanly, which is the expected state for the whole pre-1.0 run. |
| `RENOVATE_TOKEN` | Codeberg bot-account token — repo read/write + PR scope on `jlmakiola/clockula`. 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.clockula.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`.