Compare commits

..

1 Commits

Author SHA1 Message Date
54739a5348 chore(deps): update kotlin to v2.4.0
Some checks failed
CI / ci (push) Failing after 3m22s
2026-06-19 09:17:54 +00:00
9 changed files with 229 additions and 374 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[versions] [versions]
agp = "9.2.1" agp = "9.2.1"
kotlin = "2.3.21" kotlin = "2.4.0"
ksp = "2.3.9" ksp = "2.3.9"
hilt = "2.59.2" hilt = "2.59.2"
coreKtx = "1.19.0" coreKtx = "1.19.0"

View File

@@ -1,43 +0,0 @@
#!/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)."