ci(release): port Agendula's 1.1.0 release pipeline, with betas
Brings the workflows, scripts and fastlane setup up to Agendula's
v1.1.0-beta.1 state:
- beta.yaml: a release/** branch carrying X.Y.Z-beta.N publishes a
Gitea + Codeberg pre-release with the signed APK. Never F-Droid or Play.
- scripts/version_info.sh derives versionCode from versionName for every
workflow and CI step. Clockula has no shipped 1.0.x codes, so it uses one
scheme: X*1000000 + Y*10000 + Z*100 + (beta N, or 99 for stable).
- CI checks the committed versionCode, blocks a beta from reaching main,
enforces the 500-char What's New and validates the store listing.
- Shared publish_gitea_release.sh / publish_codeberg_release.sh /
release_notes.sh replace the inline steps in release.yaml.
- Renovate also runs on every push to main to rebase conflicted PRs.
- jniLibs keepDebugSymbols, which the reproducibility guard now requires.
Fixes a bug present in Agendula too: the Codeberg tag lookups used
/git/refs/tags/{tag}, which matches by prefix, so v1.0.0 would have
"existed" as soon as v1.0.0-beta.1 did. That would have refused every
later beta and skipped the stable release. Both now use /tags/{tag}.
This commit is contained in:
1 parent
b5680bdc9e
commit
881f6b1286
18 files changed
+1206
-508
No files matched your search
+242
-143
@@ -1,37 +1,51 @@
|
||||
# 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
|
||||
Clockula 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
|
||||
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.
|
||||
Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the
|
||||
release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid
|
||||
and Play never see them. See [Cutting a beta](#cutting-a-beta).
|
||||
|
||||
Clockula's first public build is `1.0.0-beta.1`; there was never a `0.x` release.
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
`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`)
|
||||
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `1.0.0`), or
|
||||
`MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `1.0.0-beta.2`)
|
||||
- `versionCode` is derived from it by `scripts/version_info.sh`:
|
||||
|
||||
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).
|
||||
| `versionName` | `versionCode` | Example |
|
||||
| --- | --- | --- |
|
||||
| `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `1.0.0-beta.2` → `1000002` |
|
||||
| `X.Y.Z` | `X*1000000 + Y*10000 + Z*100 + 99` | `1.0.0` → `1000099` |
|
||||
|
||||
A beta's code sits below its own stable release and above everything before
|
||||
it, so a beta install updates in place to the next beta and then to the stable
|
||||
version. `MINOR` and `PATCH` each have room for 0–99.
|
||||
Run `scripts/version_info.sh` to see what the committed version resolves to;
|
||||
CI fails a PR whose committed `versionCode` doesn't match, because the official
|
||||
F-Droid repo builds the tag exactly as committed.
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
@@ -39,66 +53,118 @@ re-running the workflow safely retries).
|
||||
|
||||
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`.
|
||||
that branch, before it reaches `main`. To ship betas first, follow
|
||||
[Cutting a beta](#cutting-a-beta) from here and come back to step 2 when
|
||||
going stable.
|
||||
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:
|
||||
and the next `## [` becomes the Gitea and Codeberg release notes. The
|
||||
heading **must** match the version exactly.
|
||||
3. **Bump the committed `versionName`** (and `versionCode`, which
|
||||
`scripts/version_info.sh` prints) 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` (e.g.
|
||||
`1000099.txt` for 1.0.0), 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.** This is 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;
|
||||
- launch from a **clean / permission-not-granted** state: notification
|
||||
permission is asked for, no crash;
|
||||
- set an alarm a minute out and lock the device: it must ring over the lock
|
||||
screen with sound and vibration, and snooze and dismiss must both work;
|
||||
- reboot with that alarm armed: it must survive and still fire;
|
||||
- start a timer and the stopwatch, background the app: both keep time and
|
||||
stay controllable from the notification shade;
|
||||
- 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 script prints the same list.
|
||||
|
||||
> The `releaseTest` build type exists only for step 4 — it is never published.
|
||||
Only proceed once all of that passes on-device.
|
||||
5. **Merge `release/vX.Y.Z` into `main`.** There is 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 and is never published.
|
||||
> The pipeline always builds and signs the real `release` variant.
|
||||
|
||||
---
|
||||
|
||||
## Cutting a beta
|
||||
|
||||
A beta is a test build of the next version, published as a **pre-release on
|
||||
Codeberg** and nowhere else. It is signed with the real app key, so testers
|
||||
install it over their stable install and it updates in place to later betas
|
||||
and to the stable release.
|
||||
|
||||
1. **On `release/vX.Y.Z`**, with the features merged in, set
|
||||
`versionName = "X.Y.Z-beta.1"` and the matching `versionCode`
|
||||
(`scripts/version_info.sh` prints it; `1.0.0-beta.1` → `1000001`).
|
||||
No What's New file, since betas don't ship to the stores. Notes come from a
|
||||
`## [X.Y.Z-beta.N]` section of `CHANGELOG.md` if you write one, otherwise
|
||||
from `## [Unreleased]`. So keep the entries under `## [Unreleased]` while
|
||||
betas are going out and only move them to `## [X.Y.Z]` when going stable:
|
||||
once they're moved, a beta's notes come out empty.
|
||||
2. **Optionally verify it on a device** with `scripts/verify-release.sh`, as for
|
||||
a stable release.
|
||||
3. **Push the branch to Codeberg.** When it reaches Gitea, `beta.yaml` sees a
|
||||
beta version without a tag, runs the unit tests, builds and signs the APK,
|
||||
creates the `vX.Y.Z-beta.1` tag + a Gitea pre-release (with the R8 mapping)
|
||||
and publishes the **Codeberg pre-release** with the APK + `.sha256`.
|
||||
4. **Next round:** bump to `-beta.2` (and its `versionCode`) and push again.
|
||||
5. **Going stable:** set `versionName = "X.Y.Z"` and its `versionCode`
|
||||
(`1.0.0` → `1000099`), then continue from step 2 of
|
||||
[Cutting a release](#cutting-a-release).
|
||||
|
||||
Who gets a beta:
|
||||
|
||||
- **Obtainium** users only with *Include prereleases* switched on for the app.
|
||||
That is how a tester opts in; everyone else stays on stable.
|
||||
- **F-Droid** (self-hosted and official) never: `beta.yaml` doesn't touch the
|
||||
self-hosted repo, and the official recipe's `UpdateCheckMode: Tags ^v[0-9.]+$`
|
||||
ignores `-beta` tags.
|
||||
- **Play** never.
|
||||
|
||||
Guards: a beta version can't reach `main` (CI fails the PR, and `release.yaml`'s
|
||||
`detect` refuses one as a backstop), `beta.yaml` refuses a beta of a version
|
||||
that already shipped stable.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- **`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
|
||||
- **`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.
|
||||
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC, on push to
|
||||
`main`, plus `workflow_dispatch`, **Gitea**): the dependency sweep. Runs the
|
||||
pinned Renovate image as its job container and targets Codeberg's API. New
|
||||
PRs only open in the Monday `schedule` window in `renovate.json5` (keep it
|
||||
bracketing the cron); the on-merge runs fall outside it and only rebase
|
||||
existing PRs that the merge left conflicted.
|
||||
- **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus
|
||||
`workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and
|
||||
`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,
|
||||
@@ -106,13 +172,19 @@ release work when a merge actually cuts a release:
|
||||
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** 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.
|
||||
- **`beta.yaml`** (`.gitea/workflows/`, on push to `release/**`, **Gitea**):
|
||||
the same `detect` idea for betas. Only when the committed `versionName` is
|
||||
`X.Y.Z-beta.N` and Codeberg has no tag for it does the `beta` job run: unit
|
||||
tests, pin `versionCode`, build & sign the release APK with the **app key**,
|
||||
create the tag + a Gitea pre-release with the R8 mapping, and publish the
|
||||
**Codeberg pre-release** with the APK + SHA-256. No F-Droid, no Play.
|
||||
- **`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
|
||||
|
||||
@@ -122,77 +194,54 @@ 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.1–0.3.2 this step reported
|
||||
adds no trust surface. The logic lives in `scripts/publish_codeberg_release.sh`,
|
||||
which `beta.yaml` uses too (with the pre-release flag set). It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset,
|
||||
but it is **not** `continue-on-error`: in Agendula 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.
|
||||
|
||||
### 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 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.
|
||||
|
||||
**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.** 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.
|
||||
|
||||
**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:
|
||||
**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)).
|
||||
|
||||
- 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.
|
||||
**Track.** Uploads go straight to `production` at full rollout, because 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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
|
||||
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope,
|
||||
since it pushes the tag as well as creating the release) added to Gitea Actions.
|
||||
|
||||
### Manual re-sign / recovery
|
||||
|
||||
@@ -206,7 +255,7 @@ rotation or repo recovery without publishing a new app version.
|
||||
|
||||
## Two forges, one repo
|
||||
|
||||
**Codeberg (`jlmakiola/clockula`) is canonical** — git, issues, PRs, tags and
|
||||
**Codeberg (`jlmakiola/clockula`) is canonical** for 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
|
||||
@@ -221,14 +270,13 @@ first that exists; Gitea doesn't know `.forgejo/` at all:
|
||||
| `.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
|
||||
The line is drawn at **secrets, not at CI-vs-release**, which 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
|
||||
PRs on Codeberg*: it talks to Codeberg's API instead of 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
|
||||
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:
|
||||
@@ -243,7 +291,7 @@ Two consequences worth remembering:
|
||||
|
||||
`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
|
||||
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.
|
||||
|
||||
@@ -251,43 +299,94 @@ resolve.
|
||||
|
||||
## Secrets (Gitea → repo Settings → Actions → Secrets)
|
||||
|
||||
The workflow fails loudly if the F-Droid ones are missing — it will **never**
|
||||
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. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `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 with 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
|
||||
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)
|
||||
|
||||
## 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.
|
||||
| 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.clockula.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
|
||||
|
||||
Reference in new issue
Block a user