Compare commits

...

6 Commits

Author SHA1 Message Date
9fb3c780f1 Merge pull request 'build,docs: on-device release verification gate (releaseTest build)' (#20) from chore/release-verification-process into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #20
2026-06-21 11:01:29 +00:00
8a80478555 docs(architecture): describe merge-driven release flow
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m14s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:33:55 +02:00
df203c0b6b ci: release on merge-to-main; consolidate pipeline triggers
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m55s
Reworks the pipeline so a change is built once and a release is driven by the
merge, not a manual tag push.

- ci.yaml now runs on pull_request (one gate per PR) instead of every branch
  push, so there's no CI-on-push + CI-on-merge double run. A single `ci` job
  with a docs-only fast-path keeps the required "CI" check always reporting
  (docs/metadata-only PRs skip the Android build but still go green).
- release.yaml triggers on push to main. A cheap `detect` job reads versionName
  from build.gradle; only when no tag for it exists does the `release` job run:
  tests on the merged commit, build + sign, publish to F-Droid, then create the
  vX.Y.Z tag + Gitea release via the API (target_commitish = the merged sha).
  The tag is now an OUTPUT of a successful release, not its trigger — a failure
  before publish leaves no tag, so re-running safely retries. No more separate
  tag-triggered run or duplicate ci job.
- The committed versionName/versionCode are now the source of truth (pipeline
  pins versionCode from versionName); updated the build.gradle comment.
- translations.yaml switched to pull_request (same path filter).
- docs/RELEASING.md: release-by-merge flow, no manual git tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:32:56 +02:00
0c95479051 build,docs: add on-device release verification gate
Adds a mandatory pre-tag step to the release process: build the R8-shrunk
release candidate and smoke-test it on a real device, including a first-run /
permission-not-granted state. The v2.7.0 launch crash (calendar observer
registered before the permission gate) reached users because it only manifests
in the minified release build on a device without the permission already
granted — the debug build and an already-permissioned phone both hid it.

- New `releaseTest` build type: same R8 shrinking + obfuscation as `release`,
  but debug-signed with a `.releasetest` applicationId suffix so it installs
  alongside the production and debug apps. Never published; CI only ever builds
  the real `release` variant from the tag.
- scripts/verify-release.sh: builds + installs `releaseTest` and resets it to a
  first-run state, with an on-device checklist.
- docs/RELEASING.md: formalize the release/vX.Y.Z branch flow and the on-device
  verification gate before tagging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:27:30 +02:00
297a2350e3 Merge pull request 'fix(calendar): don't register calendar observer before permission granted' (#19) from fix/calendar-observer-permission-crash into main
All checks were successful
CI / ci (push) Successful in 4m8s
Release — F-Droid repo + Gitea release / ci (push) Successful in 2m29s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 5m43s
Reviewed-on: #19
2026-06-21 09:59:32 +00:00
997ee44792 fix(calendar): don't register calendar observer before permission granted
All checks were successful
CI / ci (push) Successful in 4m15s
The repository registers a ContentObserver on the calendar provider eagerly
in its init block, and an activity-scoped SettingsViewModel (which drives the
theme) injects that repository — so the @Singleton is constructed at launch,
above RootScreen's permission gate. On newer Android, registering an observer
on a provider you lack permission for throws SecurityException instead of
silently no-op'ing, so the app crashed instantly on every launch whenever
calendar access wasn't granted (fresh install or revoked permission), before
the permission screen could ever appear.

Guard the registration behind a calendar-permission check and re-attach the
observer lazily on the first calendars()/instances() read, which runs once the
gate opens and screens subscribe. Access to the observer collections is now
synchronized since registration can happen on the main thread (repo init) or
the IO dispatcher (query re-attach).

Verified on-device: permission-denied launch shows the permission screen
instead of crashing; granting it proceeds to the calendar with live updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:52:28 +02:00
9 changed files with 370 additions and 224 deletions

View File

@@ -1,18 +1,23 @@
name: CI name: CI
# One gate per pull request. Branch pushes no longer trigger CI on their own,
# so a change is built once on its PR (covering feature -> release/* and
# release/* -> main) instead of once per push and again on the merge to main.
# The merge itself is handled by release.yaml, which only does heavy work when
# the merge actually cuts a release.
on: on:
push: pull_request:
branches:
- '**'
tags-ignore:
- '**'
# Cancel superseded runs on the same branch. # Cancel superseded runs for the same PR.
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# Single job named `ci` so the required "CI" status check is always reported,
# even for docs-only PRs: those just skip the Android build and the job still
# succeeds (fast green check) instead of being filtered out and leaving the
# required check pending forever.
ci: ci:
runs-on: docker runs-on: docker
env: env:
@@ -21,14 +26,37 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
# Full history so the base..HEAD diff below has a merge-base.
fetch-depth: 0
# Decide whether anything that affects the app build changed. Docs,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
run: |
set -e
BASE="${{ github.base_ref }}"
git fetch --no-tags --depth=1 origin "$BASE"
CHANGED=$(git diff --name-only "origin/$BASE...HEAD")
echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^LICENSE$)' | grep -q .; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
fi
- name: Setup Java - name: Setup Java
if: steps.scope.outputs.code == 'true'
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
- name: Setup Android SDK - name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with: with:
# Default ("tools platform-tools") drags in the Android Emulator # Default ("tools platform-tools") drags in the Android Emulator
@@ -36,12 +64,14 @@ jobs:
packages: '' packages: ''
- name: Setup Android SDK cache - name: Setup Android SDK cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: /opt/android-sdk path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0 key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages - name: Install Android SDK packages
if: steps.scope.outputs.code == 'true'
run: | run: |
yes | sdkmanager --licenses >/dev/null || true yes | sdkmanager --licenses >/dev/null || true
sdkmanager \ sdkmanager \
@@ -50,6 +80,7 @@ jobs:
"build-tools;36.0.0" "build-tools;36.0.0"
- name: Setup Gradle cache - name: Setup Gradle cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
@@ -60,21 +91,25 @@ jobs:
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
if: steps.scope.outputs.code == 'true'
run: chmod +x ./gradlew run: chmod +x ./gradlew
# No --no-daemon: the daemon lives only as long as this job container # No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration. # and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only) - name: Lint (debug variant only)
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug run: ./gradlew lintDebug
- name: Unit tests - name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest run: ./gradlew testDebugUnitTest
- name: Assemble debug APK - name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
- name: Trivy filesystem scan - name: Trivy filesystem scan
if: github.ref == 'refs/heads/main' if: steps.scope.outputs.code == 'true'
run: | run: |
set -e set -e
SUDO="" SUDO=""

View File

@@ -1,75 +1,84 @@
name: Release — F-Droid repo + Gitea release name: Release — F-Droid repo + Gitea release
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
# merges (no version bump) fall through `detect` and do nothing.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
# without building an APK or creating a release. Used for key rotation / repo
# recovery.
on: on:
push: push:
tags: branches: [main]
- '*'
workflow_dispatch: workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
jobs: jobs:
ci: # Cheap gate: resolve the version from the committed build.gradle and decide
# whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main.
detect:
runs-on: docker runs-on: docker
env: outputs:
ANDROID_HOME: /opt/android-sdk is_release: ${{ steps.v.outputs.is_release }}
ANDROID_SDK_ROOT: /opt/android-sdk version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Java - name: Resolve version and whether it is a new release
uses: actions/setup-java@v4 id: v
with: env:
distribution: 'zulu' TOKEN: ${{ secrets.GITHUB_TOKEN }}
java-version: '17' API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
- 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: | run: |
yes | sdkmanager --licenses >/dev/null || true set -e
sdkmanager \ VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
"platform-tools" \ if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
"platforms;android-37.0" \ MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
"build-tools;36.0.0" 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"
echo "Resolved version $VERSION (code $VERSION_CODE)"
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
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
if [ "$STATUS" = "200" ]; then
echo "Tag v$VERSION already exists — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
else
echo "No tag for v$VERSION yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Gradle cache # Releases: build + sign + publish, then mint the tag and Gitea release.
uses: actions/cache@v4 # Also runs on manual dispatch, where it skips the build and just re-signs and
with: # re-uploads the existing index (recovery path).
path: | release:
~/.gradle/caches needs: detect
~/.gradle/wrapper if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# Lint already enforced on every push to main via ci.yaml.
# Release sanity only re-runs tests + a debug build to catch
# any tag-resolved drift (e.g. version code substitution issues).
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK (sanity)
run: ./gradlew assembleDebug
build-and-deploy:
needs: ci
runs-on: docker runs-on: docker
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -121,31 +130,26 @@ jobs:
$SUDO apk add --no-cache jq $SUDO apk add --no-cache jq
fi fi
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch, - name: Grant execute permission for gradlew
# not a tag) these are skipped: the job then just re-signs the existing run: chmod +x ./gradlew
# index with the configured repo key and re-uploads — used for key
# rotation / repo recovery without publishing a new APK. # The committed versionName is the source of truth. Pin versionCode to the
- name: Set version from git tag # value derived from it so the published APK's code is always
if: startsWith(github.ref, 'refs/tags/') # MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: | run: |
set -e set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${RAW_TAG#v}"
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, VersionCode: $VERSION_CODE"
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts grep -E 'versionName|versionCode' app/build.gradle.kts
# Export for later steps (F-Droid changelog, mapping asset name).
echo "VERSION=$VERSION" >> "$GITHUB_ENV" # Test the exact commit being shipped (only on a real release).
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV" - name: Unit tests
if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest
- name: Setup Android Keystore - name: Setup Android Keystore
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
env: env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -160,11 +164,8 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF EOF
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build release APK - name: Build release APK
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools - name: Setup F-Droid Server Tools
@@ -202,8 +203,7 @@ jobs:
set -euo pipefail set -euo pipefail
# Fail loudly if the repo key is not configured. NEVER auto-generate # Fail loudly if the repo key is not configured. NEVER auto-generate
# one: a fresh key changes the repo fingerprint and breaks every # one: a fresh key changes the repo fingerprint and breaks every
# user's pinned repo. (Replaces the old `fdroid update --create-key` # user's pinned repo.
# path, which silently rotated the key on a wiped server.)
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2 echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
echo "Refusing to continue — will not auto-generate a new repo key." >&2 echo "Refusing to continue — will not auto-generate a new repo key." >&2
@@ -216,28 +216,23 @@ jobs:
mkdir -p fdroid/repo/icons mkdir -p fdroid/repo/icons
- name: Copy new APK to repo - name: Copy new APK to repo
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
run: | run: |
set -e set -e
mkdir -p fdroid/repo mkdir -p fdroid/repo
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
if [ -z "$SAFE_REF_NAME" ]; then
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
fi
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_${SAFE_REF_NAME}.apk"
- name: Copy metadata to F-Droid repo - name: Copy metadata to F-Droid repo
run: | run: |
mkdir -p fdroid/metadata mkdir -p fdroid/metadata
cp -r fdroid-metadata/* fdroid/metadata/ cp -r fdroid-metadata/* fdroid/metadata/
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG # Per-version "What's New" for F-Droid clients: this version's CHANGELOG
# section written to changelogs/<versionCode>.txt (same extraction as the # section written to changelogs/<versionCode>.txt (same extraction as the
# Gitea release notes). en-US only — F-Droid falls back to it for locales # Gitea release notes). en-US only — F-Droid falls back to it for locales
# without their own changelog. fdroid update bakes this into the index. # without their own changelog. fdroid update bakes this into the index.
- name: Generate F-Droid changelog for this version - name: Generate F-Droid changelog for this version
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
run: | run: |
set -e set -e
awk -v ver="$VERSION" ' awk -v ver="$VERSION" '
@@ -272,97 +267,46 @@ jobs:
SFTP SFTP
# Publish the signed repo/ plus metadata/ (descriptions, screenshots, # Publish the signed repo/ plus metadata/ (descriptions, screenshots,
# per-version changelogs) so changelog history survives across # per-version changelogs) so changelog history survives across
# releases. keystore.p12 and config.yml are NEVER uploaded, so they # releases. keystore.p12 and config.yml are NEVER uploaded.
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/" sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable. # The APK is published and the index re-signed — now record the release.
# Attached to the Gitea release (it's not an APK, so it fits the # Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# no-binaries rule). Best-effort: never fail a release over it. # this commit, so the tag only ever marks a fully-shipped release (and a
- name: Attach R8 mapping to Gitea release # failure before here leaves no tag, so re-running the workflow retries).
if: startsWith(github.ref, 'refs/tags/') - name: Create tag + Gitea release
continue-on-error: true if: env.IS_RELEASE == 'true'
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: | run: |
set -e set -e
MAP="app/build/outputs/mapping/release/mapping.txt" TAG="v$VERSION"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi # Notes = this version's CHANGELOG section.
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
# The release is created by the gitea-release job; ensure it exists
# (idempotent) so this job doesn't race it to a 404.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
"$API/releases" | jq -r '.id // empty')
fi
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"
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
# notes. Deliberately no APK assets — distribution stays with the F-Droid
# repo; the release is the human-readable record. Gated on the tests-only
# ci job (not the deploy) so notes appear even if the F-Droid upload has
# an infrastructure hiccup.
gitea-release:
needs: ci
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract changelog section for this tag
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${TAG#v}"
# Everything between "## [<version>]" and the next "## [" heading.
awk -v ver="$VERSION" ' awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next } $0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 } /^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md flag' CHANGELOG.md > release-notes.md
# Trim leading blank lines.
sed -i -e '/./,$!d' release-notes.md sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi fi
echo "--- release notes ---" python3 - "$TAG" "$SHA" <<'PY' > payload.json
cat release-notes.md
- name: Create Gitea release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
python3 - "$TAG" <<'PY' > payload.json
import json, sys import json, sys
print(json.dumps({ print(json.dumps({
"tag_name": sys.argv[1], "tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1], "name": sys.argv[1],
"body": open("release-notes.md").read(), "body": open("release-notes.md").read(),
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
})) }))
PY PY
# Upsert: the build-and-deploy job may have created a bare release # Upsert (re-run safe): PATCH if a release for the tag already exists,
# first (to attach the mapping asset), so PATCH the notes if it # else POST a new one (which also creates the tag at target_commitish).
# exists, otherwise POST a new one. Both paths are re-run safe.
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true) ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \ CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
@@ -376,6 +320,33 @@ jobs:
fi fi
cat response.json cat response.json
if [ "$CODE" != "$OK" ]; then if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1 exit 1
fi 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"

View File

@@ -4,11 +4,7 @@ name: Translations
# only touch values-*/strings.xml) get quick feedback without the full Android # only touch values-*/strings.xml) get quick feedback without the full Android
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation). # build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
on: on:
push: pull_request:
branches:
- '**'
tags-ignore:
- '**'
paths: paths:
- 'app/src/main/res/values*/strings.xml' - 'app/src/main/res/values*/strings.xml'
- 'app/src/main/res/xml/locales_config.xml' - 'app/src/main/res/xml/locales_config.xml'

View File

@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.7.1] — 2026-06-21
### Fixed
- Fixed the app crashing immediately on launch whenever calendar access hadn't
been granted yet (a fresh install, or after revoking the permission). The app
set up its live calendar-change listener before the permission screen could
appear, which newer Android versions reject outright — so the app died before
you could grant access. The listener now waits for the permission and attaches
itself the moment it's granted.
## [2.7.0] — 2026-06-18 ## [2.7.0] — 2026-06-18
### Added ### Added

View File

@@ -23,13 +23,13 @@ android {
applicationId = "de.jeanlucmakiola.calendula" applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
// The git tag is the single source of truth for released builds: at // These committed values ARE the source of truth for a release: merging
// release time .gitea/workflows/release.yaml derives both fields from // a bumped versionName into main triggers .gitea/workflows/release.yaml,
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // which builds this version and then creates the matching vX.Y.Z tag +
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// default; keep them matching the latest released tag. See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.1 -> 20701). See docs/RELEASING.md.
versionCode = 20700 versionCode = 20701
versionName = "2.7.0" versionName = "2.7.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -61,6 +61,21 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
isMinifyEnabled = false isMinifyEnabled = false
} }
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
// and resource shrinking, but debug-signed and given its own applicationId
// suffix so it installs alongside both the production app (signed with the
// real key) and the debug build. Used to smoke-test a release candidate on
// a real device before tagging — R8-only breakage and first-run/permission
// states don't surface in the unminified debug build, nor on a device that
// already holds the permission. Never published. See docs/RELEASING.md.
create("releaseTest") {
initWith(getByName("release"))
applicationIdSuffix = ".releasetest"
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
matchingFallbacks += "release"
}
} }
compileOptions { compileOptions {

View File

@@ -1,9 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
@@ -11,6 +13,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.CalendarContract import android.provider.CalendarContract
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
@@ -162,14 +165,52 @@ class AndroidCalendarDataSource @Inject constructor(
) : CalendarDataSource { ) : CalendarDataSource {
private val resolver: ContentResolver get() = context.contentResolver private val resolver: ContentResolver get() = context.contentResolver
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
override fun calendars(): List<CalendarSource> = resolver.query( // All access to these two collections is guarded by [observerLock] because
// listeners are registered on the main thread (repository init, via ViewModel
// creation) while [ensureObserversRegistered] runs on the IO dispatcher.
private val observerLock = Any()
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
private val registeredObservers = mutableSetOf<ContentObserver>()
private fun hasCalendarPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
/**
* Attach any not-yet-registered observers to the provider, but only once the
* calendar permission is held. Registering a ContentObserver on the calendar
* provider without that permission throws SecurityException on newer Android
* (it used to silently no-op), which crashed the app at launch — the repo
* registers its observer eagerly, before the permission gate. Called from the
* observed reads ([calendars]/[instances]) so the observer re-attaches the
* first time a screen queries after the permission is granted.
*/
private fun ensureObserversRegistered() {
if (!hasCalendarPermission()) return
synchronized(observerLock) {
if (registeredObservers.size == observers.size) return
observers.values.forEach(::registerObserverLocked)
}
}
private fun registerObserverLocked(obs: ContentObserver) {
if (obs in registeredObservers || !hasCalendarPermission()) return
runCatching {
resolver.registerContentObserver(CalendarContract.CONTENT_URI, true, obs)
}.onSuccess { registeredObservers += obs }
.onFailure { Log.w(TAG, "Calendar observer registration skipped", it) }
}
override fun calendars(): List<CalendarSource> {
ensureObserversRegistered()
return resolver.query(
CalendarContract.Calendars.CONTENT_URI, CalendarContract.Calendars.CONTENT_URI,
CalendarProjection.COLUMNS, CalendarProjection.COLUMNS,
null, null, null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList() )?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
}
/** /**
* Calendar-row writes must address the provider as a sync adapter and name * Calendar-row writes must address the provider as a sync adapter and name
@@ -242,6 +283,7 @@ class AndroidCalendarDataSource @Inject constructor(
} }
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis) ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis) ContentUris.appendId(this, endMillis)
@@ -718,16 +760,20 @@ class AndroidCalendarDataSource @Inject constructor(
listener() listener()
} }
} }
synchronized(observerLock) {
observers[listener] = obs observers[listener] = obs
resolver.registerContentObserver( // Attach now if we already hold the permission; otherwise it stays
CalendarContract.CONTENT_URI, // pending and re-attaches on the first read after the grant.
/* notifyForDescendants = */ true, registerObserverLocked(obs)
obs, }
)
} }
override fun unregisterChangeListener(listener: () -> Unit) { override fun unregisterChangeListener(listener: () -> Unit) {
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) } synchronized(observerLock) {
observers.remove(listener)?.let { obs ->
if (registeredObservers.remove(obs)) resolver.unregisterContentObserver(obs)
}
}
} }
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query( private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(

View File

@@ -143,5 +143,6 @@ JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
`CalendarDataSource` is faked (`FakeCalendarDataSource` records writes), `CalendarDataSource` is faked (`FakeCalendarDataSource` records writes),
mappers parse `ColumnReader`/plain maps instead of cursors, domain logic mappers parse `ColumnReader`/plain maps instead of cursors, domain logic
(recurrence, validation, snapshots, write-value building) is pure. CI (recurrence, validation, snapshots, write-value building) is pure. CI
(Gitea Actions) runs `lint test assembleDebug` on every push; release tags (Gitea Actions) runs `lint test assembleDebug` once per pull request; merging a
additionally build, sign, and publish to the self-hosted F-Droid repo. bumped `versionName` to `main` builds, signs, and publishes to the self-hosted
F-Droid repo and then mints the `vX.Y.Z` tag + release. See docs/RELEASING.md.

View File

@@ -1,61 +1,90 @@
# Releasing Calendula # Releasing Calendula
Calendula is distributed through a self-hosted F-Droid repository. Every Calendula is distributed through a self-hosted F-Droid repository. A release is
release is built, signed, and published automatically by built, signed, and published automatically by `.gitea/workflows/release.yaml`
`.gitea/workflows/release.yaml` when a version tag is pushed. when a **bumped `versionName` reaches `main`** — the pipeline then creates the
matching `vX.Y.Z` tag and Gitea release itself.
## Versioning — the git tag is the single source of truth ## Versioning — the committed version is the source of truth
A release is defined by its tag, `vMAJOR.MINOR.PATCH` (e.g. `v2.1.0`). At A release is defined by the `versionName`/`versionCode` committed in
release time the workflow derives both Gradle fields from the tag: `app/build.gradle.kts`:
- `versionName` = the tag without the leading `v` (`2.1.0`) - `versionName` = `MAJOR.MINOR.PATCH` (e.g. `2.1.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0``20100`) - `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0``20100`)
So `MINOR` and `PATCH` each have room for 099. The values committed in So `MINOR` and `PATCH` each have room for 099. The release pipeline reads
`app/build.gradle.kts` are only the dev/local default — CI overwrites them `versionName`, pins `versionCode` to the derived value, builds, and — once the
from the tag. Keep the committed `versionCode`/`versionName` matching the APK is published — creates the tag `v<versionName>` at that commit. The tag is
**latest released tag** so local builds are sanely versioned; the published an **output** of a successful release, not its trigger, so a tag always marks a
value always comes from the tag. fully-shipped version (and a failure before publish leaves no tag, so re-running
the workflow safely retries).
Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000. Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000.
## Cutting a release ## Cutting a release
1. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new 1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
feature/fix branches that make up this release into it. This branch is the
release candidate — everything below happens on it, before it reaches `main`.
2. Move the `## [Unreleased]` section of `CHANGELOG.md` under a new
`## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between `## [X.Y.Z] — <date>` heading (Keep a Changelog format). The text between
that heading and the next `## [` becomes both the Gitea release notes and that heading and the next `## [` becomes both the Gitea release notes and
the F-Droid per-version changelog. the F-Droid per-version changelog.
2. Optionally bump the committed `versionCode`/`versionName` in 3. Bump the committed `versionName` (and `versionCode`) in
`app/build.gradle.kts` to match the new version (keeps local builds tidy). `app/build.gradle.kts` to the new version. **This bump is what triggers the
3. Commit, then tag and push: release** when the branch merges to `main`.
4. **Verify the release build on a real device** — the mandatory gate. The
shipped APK is R8-shrunk/obfuscated, and bugs that only appear there, or
only on first run, never show up in the debug build or on a device that
already granted the calendar permission (this is how the v2.7.0 launch
crash slipped through). Run:
```bash ```bash
git tag vX.Y.Z scripts/verify-release.sh
git push origin vX.Y.Z
``` ```
4. The push triggers the release workflow. **Hold UI releases for on-device It builds the `releaseTest` variant (same R8 config as `release`, debug-signed
review and explicit go-ahead before tagging.** 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 access — the calendar must load;
- add both home-screen **widgets** and confirm they render;
- 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. **Hold UI
releases for on-device review and explicit go-ahead before merging.**
> The `releaseTest` build type exists only for step 4 — it is never published.
> The pipeline always builds and signs the real `release` variant.
## What the pipeline does ## What the pipeline does
`release.yaml` has three jobs: 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** — unit tests + a debug assemble (sanity). - **`ci.yaml`** (on `pull_request`) — lint + unit tests + a debug assemble (and
- **build-and-deploy** — derives the version, builds & signs the release APK a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but
with the app key, copies it into the F-Droid repo, generates the per-version still report a green `CI` check.
changelog, re-signs the F-Droid index with the **repo key**, uploads - **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
`repo/` + `metadata/` to the box, and attaches the R8 `mapping.txt` to the `detect` job reads `versionName` and checks whether a tag for it already
Gitea release (best-effort). exists. Only when it doesn't does the `release` job run: unit tests on the
- **gitea-release** — creates/updates the Gitea release carrying the tag's merged commit, build & sign the release APK with the **app key**, copy it into
CHANGELOG section as notes. Gated on `ci` only (not the deploy) so notes the F-Droid repo, generate the per-version changelog, re-sign the index with
publish even if the F-Droid upload hiccups. the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z` tag +
Gitea release (CHANGELOG section as notes) and attach the R8 `mapping.txt`
(best-effort). Ordinary merges with no version bump fall through `detect` and
do nothing.
### Manual re-sign / recovery ### Manual re-sign / recovery
A manual `workflow_dispatch` of the release workflow **from a branch** (not a A manual `workflow_dispatch` of the release workflow runs a **re-sign-only**
tag) runs a **re-sign-only** path: it skips the APK build and just re-signs path: `detect` reports it's not a release, so the `release` job skips the APK
the existing F-Droid index with the configured repo key and re-uploads. Use build, the version bump, and tag/release creation, and just re-signs the
this for key rotation or repo recovery without publishing a new app version. existing F-Droid index with the configured repo key and re-uploads. Use this
for key rotation or repo recovery without publishing a new app version.
## Secrets (Gitea → repo Settings → Actions → Secrets) ## Secrets (Gitea → repo Settings → Actions → Secrets)

43
scripts/verify-release.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Build the release-candidate APK and install it on a connected device for the
# mandatory pre-tag on-device check (see docs/RELEASING.md).
#
# It builds the `releaseTest` variant: the same R8 shrinking + obfuscation and
# resource shrinking as the published `release` build, but debug-signed and
# with a `.releasetest` applicationId suffix so it installs alongside the
# production and debug apps. This is what surfaces release-only breakage (R8
# stripping) and first-run states (permission not yet granted) that the
# unminified debug build — or a device that already holds the permission —
# silently hides.
#
# Usage: scripts/verify-release.sh
set -euo pipefail
cd "$(dirname "$0")/.."
PKG="de.jeanlucmakiola.calendula.releasetest"
APK="app/build/outputs/apk/releaseTest/app-releaseTest.apk"
echo "==> Building release-candidate APK (releaseTest, R8 minified)…"
./gradlew :app:assembleReleaseTest
echo "==> Installing $PKG"
adb install -r "$APK"
echo "==> Resetting to a first-run state (revoking calendar permission)…"
# The release build crashed at launch precisely because this state was never
# tested. Force it so the permission gate / onboarding is exercised every time.
adb shell pm revoke "$PKG" android.permission.READ_CALENDAR 2>/dev/null || true
adb shell pm revoke "$PKG" android.permission.WRITE_CALENDAR 2>/dev/null || true
echo
echo "Installed and reset. Now verify ON THE DEVICE before tagging:"
echo " 1. Launch from a clean state — the permission screen must appear (no crash)."
echo " 2. Grant calendar access — the calendar must load with events."
echo " 3. Add both home-screen widgets and confirm they render (not a spinner)."
echo " 4. Exercise the release's headline changes end to end."
echo
echo "Watch for crashes with: adb logcat -b crash"
echo "Only merge the release branch to main once all of the above pass on a device"
echo "(the merge is what publishes the release — see docs/RELEASING.md)."