ci(release): beta releases as Codeberg-only pre-releases (#38)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 52s

### What this changes

Adds beta releases. Pushing a `release/*` branch whose committed `versionName` is `X.Y.Z-beta.N` makes the new `.gitea/workflows/beta.yaml` run the unit tests, build and sign the APK with the app key, and publish it as a **Codeberg pre-release** (APK + `.sha256`), plus a Gitea pre-release with the R8 mapping. F-Droid (self-hosted and official) and Play never get a beta; Obtainium only offers it with *Include prereleases* on.

- **`scripts/version_info.sh`** is the single source for `versionName` → `versionCode`, used by `release.yaml`, `beta.yaml`, the changelog sync and the store-listing check. From 1.1.0: `X*1000000 + Y*10000 + Z*100 + N` for betas (N = 1–98), `+ 99` for stable, so `1.1.0-beta.1` → `1010001`, `1.1.0` → `1010099`. All 1.0.x versions keep the legacy formula, so `release/v1.0.1` (code `10001`) stays valid.
- **Shared publish scripts:** `scripts/publish_codeberg_release.sh`, `scripts/publish_gitea_release.sh` and `scripts/release_notes.sh`, moved out of `release.yaml`. The stable path behaves as before.
- **Guards:**
  - CI fails a PR whose committed `versionCode` doesn't match its `versionName`.
  - CI fails a PR into `main` that carries a beta version.
  - `release.yaml`'s `detect` refuses a beta on `main` as a backstop.
  - `beta.yaml` refuses a beta of a version that has already shipped as stable.
  - Betas get no store What's New file.
- **Docs:** "Cutting a beta" and the versionCode table in `docs/RELEASING.md`; a note on beta tags in `docs/fdroid-official/README.md`; how to opt in to betas in the README.

### Why

To ship a test build of an upcoming version (e.g. 1.1.0) to opted-in testers before the stable release, without it reaching F-Droid or Play users.

### How it was tested

- `scripts/version_info.sh` against stable, beta, legacy and invalid version names.
- Both publish scripts against a mock forge API: create, re-run (PATCH plus asset replacement), Codeberg's 500-then-retry path, and the skip when no token is set.
- A scratch copy with `1.1.0-beta.1` committed: the version check passes, the changelog sync and `check_store_listing.py --complete` pass without a What's New, the PR-into-main guard trips, and a wrong `versionCode` is rejected.
- `sync_changelog_to_fastlane.sh` and `check_store_listing.py` (with and without `--complete`) still pass on the current `1.0.0`.
- All three workflow files parse as YAML.

Not run on the real runners yet. The first beta push is the live test of `beta.yaml`, which assumes a mirrored branch push starts a workflow on Gitea, the same way pushes to `main` already do.

### Checklist

- [x] No `versionName` / `versionCode` bump
- [x] No `values-*/strings.xml` touched
- [x] `CHANGELOG.md` not updated: this is release infrastructure, not a user-visible change

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/38
This commit is contained in:
Jean-Luc Makiola
2026-10-05 18:47:57 +02:00
co-authored by makiolaj
parent f92f9bcdad
commit fe0c7d83a9
13 changed files with 653 additions and 208 deletions
+7 -5
View File
@@ -16,6 +16,7 @@ without setup.
"""
import re
import struct
import subprocess
import sys
from pathlib import Path
@@ -120,10 +121,11 @@ def check_images(locale_dir):
def version_code():
gradle = (ROOT / "app/build.gradle.kts").read_text()
name = re.search(r'versionName\s*=\s*"([^"]+)"', gradle).group(1)
parts = (name.split(".") + ["0", "0"])[:3]
return int(parts[0]) * 10000 + int(parts[1]) * 100 + int(parts[2])
"""The committed version's code, or None for a beta (it ships no What's New)."""
def info(key):
return subprocess.run(["bash", str(ROOT / "scripts/version_info.sh"), key],
check=True, capture_output=True, text=True).stdout.strip()
return None if info("channel") == "beta" else int(info("version_code"))
def shipped_languages():
@@ -183,7 +185,7 @@ def main():
err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)")
if len(list((images / "phoneScreenshots").glob("*"))) < 2:
err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots")
for loc in REQUIRED_LOCALES:
for loc in REQUIRED_LOCALES if current is not None else []:
if not (META / loc / "changelogs" / f"{current}.txt").exists():
err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version")