ci(release): beta releases as Codeberg-only pre-releases (#369)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
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 1m30s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped

### What this changes

Adds beta releases, ported from Agendula (#38 and #40 there). Pushing a `release/*` branch whose `versionName` is `X.Y.Z-beta.N` runs the new `.gitea/workflows/beta.yaml`: unit tests, build + sign with the app key, then a **Codeberg pre-release** (APK + `.sha256`) and a Gitea pre-release (R8 mapping). F-Droid (self-hosted and official) and Play never get a beta; Obtainium only offers it with *Include prereleases* on.

- **New versionCode scheme**, derived in one place by `scripts/version_info.sh`. 2.22.3 is the last legacy version (`X*10000 + Y*100 + Z`); from **2.22.4** on it is `X*1000000 + Y*10000 + Z*100 + N` for a beta (N = 1–98) and `+ 99` for stable, so `2.22.4` → `2220499`, `2.23.0-beta.1` → `2230001`.
- **`scripts/release_gate.sh`** decides in both `detect` jobs whether the version still needs publishing. Tags are read by exact name via `git ls-remote`: Codeberg's `git/refs/tags/<name>` matches by prefix, so a beta tag would otherwise hide its stable release. A beta counts as done only once its Codeberg pre-release carries the APK (a failed publish is redone by the next push) and must be newer than the latest stable.
- **Shared scripts** `publish_codeberg_release.sh`, `publish_gitea_release.sh`, `release_notes.sh`, `write_keystore.sh`, and a local composite action `.gitea/actions/android-env` for the toolchain setup, used by both `release.yaml` and `beta.yaml`. The stable path behaves as before (Codeberg step stays best-effort).
- **Guards:** CI fails a PR whose `versionCode` doesn't match its `versionName`, or that brings a beta into `main`; `release.yaml` refuses a beta as a backstop; betas get no store What's New (`sync_changelog_to_fastlane.sh`, `check_changelog_lengths.sh`).
- **Docs:** versionCode table and "Cutting a beta" in `docs/RELEASING.md`, the Obtainium note in the README, `build.gradle.kts` comment.
- `gradle/gradle-daemon-jvm.properties` now points at JetBrains' own JBR 21.0.11 downloads instead of foojay, which dropped JetBrains 21 from its index (the pinned ids return 400, so a clean runner can't provision the daemon JVM).

### Why

To ship test builds of an upcoming version to opted-in testers before the stable release, without them reaching F-Droid or Play users.

Infra-only, so this targets `main` directly; no version bump. When cutting 2.22.4, its What's New file is `changelogs/2220499.txt`.

### Checklist

- [x] Targeting `main` (infra change, noted above)
- [x] No `values-*/strings.xml` touched
- [x] `CHANGELOG.md` not updated: release infrastructure, not a user-visible change
- [x] No planning or design documents committed

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/369
This commit is contained in:
Jean-Luc Makiola
2026-10-06 18:43:45 +02:00
co-authored by makiolaj
parent 4342c6ab6d
commit 9c35712573
16 changed files with 631 additions and 285 deletions
+6 -5
View File
@@ -28,10 +28,11 @@ LIMIT=500
STRICT=0
[ "${1:-}" = "--strict" ] && STRICT=1
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
VERSION_CODE=$(( ${MAJOR:-0} * 10000 + ${MINOR:-0} * 100 + ${PATCH:-0} ))
VERSION=$(bash scripts/version_info.sh version)
VERSION_CODE=$(bash scripts/version_info.sh version_code)
# A beta ships no What's New, so only the older files are checked.
BETA=0
[ "$(bash scripts/version_info.sh channel)" = beta ] && BETA=1
fail=0
warned=0
@@ -65,7 +66,7 @@ for f in fastlane/metadata/android/*/changelogs/*.txt; do
fi
done
if [ "$current" -eq 0 ]; then
if [ "$current" -eq 0 ] && [ "$BETA" -eq 0 ]; then
echo "ERROR: no changelog for version $VERSION (code $VERSION_CODE)." >&2
echo " Write fastlane/metadata/android/en-US/changelogs/$VERSION_CODE.txt" >&2
echo " before releasing — see docs/RELEASING.md step 3." >&2
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Publishes TAG on Codeberg with the signed APK and its SHA-256 attached.
# Upserts, so re-runs are safe. A missing TOKEN skips a stable release but fails
# a pre-release, where Codeberg is the only channel.
#
# Env: TOKEN, API (.../api/v1/repos/<owner>/<repo>), TAG, SHA, PRERELEASE,
# NOTES_FILE, APK
set -euo pipefail
if [ -z "${TOKEN:-}" ]; then
if [ "${PRERELEASE:-}" = true ]; then
echo "CODEBERG_RELEASE_TOKEN not set — a pre-release has nowhere else to go." >&2
exit 1
fi
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
: "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" "${APK:?}"
if [ ! -f "$APK" ]; then echo "No release APK at $APK." >&2; exit 1; fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
ASSET_APK="calendula_${TAG}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "$WORK/$ASSET_APK"
( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Push the tag first and create the release without target_commitish: a POST
# naming a commit Codeberg hasn't received yet 500s.
HOST=${API#https://}; HOST=${HOST%%/*}
REPO=${API#*/repos/}
git tag -f "$TAG" "$SHA"
git push -f "https://${REPO%%/*}:${TOKEN}@${HOST}/${REPO}.git" "refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
import json, sys
tag, pre, notes = sys.argv[1:4]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open(notes).read(),
"draft": False,
"prerelease": pre == "true",
}))
PY
# Codeberg 500s on a freshly pushed tag for a few seconds, hence the retries.
ID=""
for attempt in 1 2 3 4 5 6; do
EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$EXIST" ]; then
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases/$EXIST" || echo 000)
echo "release PATCH HTTP $CODE"
if [ "$CODE" = 200 ]; then ID="$EXIST"; break; fi
else
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases" || echo 000)
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' "$WORK/response.json" 2>/dev/null || true)
[ -n "$ID" ] && break
fi
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not create or update the Codeberg release." >&2; exit 1; fi
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[]? | select(.name==$n) | .id' 2>/dev/null || true)
for O in $OLD; do
curl -s -o /dev/null -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$O" || true
done
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$A" "$API/releases/$ID/assets?name=$A" || echo 000)
echo "asset $A HTTP $CODE"
if [ "$CODE" != 201 ]; then echo "Uploading $A failed." >&2; exit 1; fi
done
echo "Published $TAG to Codeberg."
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Upserts the Gitea release for TAG (creating the tag at SHA) and attaches the
# R8 mapping, best-effort.
#
# Env: TOKEN, API (.../api/v1/repos/<owner>/<repo>), TAG, SHA, PRERELEASE,
# NOTES_FILE, MAPPING (optional)
set -euo pipefail
: "${TOKEN:?}" "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
python3 - "$TAG" "$SHA" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
import json, sys
tag, sha, pre, notes = sys.argv[1:5]
print(json.dumps({
"tag_name": tag,
"target_commitish": sha,
"name": tag,
"body": open(notes).read(),
"draft": False,
"prerelease": pre == "true",
}))
PY
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases")
OK=201
fi
cat "$WORK/response.json"; echo
if [ "$CODE" != "$OK" ]; then
echo "Gitea release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
ID=$(jq -r '.id' "$WORK/response.json")
echo "Created/updated Gitea release $TAG at $SHA"
attach_mapping() {
local asset="mapping-${TAG#v}.txt.gz" old
gzip -c "$MAPPING" > "$WORK/$asset"
old=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$asset" '.[] | select(.name==$n) | .id')
if [ -n "$old" ]; then
curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$old" >/dev/null
fi
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$asset" \
"$API/releases/$ID/assets?name=$asset" -o /dev/null -w "asset $asset HTTP %{http_code}\n"
}
if [ -z "${MAPPING:-}" ] || [ ! -f "$MAPPING" ]; then
echo "No mapping.txt (R8 off?) — skipping."
else
attach_mapping || echo "warning: could not attach the R8 mapping to $TAG" >&2
fi
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Decides whether the committed version still needs publishing; prints cut=true|false.
#
# stable: no vX.Y.Z tag on Codeberg yet.
# beta: its Codeberg pre-release doesn't carry the APK yet, so a failed
# publish is redone by the next push. Refused unless it is newer than
# every shipped stable release.
#
# Fails on any lookup error rather than guessing.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
REPO=${REPO:-jlmakiola/calendula}
VERSION=$(bash scripts/version_info.sh version)
BASE=$(bash scripts/version_info.sh base_version)
CHANNEL=$(bash scripts/version_info.sh channel)
# Exact tag names. The Codeberg git/refs/tags/<name> API matches by prefix.
TAGS=$(git ls-remote --tags --refs "https://codeberg.org/$REPO.git" | sed 's#.*refs/tags/##')
if [ "$CHANNEL" = stable ]; then
if grep -qxF "v$VERSION" <<<"$TAGS"; then
echo "Tag v$VERSION already exists on Codeberg — nothing to release." >&2
echo "cut=false"
else
echo "No tag for v$VERSION on Codeberg yet — cutting the release." >&2
echo "cut=true"
fi
exit 0
fi
LATEST=$(grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' <<<"$TAGS" | sed 's/^v//' | sort -V | tail -n1 || true)
if [ -n "$LATEST" ] && [ "$(printf '%s\n%s\n' "$BASE" "$LATEST" | sort -V | tail -n1)" = "$LATEST" ]; then
echo "Beta $VERSION is not newer than the shipped stable $LATEST — bump the version." >&2
exit 1
fi
ASSET="calendula_v${VERSION}.apk"
BODY=$(mktemp); trap 'rm -f "$BODY"' EXIT
STATUS=$(curl -s -o "$BODY" -w '%{http_code}' "https://codeberg.org/api/v1/repos/$REPO/releases/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
if grep -qF "\"name\":\"$ASSET\"" "$BODY"; then
echo "Pre-release v$VERSION already carries $ASSET — nothing to do." >&2
echo "cut=false"
else
echo "Pre-release v$VERSION exists without $ASSET — publishing it again." >&2
echo "cut=true"
fi
;;
404)
echo "No pre-release for v$VERSION yet — cutting the beta." >&2
echo "cut=true"
;;
*)
echo "Codeberg release lookup for v$VERSION returned HTTP $STATUS — refusing to guess." >&2
exit 1
;;
esac
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Prints the release notes for VERSION from CHANGELOG.md. A beta without its own
# section falls back to [Unreleased], under a line saying what a beta is.
#
# scripts/release_notes.sh 2.22.4 > release-notes.md
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
VERSION=${1:?usage: release_notes.sh VERSION}
section() {
awk -v ver="$1" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md | sed -e '/./,$!d'
}
NOTES=$(section "$VERSION")
if [[ "$VERSION" == *-beta.* ]]; then
[ -n "$NOTES" ] || NOTES=$(section Unreleased)
printf '%s\n\n' "**Beta of ${VERSION%%-*}, for testing.** It updates in place to later betas and to the stable release. Obtainium only offers betas with *Include prereleases* switched on; F-Droid and Play never get them."
fi
if [ -n "$NOTES" ]; then
printf '%s\n' "$NOTES"
else
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._"
fi
+9 -5
View File
@@ -19,11 +19,15 @@ cd "$(dirname "$0")/.." # repo root
LIMIT=500
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
VERSION=$(bash scripts/version_info.sh version)
VERSION_CODE=$(bash scripts/version_info.sh version_code)
# Betas only ship to Codeberg, whose notes come from CHANGELOG.md. A What's New
# file for one would sit in the tree F-Droid and Play read, so none is wanted.
if [ "$(bash scripts/version_info.sh channel)" = beta ]; then
echo "Beta $VERSION: no store What's New (betas only ship to Codeberg)."
exit 0
fi
CL_DIR="fastlane/metadata/android/en-US/changelogs"
mkdir -p "$CL_DIR"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Derives versionCode, channel and pre-release flag from the committed versionName.
#
# X.Y.Z (<= 2.22.3) stable X*10000 + Y*100 + Z (legacy)
# X.Y.Z-beta.N beta X*1000000 + Y*10000 + Z*100 + N (N = 1..98)
# X.Y.Z (>= 2.22.4) stable X*1000000 + Y*10000 + Z*100 + 99
#
# scripts/version_info.sh all values as key=value lines
# scripts/version_info.sh <key> one value
# scripts/version_info.sh --check fail unless the committed versionCode matches
# scripts/version_info.sh --pin rewrite the committed versionCode to the derived one
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
GRADLE="app/build.gradle.kts"
NAME=$(grep -oP 'versionName\s*=\s*"\K[^"]+' "$GRADLE" || true)
COMMITTED=$(grep -oP 'versionCode\s*=\s*\K[0-9]+' "$GRADLE" || true)
[ -n "$NAME" ] || { echo "No versionName in $GRADLE" >&2; exit 1; }
if [[ ! "$NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.([1-9][0-9]*))?$ ]]; then
echo "versionName '$NAME' is neither X.Y.Z nor X.Y.Z-beta.N" >&2
exit 1
fi
MAJOR=${BASH_REMATCH[1]}; MINOR=${BASH_REMATCH[2]}; PATCH=${BASH_REMATCH[3]}
BETA=${BASH_REMATCH[5]}
BASE="$MAJOR.$MINOR.$PATCH"
if [ "$MINOR" -gt 99 ] || [ "$PATCH" -gt 99 ]; then
echo "versionName '$NAME': MINOR and PATCH each have room for 0-99" >&2
exit 1
fi
LEGACY=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
if [ -n "$BETA" ]; then
if [ "$LEGACY" -lt 22204 ]; then
echo "versionName '$NAME': betas start at 2.22.4; legacy codes have no room for them" >&2
exit 1
fi
if [ "$BETA" -lt 1 ] || [ "$BETA" -gt 98 ]; then
echo "versionName '$NAME': beta number must be 1-98 (99 is the stable release)" >&2
exit 1
fi
CHANNEL=beta
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + BETA ))
elif [ "$LEGACY" -lt 22204 ]; then
CHANNEL=stable
CODE=$LEGACY
else
CHANNEL=stable
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + 99 ))
fi
if [ "$CHANNEL" = beta ]; then PRERELEASE=true; else PRERELEASE=false; fi
case "${1:-}" in
"")
printf 'version=%s\nversion_code=%s\nbase_version=%s\nchannel=%s\nprerelease=%s\n' \
"$NAME" "$CODE" "$BASE" "$CHANNEL" "$PRERELEASE"
;;
--check)
if [ "$COMMITTED" != "$CODE" ]; then
echo "ERROR: $GRADLE has versionCode = ${COMMITTED:-<none>}, but versionName '$NAME' needs $CODE." >&2
echo "Set versionCode = $CODE (see docs/RELEASING.md for the scheme)." >&2
exit 1
fi
echo "OK: versionName $NAME -> versionCode $CODE ($CHANNEL)."
;;
--pin)
sed -i "s/versionCode = .*/versionCode = $CODE/" "$GRADLE"
grep -E 'versionName|versionCode' "$GRADLE"
;;
version) echo "$NAME" ;;
version_code) echo "$CODE" ;;
base_version) echo "$BASE" ;;
channel) echo "$CHANNEL" ;;
prerelease) echo "$PRERELEASE" ;;
*) echo "Unknown key '$1'" >&2; exit 2 ;;
esac
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Writes the app signing keystore and key.properties from CI secrets.
#
# Env: KEYSTORE_BASE64, KEY_PASSWORD, KEY_ALIAS
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
: "${KEYSTORE_BASE64:?}" "${KEY_PASSWORD:?}" "${KEY_ALIAS:?}"
mkdir -p app
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
cat > key.properties <<PROPS
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
PROPS