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:
2026-10-06 19:14:11 +02:00
parent b5680bdc9e
commit 881f6b1286
18 changed files with 1206 additions and 508 deletions
+42 -1
View File
@@ -37,6 +37,47 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# The committed versionName must parse (X.Y.Z or X.Y.Z-beta.N) and the
# committed versionCode must be the one it derives: the official F-Droid
# repo builds the tag as committed. And a beta must never reach main,
# where the release pipeline would ship it to F-Droid and Play; betas are
# cut from release/* branches (docs/RELEASING.md).
- name: Committed version is well-formed
env:
BASE: ${{ github.base_ref }}
run: |
set -e
bash scripts/version_info.sh --check
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
echo "Set the stable version (and its versionCode) before merging into main." >&2
exit 1
fi
# Also cheap, also always-on. Two failures in one: the script exits
# non-zero if this version's changelog is over the 500-character limit,
# and the porcelain check below catches a version with no committed
# changelog, which would otherwise ship the CHANGELOG.md section instead
# of a hand-written summary. A beta version passes both: it ships no
# What's New, so the script writes nothing for it.
- name: Changelog fits the stores, and is committed
run: |
set -e
bash scripts/sync_changelog_to_fastlane.sh
DIRTY=$(git status --porcelain fastlane/metadata/android/en-US/changelogs)
if [ -n "$DIRTY" ]; then
echo "$DIRTY"
echo "ERROR: no committed What's New for this version." >&2
echo "Write fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt" >&2
echo "(under 500 chars, every shipped locale) and commit it." >&2
exit 1
fi
# The fastlane tree feeds F-Droid and Play alike; Play rejects oversized
# text and off-spec graphics at upload time, so catch that on the PR.
- name: Store listing fits both stores
run: python3 scripts/check_store_listing.py
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
@@ -47,7 +88,7 @@ jobs:
# about defaults to building. Only paths the Gradle build provably
# never reads belong here — note that the workflows themselves, the
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^licenses/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^Gemfile$|^licenses/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
run: |
set -e
BASE="${{ github.base_ref }}"
+208
View File
@@ -0,0 +1,208 @@
name: Beta — Codeberg pre-release
# A beta is cut by pushing a release branch whose committed versionName is
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
# no tag exists for that version yet, this runs the unit tests, builds and
# signs the APK with the app key, records a Gitea pre-release (with the R8
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
#
# Deliberately nothing else. A beta never reaches the F-Droid repos or Play:
# Obtainium hides pre-releases unless a user opts in, the official F-Droid
# recipe only picks up `^v[0-9.]+$` tags, and Codeberg's "latest release"
# skips pre-releases. Pushes of a release branch carrying a stable version
# (the usual state) fall through `detect` and do nothing.
#
# Lives in .gitea/workflows next to release.yaml for the same reason: it needs
# the app signing key, which only the self-hosted Gitea instance holds.
on:
push:
branches: ['release/**']
concurrency:
group: beta
cancel-in-progress: false
jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: docker
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and whether it is a new beta
id: v
env:
# Codeberg, not Gitea: see the same lookup in release.yaml.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
run: |
set -e
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
BASE=$(echo "$INFO" | sed -n 's/^base_version=//p')
if [ "$(echo "$INFO" | sed -n 's/^channel=//p')" != "beta" ]; then
echo "versionName $VERSION is not a beta — nothing to do."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fatal on anything but a clean 200/404, as in release.yaml: guessing
# "no tag" during an outage would re-cut a published beta.
# /tags/{tag}, not /git/refs/tags/{tag}: the refs endpoint matches by
# prefix, so v1.0.0 would "exist" as soon as v1.0.0-beta.1 did.
tag_status() {
curl -s -o /dev/null -w '%{http_code}' "$TAG_API/tags/$1" || echo 000
}
# A beta of a version that already shipped stable would carry a lower
# versionCode than the stable one: nobody could install it over it.
case "$(tag_status "v$BASE")" in
200)
echo "v$BASE already shipped as stable; a beta of it is pointless. Bump the version." >&2
exit 1 ;;
404) ;;
*) echo "Codeberg tag lookup for v$BASE failed — refusing to guess." >&2; exit 1 ;;
esac
STATUS=$(tag_status "v$VERSION")
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the beta."
echo "is_beta=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a published beta." >&2
exit 1
;;
esac
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install jq
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Pin versionCode to versionName
run: |
set -e
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
- name: Unit tests
run: ./gradlew testDebugUnitTest
# The real app key, same as a stable release: a beta has to update in
# place to the next beta and to the stable version.
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
mkdir -p app
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
cat > key.properties <<EOF
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
EOF
- name: Build release APK
run: ./gradlew assembleRelease
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
# `## [Unreleased]`. Gitea creates the tag at this commit.
- name: Create tag + Gitea pre-release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
# The point of the whole workflow, so NOT continue-on-error.
- name: Publish pre-release to Codeberg
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
+85 -252
View File
@@ -7,14 +7,14 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
# nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
# one that reaches main.
#
# A trailing `play` job then uploads the App Bundle to Google Play. It is last
# and separate because Play is the only channel that can reject a good build for
# reasons the pipeline can't see, and that must not endanger a release which has
# already shipped to F-Droid and Codeberg. It skips cleanly until the
# PLAY_SERVICE_ACCOUNT_JSON secret exists — so it stays dormant through the
# whole pre-1.0 run, which is the correct behaviour anyway.
# and separate because Play can reject a good build for reasons the pipeline
# can't see, and that must not endanger a release which already shipped to
# F-Droid and Codeberg. It skips cleanly until PLAY_SERVICE_ACCOUNT_JSON exists.
#
# This file lives in .gitea/workflows on purpose: Codeberg is canonical for git,
# issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner
@@ -75,23 +75,25 @@ jobs:
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
run: |
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
# versionName -> versionCode, channel and the pre-release flag (set
# while MAJOR was 0) all come from the one script beta.yaml uses too.
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Betas ship from release/* branches via beta.yaml and must never be
# cut here: this path publishes to F-Droid and Play. CI blocks such a
# PR into main; this is the backstop if one gets through anyway.
if [ "$CHANNEL" != "stable" ]; then
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
exit 1
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
#
@@ -100,7 +102,11 @@ jobs:
# read as absent and re-cut a release that has already shipped —
# republishing to F-Droid. Failing here is recoverable; a duplicate
# release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
#
# /tags/{tag}, not /git/refs/tags/{tag}: the refs endpoint matches by
# prefix, so v1.0.0 would "exist" once v1.0.0-beta.1 does and the
# stable release would never be cut.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
@@ -117,6 +123,16 @@ jobs:
;;
esac
# Before a single Gradle task runs: F-Droid truncates the in-client
# changelog, so an over-long one would reach users cut off mid-sentence.
# The script exits non-zero past the limit. Cheap enough to sit in the
# gate job, where failing costs nothing and publishes nothing — the step
# further down that regenerates the file for the repo would otherwise be
# the first thing to notice, after the build and the signing.
- name: Changelog fits the stores
if: steps.v.outputs.is_release == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
# Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and
# re-uploads the existing index (recovery path).
@@ -188,8 +204,8 @@ jobs:
run: chmod +x ./gradlew
# The committed versionName is the source of truth. Pin versionCode to the
# value derived from it so the published APK's code is always
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
# value scripts/version_info.sh derives from it, so the published APK's
# code follows the scheme even if the committed code was forgotten.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: |
@@ -324,6 +340,8 @@ jobs:
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# this commit, so the tag only ever marks a fully-shipped release (and a
# failure before here leaves no tag, so re-running the workflow retries).
# Also attaches the R8 mapping (best-effort) so user crash stacktraces
# stay deobfuscatable. Notes = this version's CHANGELOG section.
- name: Create tag + Gitea release
if: env.IS_RELEASE == 'true'
env:
@@ -332,217 +350,52 @@ jobs:
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION"
# Notes = this version's CHANGELOG section.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true",
}))
PY
# Upsert (re-run safe): PATCH if a release for the tag already exists,
# else POST a new one (which also creates the tag at target_commitish).
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases")
OK=201
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
# Publish the release on Codeberg, which is canonical for tags and
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
# to Gitea, but releases aren't git objects and don't sync in either
# direction — so this step pushes the tag straight to Codeberg and creates
# the release there over the API, attaching the signed APK plus a SHA-256
# checksum as the direct-download channel for users who don't want
# F-Droid. The APK is identical to the F-Droid one (same app key), so this
# adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
# cleanly if unset.
# releases (see docs/RELEASING.md): push the tag there and attach the
# signed APK plus a SHA-256 checksum as the direct-download channel for
# users who don't want F-Droid. The APK is identical to the F-Droid one
# (same app key), so this adds no trust surface. Needs the
# CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
# NOT continue-on-error, and inherited that way deliberately: in
# Agendula this step reported green through five consecutive releases
# (0.2.1 through 0.3.2) 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 must fail the release loudly.
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
# 0.3.0, 0.3.1 and 0.3.2 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 must fail the release loudly.
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
SHA: ${{ github.sha }}
run: |
set -e
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="clockula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
TAG="v$VERSION" NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the
# mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
# from Gitea (what Agendula 0.3.2 did) would wait forever. The tag this
# pipeline minted on Gitea is in fact *deleted* by the next mirror
# sync until Codeberg has it — so pushing it here is what makes it
# durable on both forges.
#
# Pushing the ref first and attaching with NO target_commitish is
# deliberate: a release POST carrying a target_commitish for a commit
# or tag Codeberg hasn't received yet is what produced the
# empty-bodied 500s. Attaching to a ref that already exists doesn't
# need the API to write one.
git tag -f "$TAG" "$SHA"
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/clockula.git" \
"refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
import json, sys
tag, pre = sys.argv[1:3]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": pre == "true",
}))
PY
# Create (or update) the release. Codeberg 500s on a POST/GET against a
# tag it has only just received — the release request outruns the
# indexing of the ref we pushed a moment ago — so a single attempt can
# fail even though the very same call succeeds seconds later. Retry
# with backoff, and PATCH in place if a release already exists (re-run
# safe, so re-running never disturbs a published release). A 5xx body
# still exits curl 0, so the loop, not `set -e`, controls the flow.
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
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
[ -n "$ID" ] && break
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
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')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK, so it is a second artifact from
# the same source and the same signing config — not a repackage of the
# APK. The release key signs it, but Play only ever treats that key as the
# *upload* key: Play App Signing re-signs with Google's own key before
# delivery. A Play install and an F-Droid install therefore carry
# different signatures and cannot update each other. That divergence is a
# deliberate, documented choice (docs/RELEASING.md), not an accident.
# Play takes an App Bundle, not the APK: a second artifact from the same
# source and signing config. Play treats the release key only as the
# upload key and re-signs with its own (Play App Signing), so Play and
# F-Droid installs carry different signatures and can't update each other.
#
# Built LAST and `continue-on-error`, both deliberately: everything above
# has already shipped by this point, and nothing Play-related may put that
# at risk. Sitting mid-job without continue-on-error, this block took the
# whole Calendula 2.17.0 release down with it — no F-Droid publish, no tag, no
# Codeberg mirror — over an artifact upload. A failure here now costs the
# Play upload and nothing else.
#
# Nothing here touches the F-Droid path: the AAB is never copied into the
# repo, never attached to a release, and its build cannot change the APK
# published above.
#
# AGP embeds the R8 mapping in the bundle's BUNDLE-METADATA, so Play gets
# deobfuscated stacktraces without a separate mapping upload.
# Built last and continue-on-error: everything above has already shipped,
# and nothing Play-related may take it down. The AAB never touches the
# F-Droid repo or the releases. AGP embeds the R8 mapping in the bundle,
# so Play gets deobfuscated stacktraces without a separate upload.
- name: Build release AAB
if: env.IS_RELEASE == 'true'
continue-on-error: true
run: ./gradlew bundleRelease
# NOT actions/upload-artifact@v4: it runs @actions/artifact v2, which
# refuses to start whenever GITHUB_SERVER_URL is not github.com — it reads
# any other forge as an unsupported GHES instance and fails before it ever
# talks to the server (go-gitea/gitea#36024). Gitea 1.25 serves the v4
# artifact API fine; only the client-side check is wrong. This fork is that
# client with the check removed. Pinned to a commit, not the v4 branch: a
# third-party action in the signing pipeline must not change under us.
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
# check. Pinned to a commit — a third-party action in the signing
# pipeline must not change under us.
- name: Hand the AAB to the Play job
if: env.IS_RELEASE == 'true'
continue-on-error: true
@@ -553,46 +406,34 @@ jobs:
if-no-files-found: error
retention-days: 14
# Google Play channel.
# Google Play channel. A separate job after the F-Droid publish and both forge
# releases, so a Play rejection (policy review, API outage, listing rules)
# shows up as one red job next to a release that already shipped.
#
# A separate job, on purpose, running only AFTER the F-Droid publish and both
# forge releases have completed. Play is the one channel that can reject a
# perfectly good build for reasons outside the pipeline (listing rules, policy
# review, API outage, a track that needs manual promotion). Isolating it means
# such a rejection surfaces as one red job next to a release that already
# shipped everywhere else, instead of failing the workflow that publishes it.
#
# Not a `container:` job even though a fastlane image exists: act_runner does
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above.
# Not a `container:` job: act_runner provides no node inside custom job
# containers, so JavaScript actions (checkout, download-artifact) can't run.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
# workflow_dispatch is the F-Droid re-sign recovery path; never touch Play.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
# Where the bundle lands. `production` — the release itself is already the
# gate (a bumped versionName only reaches main after on-device review), so
# a second manual promotion in the Play Console bought nothing but delay.
# Override with the PLAY_TRACK repo variable to stage a release instead.
# The release itself is the gate (a bumped versionName only reaches main
# after on-device review), so it goes straight to production. Override
# with repo variables to stage instead.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
# it instead of committing — used to rehearse the first upload.
# true validates the edit against the API and discards it.
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
BUNDLE_PATH: vendor/bundle
steps:
- name: Checkout
uses: actions/checkout@v4
# Skip cleanly (not fatally) when Play isn't configured yet, so the rest
# of the release pipeline keeps working during setup — same contract as
# the Codeberg mirror step.
# Skip cleanly when Play isn't configured yet, same contract as the
# Codeberg publish.
- name: Write the Play service-account key
id: key
env:
@@ -605,14 +446,11 @@ jobs:
exit 0
fi
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
# Fail here, with a clear message, rather than inside fastlane: a
# mangled multi-line secret is the likeliest setup mistake.
python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \
|| { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
echo "configured=true" >> "$GITHUB_OUTPUT"
# Same GHES-detection problem as the upload side, same fix — see the
# handoff step in the release job.
# Same GHES-detection fix as the upload side.
- name: Download the AAB
if: steps.key.outputs.configured == 'true'
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
@@ -627,12 +465,10 @@ jobs:
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update
# ruby-dev + build-essential: several of fastlane's dependencies build
# native extensions.
# Several fastlane dependencies build native extensions.
$SUDO apt-get install -y ruby-full ruby-dev build-essential
ruby -v
# Only the first release pays the full gem build; afterwards this restores.
- name: Cache bundled gems
if: steps.key.outputs.configured == 'true'
uses: actions/cache@v4
@@ -655,15 +491,12 @@ jobs:
if: steps.key.outputs.configured == 'true'
env:
SUPPLY_JSON_KEY: play-service-account.json
# supply is chatty on a TTY-less runner otherwise.
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: |
set -euo pipefail
# Absolute: a lane body runs from fastlane/, not the workspace root.
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
# Absolute, because a lane body runs from fastlane/, not the
# workspace root — a relative path resolves against the wrong
# directory there and Calendula 2.17.1 died on exactly that.
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
bundle exec fastlane deploy \
aab:"$AAB" \
@@ -672,8 +505,8 @@ jobs:
dry_run:"$PLAY_DRY_RUN"
echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
# The workspace is reused between runs on a self-hosted runner, so the
# credential must not outlive the job.
# The workspace is reused on a self-hosted runner; the key must not
# outlive the job.
- name: Shred the service-account key
if: always()
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json
+11 -2
View File
@@ -1,8 +1,17 @@
name: Renovate
on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
# same trigger release.yaml relies on), so a merged Renovate PR is followed
# within minutes by a run that rebases the sibling PRs it just conflicted —
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
# `schedule` window such a run only maintains existing branches
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
# to renovate/* branches, not main, so this cannot loop.
push:
branches: [main]
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
# in renovate.json5 — keep the two in step.
schedule:
- cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab.
+3 -7
View File
@@ -1,10 +1,6 @@
source "https://rubygems.org"
# fastlane is used ONLY to upload the release bundle to Google Play
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
# it never runs on a PR — only in release.yaml's `play` job.
#
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
# committed on purpose: this resolves an uploader's transitive deps, not the
# app's, and none of it affects the reproducible release build.
# fastlane is used ONLY as the Google Play Developer API client (see
# fastlane/Fastfile). It never builds and never signs. Pinned exactly, no
# Gemfile.lock: it resolves an uploader's deps, not the app's.
gem "fastlane", "2.237.0"
+6
View File
@@ -110,6 +110,12 @@ android {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
// Ship the prebuilt .so files (datastore's shared counter) exactly as the
// AAR has them. AGP strips them only when an NDK happens to be installed,
// so CI and F-Droid's buildserver would otherwise disagree on the bytes.
jniLibs {
keepDebugSymbols += "**/*.so"
}
}
lint {
+242 -143
View File
@@ -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
+1 -4
View File
@@ -1,7 +1,4 @@
# fastlane is used in this repo ONLY as a Google Play Developer API client
# (see fastlane/Fastfile). It never builds and never signs.
# Reconstructed in CI from the PLAY_SERVICE_ACCOUNT_JSON secret; gitignored.
# fastlane is only the Play Developer API client here (see Fastfile).
json_key_file(ENV["SUPPLY_JSON_KEY"] || "play-service-account.json")
package_name("de.jeanlucmakiola.clockula")
+49 -61
View File
@@ -1,87 +1,75 @@
# Google Play publishing only.
# Google Play: release upload and listing sync.
#
# Building and signing are deliberately NOT fastlane's job: release.yaml drives
# Gradle directly so the release build stays F-Droid-reproducible (`vcsInfo`,
# `dependenciesInfo` and the AGP metadata block are disabled in
# app/build.gradle.kts and guarded by scripts/check_reproducible_release.sh).
# Interposing fastlane there would add a layer that can inject Gradle flags into
# the one build whose byte-for-byte output is verified by a third party.
#
# What fastlane IS here for is `supply` — the Play Developer API client — because
# the store listing already lives in fastlane/metadata/android, which the
# official F-Droid repo harvests from the tagged source tree. One metadata tree,
# two stores.
# fastlane/metadata/android is the single source for both stores: the official
# F-Droid repo harvests it from the tagged tree, the self-hosted repo gets it
# through scripts/fastlane_to_fdroid_localized.sh, and this pushes it to Play.
# Building and signing stay with Gradle so the release build remains
# F-Droid-reproducible.
require "fileutils"
require "tmpdir"
default_platform(:android)
# fastlane runs a lane body with the working directory set to `fastlane/`, not
# the project root, so a relative artifact path from the caller has to be
# resolved explicitly. Absolute paths pass through untouched.
def project_path(path)
File.expand_path(path, File.expand_path("..", FastlaneCore::FastlaneFolder.path || "."))
end
ROOT = File.expand_path("..", __dir__)
METADATA = File.join(ROOT, "fastlane/metadata/android")
# Graphics are committed once, under en-US (F-Droid's fallback). Play falls back
# to its *default language*, en-US for this app; if that ever differs
# (PLAY_DEFAULT_LOCALE), the listing lane copies the graphics across.
IMAGE_LOCALE = "en-US"
platform :android do
desc "Upload an already-built, already-signed AAB to Play"
lane :deploy do |options|
aab = project_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab")
aab = File.expand_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab", ROOT)
UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab)
supply(
aab: aab,
metadata_path: METADATA,
track: options[:track] || "production",
release_status: options[:release_status] || "completed",
# The APK belongs to F-Droid and the Codeberg download; Play only ever
# gets the bundle. Never let the uploader reach for the other artifact.
# The APK belongs to F-Droid and the Codeberg download.
skip_upload_apk: true,
# Listing text is synced deliberately via the `listing` lane, not on every
# release. Overwriting a live listing by accident triggers a Play policy
# review, which is not a quick revert.
# The listing goes through the `listing` lane, deliberately; overwriting
# a live listing by accident triggers a Play review.
skip_upload_metadata: true,
# "What's New" DOES track every release.
# scripts/sync_changelog_to_fastlane.sh writes
# fastlane/metadata/android/en-US/changelogs/<versionCode>.txt from
# CHANGELOG.md, so Play shows exactly the text F-Droid does.
skip_upload_changelogs: false,
# See the `listing` lane for why the committed images can't go to Play yet.
skip_upload_images: true,
skip_upload_screenshots: true,
# Dry run: validate the edit against the API and roll it back instead of
# committing it. Used for the first end-to-end rehearsal.
# "What's New" tracks every release, from every locale's
# changelogs/<versionCode>.txt — the same files F-Droid shows.
skip_upload_changelogs: false,
validate_only: options[:dry_run].to_s == "true",
)
end
desc "Sync the store listing text (and, once the assets qualify, the images)"
desc "Push listing text and graphics from the repo to Play"
desc " dry_run:true validate against the API and discard the edit"
lane :listing do |options|
# Deliberate and manual: `bundle exec fastlane listing`. Not wired into the
# release pipeline.
#
# Images stay off by default because the committed assets do not satisfy
# Play today, even though F-Droid accepts all of them:
#
# * phoneScreenshots are 1280x2856. Play caps the long edge at twice the
# short edge (2560 here), so every screenshot is rejected.
# * those PNGs are 32-bit RGBA. Play wants 24-bit PNG, no alpha.
# * icon.png is 512x512 but 24-bit RGB. Play's icon wants 32-bit PNG.
# * there is no featureGraphic.png. Play requires one, exactly 1024x500.
#
# Until that is fixed, the Play listing's graphics are managed by hand in the
# console. Pass images:true once the assets qualify.
upload_images = options[:images].to_s == "true"
sh("python3", File.join(ROOT, "scripts/check_store_listing.py"), "--complete")
supply(
skip_upload_aab: true,
skip_upload_apk: true,
skip_upload_metadata: false,
skip_upload_changelogs: true,
skip_upload_images: !upload_images,
skip_upload_screenshots: !upload_images,
)
default_locale = ENV["PLAY_DEFAULT_LOCALE"] || IMAGE_LOCALE
Dir.mktmpdir("play-listing") do |staged|
FileUtils.cp_r("#{METADATA}/.", staged)
src = File.join(staged, IMAGE_LOCALE, "images")
dst = File.join(staged, default_locale, "images")
FileUtils.cp_r(src, dst) if default_locale != IMAGE_LOCALE && !File.exist?(dst)
supply(
metadata_path: staged,
skip_upload_aab: true,
skip_upload_apk: true,
skip_upload_metadata: false,
# "What's New" belongs to a release on a track, not to the listing.
skip_upload_changelogs: true,
skip_upload_images: false,
skip_upload_screenshots: false,
# Only re-upload graphics whose content changed.
sync_image_upload: true,
validate_only: options[:dry_run].to_s == "true",
)
end
end
end
+10 -3
View File
@@ -52,9 +52,16 @@
prConcurrentLimit: 5,
prHourlyLimit: 0,
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
// Mondays) — no internal `schedule` here, so the two don't double-gate and
// silently skip a run.
// New branches/PRs only in the Monday window (UTC), which brackets the weekly
// cron in .gitea/workflows/renovate.yml with slack for a late start — keep
// the two in step, or the sweep lands outside the window and finds nothing.
// The same workflow also runs on every push to main; those runs fall outside
// the window and, via the default updateNotScheduled: true, only maintain
// existing branches — chiefly rebasing PRs a merge just conflicted.
schedule: ["* 4-6 * * 1"],
// Rebase only on conflict, not every time main moves (automerge is off, so
// this matches "auto"; spelled out because the on-merge trigger relies on it).
rebaseWhen: "conflicted",
// Workflows are split by forge and neither directory is .github: CI lives in
// .forgejo/workflows (Codeberg) and the release/renovate jobs in
+13
View File
@@ -17,6 +17,8 @@
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
# binary scanner rejects as an extra signing block.
# 4. jniLibs { keepDebugSymbols += "**/*.so" } — else AGP strips prebuilt .so
# files only when an NDK is installed, so the bytes depend on the host.
set -euo pipefail
APP="app/build.gradle.kts"
@@ -62,6 +64,17 @@ else
fail=1
fi
# 4. Prebuilt native libs must not be stripped. AGP strips them only if an NDK is
# installed, so the output would depend on the build host.
if grep -Pzoq 'jniLibs\s*\{[^}]*keepDebugSymbols\s*\+=\s*"\*\*/\*\.so"' "$APP"; then
echo "OK: jniLibs keepDebugSymbols — native libs ship unstripped on every host."
else
echo "ERROR: '$APP' is missing 'jniLibs { keepDebugSymbols += \"**/*.so\" }'." >&2
echo " AGP strips .so files only when an NDK is present, so a from-source" >&2
echo " rebuild would not match the published APK." >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
echo >&2
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Validate fastlane/metadata/android against what BOTH stores accept.
The fastlane tree is the single source for the F-Droid listing (official repo
harvests it, the self-hosted repo gets it via fastlane_to_fdroid_localized.sh)
and for the Play listing (`bundle exec fastlane listing`). F-Droid accepts
nearly anything; Play rejects a lot at upload time. Checking against Play's
rules here is what keeps the graphics shippable to both from the same files.
scripts/check_store_listing.py validate what exists
scripts/check_store_listing.py --complete also require everything a Play
listing needs to go live
SDK- and dependency-free (PNG/JPEG headers are parsed by hand) so CI can run it
without setup.
"""
import re
import struct
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
META = ROOT / "fastlane/metadata/android"
RES = ROOT / "app/src/main/res"
# Play's limits; F-Droid truncates or recommends the same.
TEXT_LIMITS = {"title.txt": 30, "short_description.txt": 80, "full_description.txt": 4000}
CHANGELOG_LIMIT = 500
# The fallback locale of both stores: F-Droid always, Play as the app's default
# language. It carries the full text and every graphic.
REQUIRED_LOCALES = ("en-US",)
IMAGE_LOCALE = "en-US"
SCREENSHOT_DIRS = ("phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots",
"tvScreenshots", "wearScreenshots")
# Play policy on the title: no ranking, price or promotional terms.
TITLE_BANNED = re.compile(r"\b(best|top|#1|no\.? ?1|free|sale|discount|new|hot|download now)\b", re.I)
errors, warnings = [], []
def err(path, msg):
errors.append(f"{path.relative_to(ROOT)}: {msg}")
def warn(path, msg):
warnings.append(f"{path.relative_to(ROOT)}: {msg}")
def image_info(path):
"""(format, width, height, has_alpha) or None if unreadable."""
data = path.read_bytes()
if data[:8] == b"\x89PNG\r\n\x1a\n":
w, h, _depth, color = struct.unpack(">IIBB", data[16:26])
has_alpha = color in (4, 6) or b"tRNS" in data[:data.find(b"IDAT")]
return "png", w, h, has_alpha
if data[:2] == b"\xff\xd8":
i = 2
while i + 9 < len(data):
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
seg_len = struct.unpack(">H", data[i + 2:i + 4])[0]
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
h, w = struct.unpack(">HH", data[i + 5:i + 9])
return "jpeg", w, h, False
i += 2 + seg_len
return None
def check_image(path, *, size=None, alpha=None, max_mb, screenshot=False):
info = image_info(path)
if info is None:
err(path, "not a PNG or JPEG")
return
fmt, w, h, has_alpha = info
if size and (w, h) != size:
err(path, f"is {w}x{h}, Play requires exactly {size[0]}x{size[1]}")
if alpha is True and (fmt != "png" or not has_alpha):
err(path, "must be a 32-bit PNG (with alpha channel)")
if alpha is False and has_alpha:
err(path, "has an alpha channel; Play wants 24-bit PNG or JPEG")
if screenshot:
short, long_ = sorted((w, h))
if short < 320 or long_ > 3840:
err(path, f"is {w}x{h}; each side must be 320–3840 px")
if long_ > 2 * short:
err(path, f"is {w}x{h}; the long side may be at most twice the short side")
if short < 1080:
warn(path, f"is {w}x{h}; Play only features screenshots at 1080 px or more")
mb = path.stat().st_size / 1_000_000
if mb > max_mb:
err(path, f"is {mb:.1f} MB, over Play's {max_mb} MB limit")
def check_images(locale_dir):
images = locale_dir / "images"
if not images.is_dir():
return
if locale_dir.name != IMAGE_LOCALE:
warn(images, f"graphics belong in {IMAGE_LOCALE} only; both stores fall back to it")
for f in images.iterdir():
if f.is_file() and f.stem not in ("icon", "featureGraphic", "promoGraphic", "tvBanner"):
err(f, "unknown image; neither store will pick it up")
icon = images / "icon.png"
if icon.exists():
check_image(icon, size=(512, 512), alpha=True, max_mb=1)
for name, size in (("featureGraphic", (1024, 500)), ("promoGraphic", (180, 120)),
("tvBanner", (1280, 720))):
for f in images.glob(f"{name}.*"):
check_image(f, size=size, alpha=False, max_mb=15)
for d in SCREENSHOT_DIRS:
shots = sorted(p for p in (images / d).glob("*") if p.is_file())
if len(shots) > 8:
err(images / d, f"{len(shots)} screenshots; Play takes at most 8")
for s in shots:
check_image(s, alpha=False, max_mb=8, screenshot=True)
def version_code():
"""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():
langs = set()
for d in RES.glob("values-*"):
if (d / "strings.xml").exists():
m = re.fullmatch(r"values-([a-z]{2,3})(?:-r([A-Z]{2}))?", d.name)
if m:
langs.add((m.group(1), m.group(2)))
return langs
def main():
complete = "--complete" in sys.argv[1:]
current = version_code()
locales = sorted(d for d in META.iterdir() if d.is_dir())
names = {d.name for d in locales}
for loc in REQUIRED_LOCALES:
if loc not in names:
err(META / loc, "missing; this locale is a store fallback and must exist")
for d in locales:
has_text = any((d / f).exists() for f in TEXT_LIMITS)
for fname, limit in TEXT_LIMITS.items():
f = d / fname
if not f.exists():
if has_text or d.name in REQUIRED_LOCALES:
err(f, "missing (a locale with any listing text needs all three files)")
continue
text = f.read_text(encoding="utf-8").strip()
if not text:
err(f, "empty")
elif len(text) > limit:
err(f, f"{len(text)} chars, limit {limit}")
if fname == "title.txt" and TITLE_BANNED.search(text):
warn(f, "promotional term in the title; Play policy forbids these")
for f in sorted((d / "changelogs").glob("*.txt")):
n = len(f.read_text(encoding="utf-8").strip())
if f.stem == str(current) and n > CHANGELOG_LIMIT:
err(f, f"{n} chars, limit {CHANGELOG_LIMIT}; Play would reject this release")
elif n > CHANGELOG_LIMIT:
warn(f, f"{n} chars, over {CHANGELOG_LIMIT} (already published)")
check_images(d)
for lang, region in sorted(shipped_languages(), key=str):
match = f"{lang}-{region}" if region else lang
if not any(n == match or n.startswith(f"{lang}-") for n in names):
warn(META, f"app ships values-{lang}{'-r' + region if region else ''} "
"but there is no store locale for it")
if complete:
images = META / IMAGE_LOCALE / "images"
if not (images / "icon.png").exists():
err(images / "icon.png", "required for Play")
if not list(images.glob("featureGraphic.*")):
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 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")
for w in warnings:
print(f"warning: {w}")
for e in errors:
print(f"ERROR: {e}", file=sys.stderr)
if errors:
return 1
print(f"Store listing OK ({len(locales)} locales, {len(warnings)} warnings).")
return 0
if __name__ == "__main__":
sys.exit(main())
+10 -7
View File
@@ -14,8 +14,8 @@
# short_description.txt -> summary.txt
# full_description.txt -> description.txt
# title.txt -> name.txt
# images/icon.png -> icon.png
# images/phoneScreenshots/* -> phoneScreenshots/*
# images/{icon,featureGraphic,promoGraphic,tvBanner}.* -> same name
# images/<type>Screenshots/* -> <type>Screenshots/*
# changelogs/<versionCode>.txt -> changelogs/<versionCode>.txt
# (changelogs are seeded into the fastlane tree by
# scripts/sync_changelog_to_fastlane.sh.)
@@ -32,11 +32,14 @@ for locdir in "$SRC"/*/; do
[ -f "$locdir/short_description.txt" ] && cp "$locdir/short_description.txt" "$dst/summary.txt"
[ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt"
[ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt"
[ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png"
if [ -d "$locdir/images/phoneScreenshots" ]; then
mkdir -p "$dst/phoneScreenshots"
cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/"
fi
for img in "$locdir"images/{icon,featureGraphic,promoGraphic,tvBanner}.*; do
cp "$img" "$dst/"
done
for shots in "$locdir"images/*Screenshots/; do
t="$(basename "$shots")"
mkdir -p "$dst/$t"
cp "$shots"* "$dst/$t/"
done
# Per-version changelogs live in the same fastlane tree (see
# scripts/sync_changelog_to_fastlane.sh) and map straight across.
if [ -d "$locdir/changelogs" ]; then
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Publish TAG on Codeberg, which is canonical for tags and releases, with the
# signed APK and its SHA-256 checksum attached: the direct-download channel
# that Obtainium follows. Shared by .gitea/workflows/release.yaml (stable) and
# beta.yaml (pre-release). Run from the repo checkout: it pushes the tag.
#
# Codeberg push-mirrors branches and tags to Gitea, but releases aren't git
# objects and don't sync in either direction, so this pushes the tag straight
# to Codeberg and creates the release over the API. Skips cleanly when TOKEN is
# unset; fails loudly on anything else (see release.yaml for why).
#
# Env:
# TOKEN CODEBERG_RELEASE_TOKEN (write:repository); empty = skip
# API https://codeberg.org/api/v1/repos/<owner>/<repo>
# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks
# PRERELEASE true | false
# NOTES_FILE release notes (scripts/release_notes.sh)
# APK the signed release APK
set -euo pipefail
if [ -z "${TOKEN:-}" ]; then
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="clockula_${TAG}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "$WORK/$ASSET_APK"
( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the mirror runs
# Codeberg -> Gitea, so waiting for a tag to arrive from Gitea (what 0.3.2 did)
# would wait forever. The tag minted on Gitea is in fact *deleted* by the next
# mirror sync until Codeberg has it, so pushing it here is what makes it
# durable on both forges.
#
# Pushing the ref first and creating the release with NO target_commitish is
# deliberate: a release POST carrying a target_commitish for a commit or tag
# Codeberg hasn't received yet is what produced the empty-bodied 500s.
# Attaching to a ref that already exists doesn't need the API to write one.
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
# Create (or update) the release. Codeberg 500s on a POST/GET against a tag it
# has only just received (the release request outruns the indexing of the ref
# pushed a moment ago), so a single attempt can fail even though the very same
# call succeeds seconds later. Retry with backoff, and PATCH in place if a
# release already exists (re-run safe). A 5xx body still exits curl 0, so the
# loop, not `set -e`, controls the flow.
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
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases/$EXIST"
ID="$EXIST"; break
fi
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 "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' "$WORK/response.json" 2>/dev/null || true)
[ -n "$ID" ] && break
sleep $((attempt * 10))
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
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')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Create (or update) the release for TAG on the Gitea build instance, the
# changelog of record there, and attach the R8 mapping. Shared by
# .gitea/workflows/release.yaml and beta.yaml.
#
# Creating it with target_commitish makes Gitea create the tag at SHA, so the
# tag only ever marks a fully-shipped version. Upsert, so re-runs are safe.
#
# Env:
# TOKEN, API Gitea token and .../api/v1/repos/<owner>/<repo>
# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks
# PRERELEASE true | false
# NOTES_FILE release notes (scripts/release_notes.sh)
# MAPPING optional path to R8's mapping.txt
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
# PATCH if a release for the tag already exists, else POST a new one (which
# also creates the tag at target_commitish).
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"
# The R8 mapping keeps user crash stacktraces deobfuscatable. It's not an APK,
# so it fits the no-binaries rule for this release. Best-effort: never fail a
# release over it.
attach_mapping() {
local asset="mapping-${TAG#v}.txt.gz" old
gzip -c "$MAPPING" > "$WORK/$asset"
# Replace any prior asset of the same name (re-run safe).
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
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Print the forge release notes for VERSION: its `## [VERSION]` section of
# CHANGELOG.md. A beta (X.Y.Z-beta.N) normally has no section of its own, so it
# falls back to `## [Unreleased]`, i.e. what the release branch carries so far,
# under a line saying what a beta is.
#
# scripts/release_notes.sh 1.1.0 > 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
+45 -28
View File
@@ -1,41 +1,58 @@
#!/usr/bin/env bash
# Write the current version's CHANGELOG.md section into the fastlane changelog
# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/<code>.txt
# (en-US is F-Droid's fallback locale, so it covers every language).
# Ensure the current version's "What's New" exists at
# fastlane/metadata/android/en-US/changelogs/<code>.txt, which both F-Droid and
# Google Play publish.
#
# Run this when cutting a release (after editing CHANGELOG.md and bumping
# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL
# F-Droid repo — which reads the changelog from the tagged source tree — shows
# this version's "What's New". The self-hosted release pipeline also runs it so
# its changelog never depends on the file having been committed. Idempotent.
#
# Extraction matches the awk used for the Gitea release notes so all three
# (release notes, self-hosted changelog, official changelog) stay in sync.
# A COMMITTED file wins and is never rewritten: it is a hand-written summary
# under 500 characters (Play's cap), not a copy of the CHANGELOG.md section.
# Only when the file is missing is it generated from that section, so the
# self-hosted release pipeline always has something to publish. The other
# locales' changelogs are written by hand and never touched here.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
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
# Play rejects a longer "What's New" and F-Droid truncates it in-client.
MAX_CHARS=${MAX_CHARS:-500}
CL_DIR="fastlane/metadata/android/en-US/changelogs"
mkdir -p "$CL_DIR"
OUT="$CL_DIR/${VERSION_CODE}.txt"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
if [ ! -s "$OUT" ]; then
echo "See CHANGELOG.md for $VERSION." > "$OUT"
if [ -s "$OUT" ]; then
ACTION="Kept"
else
ACTION="Generated"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
[ -s "$OUT" ] || echo "See CHANGELOG.md for $VERSION." > "$OUT"
fi
CHARS=$(wc -m < "$OUT" | tr -d ' ')
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
if [ "$CHARS" -gt 500 ]; then
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
# Counted like check_store_listing.py: surrounding whitespace doesn't count.
CHARS=$(python3 -c 'import sys; print(len(open(sys.argv[1], encoding="utf-8").read().strip()))' "$OUT")
echo "$ACTION $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
# Hard limit, enforced in CI (.forgejo/workflows/ci.yaml) and before a release.
if [ "$CHARS" -gt "$MAX_CHARS" ]; then
cat >&2 <<MSG
ERROR: $OUT is $CHARS characters, over the $MAX_CHARS-character limit.
F-Droid truncates the in-client changelog, so this one would be cut off
mid-sentence and Play would reject the upload. Replace it with a hand-written
summary under $MAX_CHARS characters and commit it.
MSG
exit 1
fi
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# The one place that turns the committed versionName into a versionCode and a
# release channel. The release and beta pipelines, the changelog sync and the
# store-listing check all read it from here, so the scheme can't drift between
# them.
#
# versionName channel versionCode
# X.Y.Z-beta.N beta X*1000000 + Y*10000 + Z*100 + N (N = 1..98)
# X.Y.Z stable X*1000000 + Y*10000 + Z*100 + 99
#
# 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: 1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099.
#
# scripts/version_info.sh print version, version_code, base_version,
# channel and prerelease as key=value lines
# (ready for $GITHUB_OUTPUT)
# scripts/version_info.sh <key> print just that value
# scripts/version_info.sh --check also fail unless the committed versionCode
# matches; the official F-Droid repo builds
# the tag as committed, so it must
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-9]+)\.([0-9]+)\.([0-9]+)(-beta\.([0-9]+))?$ ]]; then
echo "versionName '$NAME' is neither X.Y.Z nor X.Y.Z-beta.N" >&2
exit 1
fi
MAJOR=$((10#${BASH_REMATCH[1]})); MINOR=$((10#${BASH_REMATCH[2]})); PATCH=$((10#${BASH_REMATCH[3]}))
BETA=${BASH_REMATCH[5]:+$((10#${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
if [ -n "$BETA" ]; then
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 ))
else
CHANNEL=stable
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + 99 ))
fi
# Betas are always pre-releases; so was everything before 1.0.0.
if [ "$CHANNEL" = beta ] || [ "$MAJOR" = 0 ]; 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)."
;;
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