Test the three bugs that reached the living room, and script the release

The rate limit, the version compare and the download check were all wrapped
in Android — a Context, a socket, a file — so none of them had a test, and
all three shipped broken: the remote thrown out after five reconnects
(0.11.1), a truncated APK handed to the installer (0.11.5), and a version
compare that would read 0.11.10 as older than 0.11.9 the moment we get
there.

They are now plain Kotlin in AttemptBudget and UpdateRules, with the clock
injected, and 20 tests covering the failures themselves. AttemptBudget also
prunes expired entries before evicting: dropping only empty queues let the
map grow without bound while every tracked address held one fresh failure.

tests/smoke.sh installs the debug APK on the headless emulator, walks the
first screen with the D-pad and fails on a crash or ANR — the class of bug
unit tests cannot see, and the reason 0.11.0 crashed on startup. Forty
seconds end to end.

tools/release.sh does the bump, tests, signed build, tag, apk branch and
Gitea release in one command. It refuses an unsigned build and never writes
the release notes itself; commit subjects are offered as a draft.

tests/helpers/jdk.sh picks a JDK Gradle can run on: Ubuntu moved
default-java to 25, which fails the build with a bare
"IllegalArgumentException: 25.0.4".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 14:12:51 +02:00
parent f1a6600e42
commit b52ef79c01
9 changed files with 632 additions and 55 deletions

171
tools/release.sh Executable file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# Cuts a release: version bump, tests, signed build, tag, apk branch, Gitea
# release. Every step was done by hand before, fifteen times in one day.
#
# tools/release.sh 0.11.6 --notes notes.md
# tools/release.sh 0.11.6 --dry-run # show what would happen
# tools/release.sh 0.11.6 --no-smoke # skip the emulator run
#
# Release notes are never generated: the commit subjects since the last tag
# are only a starting point, written to a file for you to edit. Pass --notes
# to supply them directly.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
REMOTE="${CASTARR_REMOTE:-gitea}"
REPO="${CASTARR_REPO:-be-nj/castarr}"
API="${CASTARR_API:-https://git.beckm4nn.net/api/v1}"
SIGNING_ENV="${CASTARR_SIGNING_ENV:-$HOME/.keys/castarr-release.env}"
GRADLE="./gradlew --no-daemon -q"
VERSION=""
NOTES_FILE=""
DRY=0
SMOKE=1
while [ $# -gt 0 ]; do
case "$1" in
--notes) NOTES_FILE="$2"; shift 2 ;;
--dry-run) DRY=1; shift ;;
--no-smoke) SMOKE=0; shift ;;
-*) echo "unbekannte Option: $1" >&2; exit 2 ;;
*) VERSION="$1"; shift ;;
esac
done
[ -n "$VERSION" ] || { echo "usage: $0 <version> [--notes <file>] [--dry-run] [--no-smoke]" >&2; exit 2; }
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Version muss X.Y.Z sein, nicht '$VERSION'" >&2; exit 2; }
say() { printf '\n== %s\n' "$1"; }
run() { if [ "$DRY" = 1 ]; then printf ' would run: %s\n' "$*"; else "$@"; fi; }
die() { printf 'ABBRUCH: %s\n' "$1" >&2; exit 1; }
# --- Vorbedingungen ---------------------------------------------------------
say "Prüfen"
[ -z "$(git status --porcelain)" ] || die "Arbeitsbaum nicht sauber"
[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || die "nicht auf main"
git fetch -q "$REMOTE" main
[ "$(git rev-parse HEAD)" = "$(git rev-parse "$REMOTE/main")" ] ||
die "main weicht von $REMOTE/main ab — erst pushen oder pullen"
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null &&
die "Tag v$VERSION existiert schon"
CURRENT="$(sed -nE 's/.*versionName = "(.*)".*/\1/p' app/build.gradle.kts)"
CODE="$(sed -nE 's/.*versionCode = ([0-9]+).*/\1/p' app/build.gradle.kts)"
[ -n "$CURRENT" ] && [ -n "$CODE" ] || die "Version aus app/build.gradle.kts nicht lesbar"
NEXT_CODE=$((CODE + 1))
echo " $CURRENT (code $CODE) -> $VERSION (code $NEXT_CODE)"
# shellcheck source=tests/helpers/jdk.sh
. tests/helpers/jdk.sh
echo " JDK: $(basename "${JAVA_HOME:-system}")"
# --- Notizen ----------------------------------------------------------------
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
if [ -z "$NOTES_FILE" ]; then
NOTES_FILE="$(mktemp -t castarr-notes-XXXX.md)"
{
echo "# Notizen für $VERSION — diese Zeilen sind ein Entwurf, keine Release-Notes."
echo "#"
[ -n "$LAST_TAG" ] && echo "# Commits seit $LAST_TAG:" || echo "# Commits:"
if [ -n "$LAST_TAG" ]; then
git log --format='# %s' "$LAST_TAG..HEAD"
else
git log --format='# %s' -10
fi
} > "$NOTES_FILE"
if [ -n "${EDITOR:-}" ] && [ -t 0 ]; then
"$EDITOR" "$NOTES_FILE"
else
say "Notizen"
echo " Entwurf liegt in $NOTES_FILE."
echo " Schreib die Notes dort hinein und ruf erneut auf:"
echo " $0 $VERSION --notes $NOTES_FILE"
exit 0
fi
fi
[ -f "$NOTES_FILE" ] || die "Notes-Datei fehlt: $NOTES_FILE"
NOTES="$(grep -v '^#' "$NOTES_FILE" | sed -e '/./,$!d')"
[ -n "${NOTES//[[:space:]]/}" ] || die "Notes sind leer"
# --- Tests ------------------------------------------------------------------
say "Unit-Tests"
run $GRADLE testDebugUnitTest
if [ "$SMOKE" = 1 ]; then
say "Smoke-Test auf dem Emulator"
run tests/smoke.sh
fi
# --- Version bumpen ---------------------------------------------------------
say "Version setzen"
if [ "$DRY" = 0 ]; then
sed -i -E "s/versionCode = $CODE/versionCode = $NEXT_CODE/; s/versionName = \"$CURRENT\"/versionName = \"$VERSION\"/" \
app/build.gradle.kts
grep -q "versionName = \"$VERSION\"" app/build.gradle.kts || die "Bump hat nicht gegriffen"
else
echo " would set versionCode=$NEXT_CODE versionName=$VERSION"
fi
# --- Signierter Build -------------------------------------------------------
say "Release-APK bauen"
if [ -f "$SIGNING_ENV" ]; then
# Signing secrets stay in the file; only this shell sees them.
set -a; . "$SIGNING_ENV"; set +a
echo " signiert (Konfiguration aus $SIGNING_ENV)"
else
echo " WARNUNG: $SIGNING_ENV fehlt — der Build wäre unsigniert"
[ "$DRY" = 1 ] || die "ohne Signatur kein Release (Updater lehnt Signaturwechsel ab)"
fi
run $GRADLE assembleRelease
APK="app/build/outputs/apk/release/app-release.apk"
[ "$DRY" = 1 ] || [ -f "$APK" ] || die "APK nicht gebaut: $APK"
# --- Commit, Tag, Push ------------------------------------------------------
say "Commit und Tag"
run git add app/build.gradle.kts
run git commit -q -m "Release $VERSION"
run git tag "v$VERSION"
run git push -q "$REMOTE" main "v$VERSION"
# --- APK-Branch -------------------------------------------------------------
say "APK auf den Branch apk"
if [ "$DRY" = 0 ]; then
git fetch -q "$REMOTE" apk
WORKTREE="$(mktemp -d -t castarr-apk-XXXX)"
trap 'git worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
git worktree add -q -B apk "$WORKTREE" "$REMOTE/apk"
cp "$APK" "$WORKTREE/castarr.apk"
echo "$VERSION" > "$WORKTREE/VERSION"
git -C "$WORKTREE" add castarr.apk VERSION
git -C "$WORKTREE" commit -q -m "castarr $VERSION"
git -C "$WORKTREE" push -q "$REMOTE" apk
git worktree remove --force "$WORKTREE"
trap - EXIT
else
echo " would push $APK as castarr.apk"
fi
# --- Gitea-Release ----------------------------------------------------------
say "Gitea-Release"
TOKEN="${GITEA_TOKEN:-${CASTARR_GITEA_TOKEN:-}}"
if [ -z "$TOKEN" ]; then
echo " Kein GITEA_TOKEN gesetzt — Release bitte in der Weboberfläche anlegen:"
echo " ${API%/api/v1}/$REPO/releases/new?tag=v$VERSION"
echo " Notes liegen in $NOTES_FILE"
elif [ "$DRY" = 1 ]; then
echo " would create release v$VERSION"
else
BODY="$(NOTES="$NOTES" python3 -c 'import json,os; print(json.dumps({
"tag_name": "v" + os.environ["V"], "name": os.environ["V"],
"body": os.environ["NOTES"], "target_commitish": "main"}))' V="$VERSION")"
curl -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" > /dev/null || die "Release-API fehlgeschlagen"
echo " angelegt: ${API%/api/v1}/$REPO/releases/tag/v$VERSION"
fi
say "Fertig: $VERSION"
echo " Die App findet das Update über die Release-API, die APK über den Branch apk."