diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e672869..83b7ee5 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,18 +1,23 @@ 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: - push: - branches: - - '**' - tags-ignore: - - '**' + pull_request: -# Cancel superseded runs on the same branch. +# Cancel superseded runs for the same PR. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true 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: runs-on: docker env: @@ -21,14 +26,37 @@ jobs: steps: - name: Checkout 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 + if: steps.scope.outputs.code == 'true' uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '17' - name: Setup Android SDK + if: steps.scope.outputs.code == 'true' uses: android-actions/setup-android@v3 with: # Default ("tools platform-tools") drags in the Android Emulator @@ -36,12 +64,14 @@ jobs: packages: '' - name: Setup Android SDK cache + if: steps.scope.outputs.code == 'true' uses: actions/cache@v4 with: path: /opt/android-sdk key: ${{ runner.os }}-android-sdk-37-36.0.0 - name: Install Android SDK packages + if: steps.scope.outputs.code == 'true' run: | yes | sdkmanager --licenses >/dev/null || true sdkmanager \ @@ -50,6 +80,7 @@ jobs: "build-tools;36.0.0" - name: Setup Gradle cache + if: steps.scope.outputs.code == 'true' uses: actions/cache@v4 with: path: | @@ -60,21 +91,25 @@ jobs: ${{ runner.os }}-gradle- - name: Grant execute permission for gradlew + if: steps.scope.outputs.code == 'true' run: chmod +x ./gradlew # No --no-daemon: the daemon lives only as long as this job container # and lets the following steps skip JVM startup + reconfiguration. - name: Lint (debug variant only) + if: steps.scope.outputs.code == 'true' run: ./gradlew lintDebug - name: Unit tests + if: steps.scope.outputs.code == 'true' run: ./gradlew testDebugUnitTest - name: Assemble debug APK + if: steps.scope.outputs.code == 'true' run: ./gradlew assembleDebug - name: Trivy filesystem scan - if: github.ref == 'refs/heads/main' + if: steps.scope.outputs.code == 'true' run: | set -e SUDO="" diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 643a817..13f8d7f 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -1,75 +1,84 @@ 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: push: - tags: - - '*' + branches: [main] workflow_dispatch: +concurrency: + group: release + cancel-in-progress: false + 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 - env: - ANDROID_HOME: /opt/android-sdk - ANDROID_SDK_ROOT: /opt/android-sdk + outputs: + is_release: ${{ steps.v.outputs.is_release }} + version: ${{ steps.v.outputs.version }} + version_code: ${{ steps.v.outputs.version_code }} steps: - name: Checkout uses: actions/checkout@v4 - - 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 + - name: Resolve version and whether it is a new release + id: v + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} run: | - yes | sdkmanager --licenses >/dev/null || true - sdkmanager \ - "platform-tools" \ - "platforms;android-37.0" \ - "build-tools;36.0.0" + 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" + 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 - 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: 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 + # 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). + release: + needs: detect + if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch' 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 }} + IS_RELEASE: ${{ needs.detect.outputs.is_release }} steps: - name: Checkout uses: actions/checkout@v4 @@ -121,31 +130,26 @@ jobs: $SUDO apk add --no-cache jq fi - # Tag-only build steps. On a manual workflow_dispatch (ref = a branch, - # not a tag) these are skipped: the job then just re-signs the existing - # index with the configured repo key and re-uploads — used for key - # rotation / repo recovery without publishing a new APK. - - name: Set version from git tag - if: startsWith(github.ref, 'refs/tags/') + - name: Grant execute permission for gradlew + 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. + - name: Pin versionCode to versionName + if: env.IS_RELEASE == 'true' run: | 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 grep -E 'versionName|versionCode' app/build.gradle.kts - # Export for later steps (F-Droid changelog, mapping asset name). - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV" + + # Test the exact commit being shipped (only on a real release). + - name: Unit tests + if: env.IS_RELEASE == 'true' + run: ./gradlew testDebugUnitTest - name: Setup Android Keystore - if: startsWith(github.ref, 'refs/tags/') + if: env.IS_RELEASE == 'true' env: KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} @@ -160,11 +164,8 @@ jobs: storeFile=upload-keystore.jks EOF - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew - - name: Build release APK - if: startsWith(github.ref, 'refs/tags/') + if: env.IS_RELEASE == 'true' run: ./gradlew assembleRelease - name: Setup F-Droid Server Tools @@ -202,8 +203,7 @@ jobs: set -euo pipefail # Fail loudly if the repo key is not configured. NEVER auto-generate # one: a fresh key changes the repo fingerprint and breaks every - # user's pinned repo. (Replaces the old `fdroid update --create-key` - # path, which silently rotated the key on a wiped server.) + # user's pinned repo. if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then 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 @@ -216,28 +216,23 @@ jobs: mkdir -p fdroid/repo/icons - name: Copy new APK to repo - if: startsWith(github.ref, 'refs/tags/') + if: env.IS_RELEASE == 'true' run: | set -e mkdir -p fdroid/repo - REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" - 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" + cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk" - name: Copy metadata to F-Droid repo run: | mkdir -p 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/.txt (same extraction as the # 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. - name: Generate F-Droid changelog for this version - if: startsWith(github.ref, 'refs/tags/') + if: env.IS_RELEASE == 'true' run: | set -e awk -v ver="$VERSION" ' @@ -272,97 +267,46 @@ jobs: SFTP # Publish the signed repo/ plus metadata/ (descriptions, screenshots, # per-version changelogs) so changelog history survives across - # releases. keystore.p12 and config.yml are NEVER uploaded, so they - # can't re-enter the web-served tree; nginx serves only repo/ anyway. + # releases. keystore.p12 and config.yml are NEVER uploaded. 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. - # Attached to the Gitea 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: startsWith(github.ref, 'refs/tags/') - continue-on-error: true + # The APK is published and the index re-signed — now record the release. + # 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). + - name: Create tag + Gitea release + if: env.IS_RELEASE == 'true' env: TOKEN: ${{ secrets.GITHUB_TOKEN }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + SHA: ${{ github.sha }} 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="${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 "## []" and the next "## [" heading. + 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 - # Trim leading blank lines. 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 - echo "--- release notes ---" - 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 + python3 - "$TAG" "$SHA" <<'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, "prerelease": False, })) PY - # Upsert: the build-and-deploy job may have created a bare release - # first (to attach the mapping asset), so PATCH the notes if it - # exists, otherwise POST a new one. Both paths are re-run safe. + # 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=$(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 CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ @@ -376,6 +320,33 @@ jobs: fi cat response.json 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 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" diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml new file mode 100644 index 0000000..5b5ebda --- /dev/null +++ b/.gitea/workflows/renovate.yml @@ -0,0 +1,42 @@ +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). + schedule: + - cron: '0 5 * * 1' + # Manual run for an on-demand sweep from the Actions tab. + workflow_dispatch: + +# Never let two Renovate runs touch the repo at once. +concurrency: + group: renovate + cancel-in-progress: false + +jobs: + renovate: + runs-on: docker + # Run the Renovate image *as* the job container and invoke the `renovate` + # binary directly. The renovatebot/github-action wrapper is a thin Node + # action that shells out to `docker run …` — it needs a Docker CLI + socket + # inside the job, which the Gitea runner's plain node container has not, so + # it died on "Unable to locate executable file: docker". Running the image + # directly drops the docker-in-docker requirement entirely. + # Full tag pinned; Renovate's github-actions manager keeps it bumped. + container: + image: ghcr.io/renovatebot/renovate:43.232.0 + steps: + - name: Run Renovate + run: renovate + env: + # Self-hosted Gitea, not github.com. + RENOVATE_PLATFORM: gitea + RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1 + # Bot-account token (Gitea secret). Needs repo read/write + PR scope. + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + # Scope to this repo only — no org-wide autodiscovery. + RENOVATE_AUTODISCOVER: 'false' + RENOVATE_REPOSITORIES: '["makiolaj/calendula"]' + # Commits/PRs authored as the bot, not a real maintainer. + RENOVATE_GIT_AUTHOR: 'Renovate Bot ' + LOG_LEVEL: info diff --git a/.gitea/workflows/translations.yaml b/.gitea/workflows/translations.yaml index c3440e8..0d2e8f8 100644 --- a/.gitea/workflows/translations.yaml +++ b/.gitea/workflows/translations.yaml @@ -4,11 +4,7 @@ name: Translations # only touch values-*/strings.xml) get quick feedback without the full Android # build. The deeper checks still run in CI via lintDebug (ExtraTranslation). on: - push: - branches: - - '**' - tags-ignore: - - '**' + pull_request: paths: - 'app/src/main/res/values*/strings.xml' - 'app/src/main/res/xml/locales_config.xml' diff --git a/CHANGELOG.md b/CHANGELOG.md index d9917f7..baa0fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.7.2] — 2026-06-18 +## [2.7.2] — 2026-06-21 ### Added - Crash reporting you control. If Calendula closes unexpectedly, it now captures @@ -19,6 +19,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 problem" entry in Settings, and if the app ever fails to start repeatedly, a minimal recovery screen still lets you send the report. +## [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 ### Added diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4184296..fb505cd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,11 +23,11 @@ android { applicationId = "de.jeanlucmakiola.calendula" minSdk = 29 targetSdk = 36 - // The git tag is the single source of truth for released builds: at - // release time .gitea/workflows/release.yaml derives both fields from - // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH - // (e.g. v2.0.0 -> 20000). These committed values are the dev/local - // default; keep them matching the latest released tag. See docs/RELEASING.md. + // These committed values ARE the source of truth for a release: merging + // a bumped versionName into main triggers .gitea/workflows/release.yaml, + // which builds this version and then creates the matching vX.Y.Z tag + + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. versionCode = 20702 versionName = "2.7.2" @@ -61,6 +61,21 @@ android { applicationIdSuffix = ".debug" 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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index ed93412..44c25c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -1,9 +1,11 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.Manifest import android.content.ContentResolver import android.content.ContentUris import android.content.ContentValues import android.content.Context +import android.content.pm.PackageManager import android.database.ContentObserver import android.database.Cursor import android.net.Uri @@ -11,6 +13,7 @@ import android.os.Handler import android.os.Looper import android.provider.CalendarContract import android.util.Log +import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.CalendarSource @@ -162,14 +165,52 @@ class AndroidCalendarDataSource @Inject constructor( ) : CalendarDataSource { private val resolver: ContentResolver get() = context.contentResolver - private val observers = mutableMapOf<() -> Unit, ContentObserver>() - override fun calendars(): List = resolver.query( - CalendarContract.Calendars.CONTENT_URI, - CalendarProjection.COLUMNS, - null, null, - CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", - )?.use { it.mapAll(::toCalendarSource) } ?: emptyList() + // 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() + + 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 { + ensureObserversRegistered() + return resolver.query( + CalendarContract.Calendars.CONTENT_URI, + CalendarProjection.COLUMNS, + null, null, + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", + )?.use { it.mapAll(::toCalendarSource) } ?: emptyList() + } /** * Calendar-row writes must address the provider as a sync adapter and name @@ -243,6 +284,7 @@ class AndroidCalendarDataSource @Inject constructor( } override fun instances(beginMillis: Long, endMillis: Long): List { + ensureObserversRegistered() val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { ContentUris.appendId(this, beginMillis) ContentUris.appendId(this, endMillis) @@ -720,16 +762,20 @@ class AndroidCalendarDataSource @Inject constructor( listener() } } - observers[listener] = obs - resolver.registerContentObserver( - CalendarContract.CONTENT_URI, - /* notifyForDescendants = */ true, - obs, - ) + synchronized(observerLock) { + observers[listener] = obs + // Attach now if we already hold the permission; otherwise it stays + // pending and re-attaches on the first read after the grant. + registerObserverLocked(obs) + } } 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 = resolver.query( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6bd2b04..21d1f42 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -143,5 +143,6 @@ JUnit 5 + Truth + Turbine on the JVM. The seams that make it work: `CalendarDataSource` is faked (`FakeCalendarDataSource` records writes), mappers parse `ColumnReader`/plain maps instead of cursors, domain logic (recurrence, validation, snapshots, write-value building) is pure. CI -(Gitea Actions) runs `lint test assembleDebug` on every push; release tags -additionally build, sign, and publish to the self-hosted F-Droid repo. +(Gitea Actions) runs `lint test assembleDebug` once per pull request; merging a +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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f268511..4346228 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,61 +1,90 @@ # Releasing Calendula -Calendula is distributed through a self-hosted F-Droid repository. Every -release is built, signed, and published automatically by -`.gitea/workflows/release.yaml` when a version tag is pushed. +Calendula is distributed through a self-hosted F-Droid repository. A release is +built, signed, and published automatically by `.gitea/workflows/release.yaml` +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 -release time the workflow derives both Gradle fields from the tag: +A release is defined by the `versionName`/`versionCode` committed in +`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`) -So `MINOR` and `PATCH` each have room for 0–99. The values committed in -`app/build.gradle.kts` are only the dev/local default — CI overwrites them -from the tag. Keep the committed `versionCode`/`versionName` matching the -**latest released tag** so local builds are sanely versioned; the published -value always comes from the tag. +So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads +`versionName`, pins `versionCode` to the derived value, builds, and — once the +APK is published — creates the tag `v` 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). Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000. ## 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] — ` 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 changelog. -2. Optionally bump the committed `versionCode`/`versionName` in - `app/build.gradle.kts` to match the new version (keeps local builds tidy). -3. Commit, then tag and push: +3. Bump the committed `versionName` (and `versionCode`) in + `app/build.gradle.kts` to the new version. **This bump is what triggers the + 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 - git tag vX.Y.Z - git push origin vX.Y.Z + scripts/verify-release.sh ``` -4. The push triggers the release workflow. **Hold UI releases for on-device - review and explicit go-ahead before tagging.** + 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 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 -`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). -- **build-and-deploy** — derives the version, builds & signs the release APK - with the app key, copies it into the F-Droid repo, generates the per-version - changelog, re-signs the F-Droid index with the **repo key**, uploads - `repo/` + `metadata/` to the box, and attaches the R8 `mapping.txt` to the - Gitea release (best-effort). -- **gitea-release** — creates/updates the Gitea release carrying the tag's - CHANGELOG section as notes. Gated on `ci` only (not the deploy) so notes - publish even if the F-Droid upload hiccups. +- **`ci.yaml`** (on `pull_request`) — 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. +- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap + `detect` job reads `versionName` and checks whether a tag for it already + exists. Only when it doesn't does the `release` job run: unit tests on the + merged commit, build & sign the release APK with the **app key**, copy it into + the F-Droid repo, generate the per-version changelog, re-sign the index with + 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 -A manual `workflow_dispatch` of the release workflow **from a branch** (not a -tag) runs a **re-sign-only** path: it skips the APK build and just re-signs -the 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. +A manual `workflow_dispatch` of the release workflow runs a **re-sign-only** +path: `detect` reports it's not a release, so the `release` job skips the APK +build, the version bump, and tag/release creation, and just re-signs the +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) diff --git a/renovate.json5 b/renovate.json5 new file mode 100644 index 0000000..103bd3d --- /dev/null +++ b/renovate.json5 @@ -0,0 +1,56 @@ +{ + $schema: "https://docs.renovatebot.com/renovate-schema.json", + + extends: [ + "config:recommended", + // chore(deps): … — match the repo's conventional-commit style. + ":semanticCommits", + ], + + // No automerge: a dependency bump goes through the same review (and, for + // anything touching the build, the same on-device check) as a feature + // before it can ride a release — see docs/RELEASING.md and the + // "hold release for approval" rule. + automerge: false, + + // One reviewable surface; the dashboard issue lists everything pending. + dependencyDashboard: true, + labels: ["dependencies"], + 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. + + // Gitea Actions workflows live under .gitea/workflows, not .github — extend + // the github-actions manager (same syntax) to watch them too. + "github-actions": { + fileMatch: ["^\\.gitea/workflows/[^/]+\\.ya?ml$"], + }, + + packageRules: [ + // material3 is deliberately pinned to the 1.5 *alpha* line for the + // Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train + // but keep it in its own PR, reviewed in isolation; revisit the pin when + // 1.5.0 stable lands. + { + matchPackageNames: ["androidx.compose.material3:material3"], + ignoreUnstable: false, + groupName: "material3 (alpha)", + }, + // Test-only deps: group into one low-noise PR. + { + matchPackageNames: [ + "org.junit.jupiter:**", + "org.junit.platform:**", + "com.google.truth:**", + "app.cash.turbine:**", + "androidx.test:**", + "androidx.test.espresso:**", + "androidx.test.ext:**", + ], + groupName: "test dependencies", + }, + ], +} diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh new file mode 100755 index 0000000..7e49544 --- /dev/null +++ b/scripts/verify-release.sh @@ -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)."