From 2f153fef567d80468a35a6ce6d061522425fdef8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 18 Jun 2026 17:07:46 +0200 Subject: [PATCH 1/7] ci(renovate): self-hosted Renovate config + weekly workflow renovate.json5 (config:recommended + semantic commits, no automerge, dependency dashboard; material3 stays on its 1.5-alpha pin in an isolated PR; test deps grouped; github-actions manager watches .gitea/workflows). Cadence owned by .gitea/workflows/renovate.yml (Mondays 05:00 UTC + manual dispatch), self-hosted via renovatebot/github-action, scoped to makiolaj/calendula. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/renovate.yml | 33 +++++++++++++++++++++ renovate.json5 | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 .gitea/workflows/renovate.yml create mode 100644 renovate.json5 diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml new file mode 100644 index 0000000..353b7c0 --- /dev/null +++ b/.gitea/workflows/renovate.yml @@ -0,0 +1,33 @@ +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 + steps: + - name: Run Renovate + uses: renovatebot/github-action@v40 + 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/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", + }, + ], +} From eeef089e4a987f1c0a4442627b661cfff18dfa94 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 18 Jun 2026 22:33:15 +0200 Subject: [PATCH 2/7] fix(renovate): pin action to a real tag (v46.1.15) renovatebot/github-action ships only full semver tags; @v40 was an invalid ref and the dispatched run failed to resolve it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/renovate.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index 353b7c0..1cd89c9 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -18,7 +18,9 @@ jobs: runs-on: docker steps: - name: Run Renovate - uses: renovatebot/github-action@v40 + # This action publishes only full semver tags (no moving major tag — + # @v46 / @v40 etc. 404). Pin a real release; Renovate keeps it bumped. + uses: renovatebot/github-action@v46.1.15 env: # Self-hosted Gitea, not github.com. RENOVATE_PLATFORM: gitea From 35022267dca722e1ed976d2a139bcfca95377b60 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 19 Jun 2026 10:08:08 +0200 Subject: [PATCH 3/7] fix(renovate): run renovate image directly instead of docker-wrapping action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renovatebot/github-action is a Node wrapper that shells out to `docker run ghcr.io/renovatebot/renovate`, requiring a Docker CLI + socket inside the job. The Gitea runner executes the job in a plain node:22 container with neither, so it died on "Unable to locate executable file: docker". Run the renovate image as the job container and invoke `renovate` directly — drops the docker-in-docker requirement. Full tag pinned; Renovate's github-actions manager keeps container.image bumped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/renovate.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml index 1cd89c9..5b5ebda 100644 --- a/.gitea/workflows/renovate.yml +++ b/.gitea/workflows/renovate.yml @@ -16,11 +16,18 @@ concurrency: 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 - # This action publishes only full semver tags (no moving major tag — - # @v46 / @v40 etc. 404). Pin a real release; Renovate keeps it bumped. - uses: renovatebot/github-action@v46.1.15 + run: renovate env: # Self-hosted Gitea, not github.com. RENOVATE_PLATFORM: gitea From 997ee44792eb0a186e98259f8ce89ff49068e08f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 21 Jun 2026 11:52:28 +0200 Subject: [PATCH 4/7] fix(calendar): don't register calendar observer before permission granted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 10 +++ app/build.gradle.kts | 4 +- .../data/calendar/CalendarDataSource.kt | 74 +++++++++++++++---- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdffc43..8e405bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Added diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c14444..7d4e97a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // 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. - versionCode = 20700 - versionName = "2.7.0" + versionCode = 20701 + versionName = "2.7.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } 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 7aa838e..11edaf6 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 @@ -242,6 +283,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) @@ -718,16 +760,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( From 0c95479051e64ffa1cde71859b223637a77ba23a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 21 Jun 2026 12:08:38 +0200 Subject: [PATCH 5/7] build,docs: add on-device release verification gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/build.gradle.kts | 15 ++++++++++++++ docs/RELEASING.md | 34 ++++++++++++++++++++++++++----- scripts/verify-release.sh | 42 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) create mode 100755 scripts/verify-release.sh diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d4e97a..864e3ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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/docs/RELEASING.md b/docs/RELEASING.md index f268511..f4f82b0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -22,20 +22,44 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0` ## 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 `versionCode`/`versionName` in `app/build.gradle.kts` to + match the new version (keeps local builds tidy; CI overwrites from the tag). +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 + scripts/verify-release.sh + ``` + It builds the `releaseTest` variant (same R8 config as `release`, debug-signed + with a `.releasetest` suffix so it installs alongside the real app) and resets + it to a first-run state. Then, on the device: + - launch from a **clean / permission-not-granted** state — the permission + screen must appear, no crash; + - grant 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`, then tag and push: ```bash git tag vX.Y.Z git push origin vX.Y.Z ``` -4. The push triggers the release workflow. **Hold UI releases for on-device +6. The push triggers the release workflow. **Hold UI releases for on-device review and explicit go-ahead before tagging.** +> The `releaseTest` build type exists only for step 4 — it is never published. +> The pipeline always builds and signs the real `release` variant from the tag. + ## What the pipeline does `release.yaml` has three jobs: diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh new file mode 100755 index 0000000..01baaa1 --- /dev/null +++ b/scripts/verify-release.sh @@ -0,0 +1,42 @@ +#!/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 tag the release once all of the above pass on a real device." From df203c0b6bedf88f37e428089c081849bd8f2eb4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 21 Jun 2026 12:32:56 +0200 Subject: [PATCH 6/7] ci: release on merge-to-main; consolidate pipeline triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/ci.yaml | 49 ++++- .gitea/workflows/release.yaml | 281 +++++++++++++---------------- .gitea/workflows/translations.yaml | 6 +- app/build.gradle.kts | 10 +- docs/RELEASING.md | 77 ++++---- scripts/verify-release.sh | 3 +- 6 files changed, 217 insertions(+), 209 deletions(-) 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/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/app/build.gradle.kts b/app/build.gradle.kts index 864e3ca..7ee13ca 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.1 -> 20701). See docs/RELEASING.md. versionCode = 20701 versionName = "2.7.1" diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f4f82b0..4346228 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,22 +1,24 @@ # 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. @@ -29,8 +31,9 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0` `## [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. -3. Bump the committed `versionCode`/`versionName` in `app/build.gradle.kts` to - match the new version (keeps local builds tidy; CI overwrites from the tag). +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 @@ -49,37 +52,39 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0` - exercise this release's headline changes. Only proceed once all of that passes on-device. -5. Merge `release/vX.Y.Z` into `main`, then tag and push: - ```bash - git tag vX.Y.Z - git push origin vX.Y.Z - ``` -6. The push triggers the release workflow. **Hold UI releases for on-device - review and explicit go-ahead before tagging.** +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 from the tag. +> 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/scripts/verify-release.sh b/scripts/verify-release.sh index 01baaa1..7e49544 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -39,4 +39,5 @@ 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 tag the release once all of the above pass on a real device." +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)." From 8a804785558c60faa558b8670f92a13687e04ebf Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 21 Jun 2026 12:33:55 +0200 Subject: [PATCH 7/7] docs(architecture): describe merge-driven release flow Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ARCHITECTURE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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.