ci: adopt the modern calendula pipeline + Codeberg mirror

Port Calendula's current CI/release pipeline:

- ci.yaml: pull_request-triggered, change-scope classification
  (docs/metadata-only PRs skip the Android build but still report a
  green CI), and a reproducible-release invariant guard.
- release.yaml: the committed versionName is the source of truth — a
  bump reaching main triggers the release, which builds, signs,
  publishes to the F-Droid repo, then mints the vX.Y.Z tag + Gitea
  release and mirrors it to Codeberg with the signed APK + SHA-256
  checksum. workflow_dispatch runs the re-sign-only recovery path.
- Gitea releases are flagged as pre-releases while MAJOR is 0.
- build.gradle.kts: reproducible-release invariants (vcsInfo,
  dependenciesInfo) + a releaseTest variant for the on-device gate.
- fastlane/ becomes the single source of truth for store metadata;
  the localized F-Droid layout is generated from it at release time.
- Port scripts/, .gitea/ISSUE_TEMPLATE/, and rewrite docs/RELEASING.md
  for the versionName-in-main model; fix stale references elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:53:13 +02:00
parent b266653e4e
commit 7f58f81fe1
20 changed files with 790 additions and 266 deletions

View File

@@ -0,0 +1,23 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Agendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -0,0 +1,27 @@
---
name: Crash report
about: Report a crash. Agendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
- crash
- priority:high
---
<!--
Thanks for reporting a crash in Agendula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
-->
### What happened
### Crash report
```
(paste the crash report here)
```

View File

@@ -0,0 +1,16 @@
---
name: Feature request
about: Suggest an idea or improvement
title: ""
labels:
- feat
---
### What would you like Agendula to do?
### Why — what problem does it solve?
### Anything else
<!-- mockups, examples from other apps, alternatives you considered -->

View File

@@ -0,0 +1,19 @@
---
name: Question
about: Ask how something works or get help using Agendula
title: ""
labels:
- question
---
### Your question
### What you've tried
<!-- so far, if anything -->
### Context
- Agendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -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:
@@ -22,15 +27,53 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history so the base..HEAD diff below has a merge-base.
fetch-depth: 0
submodules: recursive
# Cheap, always-on guard: the release build must stay reproducible for the
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
# change scope so a regression can't slip through on a "docs-only" PR.
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# 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 }}"
# Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts.
git fetch --no-tags origin "$BASE"
MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true)
if [ -z "$MB" ]; then
# No common ancestor available — don't risk skipping the build.
echo "No merge base with origin/$BASE — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^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
@@ -38,12 +81,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 \
@@ -52,6 +97,7 @@ jobs:
"build-tools;36.0.0"
- name: Setup Gradle cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4
with:
path: |
@@ -62,21 +108,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=""

View File

@@ -1,77 +1,94 @@
name: Release — F-Droid repo + Gitea release
name: Release — F-Droid repo + Gitea/Codeberg 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, creates the vX.Y.Z tag + Gitea release, and mirrors
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — 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 }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- 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"
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
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 }}
PRERELEASE: ${{ needs.detect.outputs.prerelease }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -125,31 +142,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 }}
@@ -164,11 +176,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
@@ -206,8 +215,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
@@ -220,42 +228,33 @@ 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/agendula_${SAFE_REF_NAME}.apk"
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
- name: Copy metadata to F-Droid repo
# Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
# the commit having happened). The transform below then carries it across.
- name: Ensure this version's changelog is in the fastlane tree
if: env.IS_RELEASE == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
- name: Build F-Droid metadata from fastlane (single source of truth)
run: |
mkdir -p fdroid/metadata
cp -r fdroid-metadata/* fdroid/metadata/
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG
# section written to changelogs/<versionCode>.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/')
run: |
set -e
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > /tmp/changelog.txt
sed -i -e '/./,$!d' /tmp/changelog.txt
if [ ! -s /tmp/changelog.txt ]; then
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
fi
CL_DIR="fdroid/metadata/de.jeanlucmakiola.agendula/en-US/changelogs"
mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
# App-level control file (Categories/License/links) for the self-hosted
# repo's `fdroid update`.
cp fdroid-metadata/de.jeanlucmakiola.agendula.yml fdroid/metadata/
# Localized text + graphics + per-version changelogs come from the SAME
# fastlane tree the official F-Droid repo harvests from source,
# transformed into the F-Droid repo "localized" layout. One source of
# truth, both channels.
bash scripts/fastlane_to_fdroid_localized.sh \
fastlane/metadata/android \
fdroid/metadata/de.jeanlucmakiola.agendula
- name: Generate F-Droid Index
run: |
@@ -276,99 +275,47 @@ 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
with:
submodules: recursive
- 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.
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" "$PRERELEASE" <<'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,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true",
}))
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" \
@@ -382,6 +329,110 @@ 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"
# Mirror the release to the Codeberg mirror as a direct-download channel
# for users who don't want F-Droid. Gitea already push-mirrors branches +
# tags to Codeberg, but releases aren't git objects so they don't sync —
# we create the release there over the API and attach the signed APK plus
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
# key), so this adds no trust surface. Best-effort: a Codeberg outage
# (it 504s under load) must never fail an already-published F-Droid
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
SHA: ${{ github.sha }}
run: |
set -e
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="agendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > cb-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,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true",
}))
PY
# Upsert (re-run safe). POST also creates the tag at target_commitish
# if the push mirror hasn't synced it yet.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -n "$ID" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$ID"
else
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
fi
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | 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/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."

View File

@@ -1,8 +1,9 @@
# Changelog
All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/); the latest released git tag is
the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
[Keep a Changelog](https://keepachangelog.com/); the `versionName` committed in
`app/build.gradle.kts` is the source of truth for a release (see
`docs/RELEASING.md`), and the `vX.Y.Z` tag is minted by the pipeline.
## [Unreleased]

View File

@@ -30,11 +30,12 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
```sh
./gradlew :app:assembleDebug # build the debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every push)
./gradlew lintDebug # Android lint (CI runs this on every PR)
```
CI (`.gitea/workflows/ci.yaml`) runs lint → unit tests → debug build on every
push, so run these locally before opening a PR. Keep CI green.
CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check,
then lint → unit tests → debug build on every pull request, so run these locally
before opening a PR. Keep CI green.
## Where to put code
@@ -75,8 +76,10 @@ push, so run these locally before opening a PR. Keep CI green.
- If your change shifts the architecture or completes a milestone, update
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
in the same PR.
- Don't bump `versionName` / `versionCode` by hand — the git tag drives those at
release time.
- Don't bump `versionName` / `versionCode` in a regular PR — the committed
`versionName` is bumped only when **cutting a release** (that bump reaching
`main` is what triggers the release; the pipeline then mints the tag). See
[`docs/RELEASING.md`](docs/RELEASING.md).
## Scope

View File

@@ -23,11 +23,12 @@ android {
applicationId = "de.jeanlucmakiola.agendula"
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. 0.2.0 -> 200). The Gitea release is marked
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
versionCode = 200
versionName = "0.2.0"
@@ -47,6 +48,11 @@ android {
buildTypes {
release {
// Keep release builds reproducible for F-Droid: don't let AGP embed
// build-environment git metadata (META-INF/version-control-info.textproto),
// whose `revision`/path content varies by build machine and is the only
// thing that otherwise differs from a clean from-source rebuild.
vcsInfo { include = false }
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
@@ -61,6 +67,22 @@ 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 merging to main — 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 {
@@ -73,6 +95,16 @@ android {
buildConfig = true
}
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
// "extra signing block" — so leaving it in blocks publishing to the official
// repo. It lives in the signing block, not the zip entries, so disabling it
// doesn't change the build output (reproducibility is unaffected).
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"

View File

@@ -243,7 +243,7 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) |
| Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
| Versioning | git tag is the source of truth; `versionCode = MAJOR*10000 + MINOR*100 + PATCH`, derived in CI at release. See [`RELEASING.md`](RELEASING.md). |
| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
| CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) |
| Distribution | F-Droid (`fdroid-metadata/`) |

View File

@@ -1,101 +1,163 @@
# Agendula — releasing
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with a
human-readable **Gitea release** per tag. Both are produced automatically by
`.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on
the Gitea release itself — distribution lives in the F-Droid repo; the release is
the changelog of record.
Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with
a human-readable **Gitea release** per version. Both are produced automatically
by `.gitea/workflows/release.yaml` when a **bumped `versionName` reaches `main`**
the pipeline builds and publishes that version, then creates the matching
`vX.Y.Z` tag and Gitea release itself. There are no APK assets on the Gitea
release: distribution lives in the F-Droid repo; the release is the changelog of
record.
While Agendula is pre-1.0 (`versionName` starts with `0.`), every Gitea release
is flagged as a **pre-release**. This happens automatically and graduates to a
stable release at `1.0.0` — no manual toggling.
---
## The one source of truth: the git tag
## The source of truth: the committed version
The git tag drives the version. You do **not** hand-edit version numbers for a
release — CI substitutes them from the tag:
A release is defined by the `versionName`/`versionCode` committed in
`app/build.gradle.kts`**not** by a hand-pushed tag:
- `versionName` = the tag without a leading `v` (e.g. `v0.2.0` `0.2.0`).
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (e.g. `0.2.0``200`,
`1.3.4``10304`).
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0``200`,
`1.3.4``10304`)
The values committed in `app/build.gradle.kts` are just the local/dev default;
keep them roughly matching the latest released tag, but the tag wins at release
time.
So `MINOR` and `PATCH` each have room for 099. The release pipeline reads
`versionName`, pins `versionCode` to the derived value, builds, publishes, and —
once the APK is live — creates the tag `v<versionName>` 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).
---
## Cutting a release
1. **Update `CHANGELOG.md`.** Move items out of `[Unreleased]` into a new
`## [X.Y.Z]` section. The release pipeline extracts everything between
`## [X.Y.Z]` and the next `## [` heading and uses it verbatim as both the
Gitea release notes and the F-Droid per-version "What's New"
(`changelogs/<versionCode>.txt`). If no matching section exists, a fallback
line is used — so the heading **must** match the tag's version exactly.
2. **Commit** the changelog on `main`.
3. **Tag and push:**
1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
feature/fix branches for this release into it. Everything below happens on
that branch, before it reaches `main`.
2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items 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 "What's New". The heading **must** match the version exactly.
3. **Bump the committed `versionName`** (and `versionCode`) in
`app/build.gradle.kts`. **This bump is what triggers the release** when the
branch merges to `main`. Then run
```sh
git tag v0.2.0
git push origin v0.2.0
scripts/sync_changelog_to_fastlane.sh
```
4. CI takes over (see below). Watch the run in Gitea Actions.
and commit the generated
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — this is what
makes the **official** F-Droid listing (which harvests the changelog from the
tagged source tree) show this version. The self-hosted pipeline regenerates it
regardless, so forgetting only affects the official listing.
4. **Verify the release build on a real device** — the mandatory gate:
```sh
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 tasks access — the task list must load;
- create a task with a due reminder and confirm the notification fires;
- 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.
> The `releaseTest` build type exists only for step 4 — it is never published.
> The pipeline always builds and signs the real `release` variant.
---
## What CI does on a tag
## What the pipeline does
`release.yaml` runs 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:
| Job | Purpose |
|---|---|
| `ci` | Sanity gate: unit tests + a debug build (catches version-substitution drift). The other jobs depend on this. |
| `build-and-deploy` | Substitute version from the tag → build signed release APK → fetch the existing F-Droid repo from Hetzner → add the new APK + per-version changelog → `fdroid update -c` → upload `repo/` + `metadata/` back. Also attaches the R8 `mapping.txt.gz` to the Gitea release (best-effort) so crash stacktraces stay deobfuscatable. |
| `gitea-release` | Create/update the Gitea release for the tag, body = the extracted CHANGELOG section. Gated on `ci` only (not deploy), so notes still publish if the F-Droid upload hiccups. |
- **`ci.yaml`** (on `pull_request`) — the reproducible-release invariant guard
(`scripts/check_reproducible_release.sh`), then 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, pin `versionCode`, build & sign the release APK with the **app
key**, copy it into the F-Droid repo, generate the per-version changelog from
the fastlane tree, re-sign the index with the **repo key**, upload `repo/` +
`metadata/`, then create the `vX.Y.Z` tag + Gitea release (CHANGELOG section as
notes, flagged pre-release while `MAJOR` is 0), attach the R8 `mapping.txt`,
and mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
(both best-effort). Ordinary merges with no version bump fall through `detect`
and do nothing.
Both deploy and release steps are **re-run safe** (idempotent upserts), so a
failed run can be retried.
### Codeberg direct-download channel
Alongside F-Droid, each release is mirrored to the Codeberg repo
(`jlmakiola/agendula`) as a plain download for users who don't want F-Droid.
Gitea already **push-mirrors** branches and tags to Codeberg, but releases
aren't git objects and don't sync, so the pipeline creates the release over the
Codeberg API and attaches `agendula_v<version>.apk` + its `.sha256`. It's the
same APK the F-Droid repo serves (same **app key**), so it adds no trust surface.
The step is best-effort: a Codeberg outage never fails an already-published
F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset.
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope)
added to Gitea Actions.
### Manual re-sign / recovery
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 pin, 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.
---
## Required CI secrets
## Secrets (Gitea → repo Settings → Actions → Secrets)
Configured in the Gitea repo settings; the workflow fails loudly if the F-Droid
ones are missing (it will **never** auto-generate a repo key that would rotate
the repo fingerprint and break every user's pinned repo).
The workflow fails loudly if the F-Droid ones are missing — it will **never**
auto-generate a repo key (that would rotate the repo fingerprint and break every
user's pinned repo).
| Secret | Used for |
|---|---|
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | App signing keystore (the APK). |
| `FDROID_KEYSTORE_BASE64`, `FDROID_CONFIG_BASE64` | The F-Droid **repo** signing key + `config.yml`. Never uploaded to the server. |
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | SFTP target for the published `repo/` + `metadata/`. |
| `GITHUB_TOKEN` | Gitea API (release create/patch, asset upload). |
| Secret | Purpose |
| --- | --- |
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key — signs the APK. Losing it means existing installs can't be updated. |
| `FDROID_KEYSTORE_BASE64` | **F-Droid repo** signing key (`keystore.p12`, base64). Signs the repo index. |
| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. |
The repo signing key and `config.yml` come from secrets at build time and are
**never** pulled from or pushed back to the server, so they can't leak into the
web-served tree (nginx serves only `repo/`).
The app key signs APKs; the repo key signs the index (its fingerprint is what
users pin). Neither key nor `config.yml` is ever uploaded to the server they
live only in CI secrets and are reconstructed in-runner (nginx serves only
`repo/`).
---
## Key rotation / repo recovery
## F-Droid metadata (single source of truth)
A manual `workflow_dispatch` run (ref = a branch, not a tag) skips all the
tag-only build steps: it just re-signs the existing index with the configured
repo key and re-uploads. Use this to recover the repo or rotate infrastructure
**without** publishing a new APK.
Store-listing text lives in **`fastlane/metadata/android/<locale>/`** — the same
tree the official F-Droid repo harvests from source. At release time
`scripts/fastlane_to_fdroid_localized.sh` transforms it into the F-Droid repo's
"localized" layout, so there is no second copy to maintain. The app-level control
file (`Categories`/`License`/links) stays in
`fdroid-metadata/de.jeanlucmakiola.agendula.yml`. Per-version changelogs are
seeded into `fastlane/.../en-US/changelogs/<versionCode>.txt` by
`scripts/sync_changelog_to_fastlane.sh` (step 3 above) and carried across by the
transform.
---
## Push CI (non-tag)
## Crash deobfuscation
Every push to any branch runs `.gitea/workflows/ci.yaml`: `lintDebug` →
`testDebugUnitTest` → `assembleDebug`, plus a Trivy filesystem scan on `main`.
Keep this green before tagging.
---
## F-Droid metadata
App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.agendula.yml`
plus `en-US/` summary/description). Per-version changelogs are generated into the
repo's `metadata/.../en-US/changelogs/<versionCode>.txt` from `CHANGELOG.md` at
release time; metadata is uploaded alongside `repo/` so changelog history
survives across releases.
Each release attaches `mapping-<version>.txt.gz` (the R8 mapping) to its Gitea
release. To deobfuscate a user stacktrace, download the mapping for that version
and run it through `retrace`.

View File

@@ -0,0 +1,30 @@
### Added
- M2 Material 3 Expressive UI — the app is fully navigable now: the
provider/permission onboarding gate, the lists overview (smart lists + user
lists grouped by account), and the task list (swipe-to-complete / -delete,
inline add, smart-list section headers), detail, and create/edit screens, all
wired to the M1 ViewModels.
- M3 detail/edit polish: a "Progress" slider (percent-complete, 5% detents,
written to `Tasks.PERCENT_COMPLETE`) and conflict-safe saves — `updateTask`
re-checks the provider's `last_modified` against the value captured when the
form loaded and surfaces an overwrite-or-cancel prompt instead of clobbering an
external change (e.g. a DAVx5 sync).
- M4 subtasks (UI): reparent — a full-width, searchable "Parent task" sheet on
the edit form, with candidates grouped by due-date section, files a task under
any active top-level task in its list (or "None" to promote it); switching list
clears the now-invalid parent. Tapping a subtask in the detail screen opens its
own detail, which shows a "Part of …" parent card. On the task list a parent has
a dedicated expand button that reveals its children as a nested grouped run,
ending with an inline "add a subtask" row (an opt-out toggle is planned for the
M6 settings screen).
- Priority is coloured by level (green / amber / red pastels) on the list, detail,
and edit screens.
### Fixed
- Overview open-counts now count top-level tasks only, so subtasks — and
especially open subtasks under a completed parent — no longer inflate a list's
"N open" or the smart-list counts.
- Swipe-to-delete now reveals its red background + icon as you drag (tracking the
live direction, not just the settled target), and a floating "Deleted · Undo"
chip defers the actual delete so it can be restored.

View File

@@ -0,0 +1 @@
Agendula

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Reproducibility guard for the official F-Droid repo (de.jeanlucmakiola.agendula).
#
# F-Droid only republishes OUR signed binary if a from-source build reproduces it
# byte-for-byte and the binary carries no extra signing blocks. If any invariant
# below regresses, the official repo silently stalls on the last good version
# (fails safe — but you'd be stuck on an old release without noticing). So fail
# loudly here, on every PR.
#
# Each invariant guards against a known fdroiddata CI rejection cause (learned on
# the sibling Calendula repo's official-repo submission):
# 1. vcsInfo { include = false } — else AGP embeds env-dependent git
# metadata (META-INF/version-control-info.textproto) -> not reproducible.
# 2. no foojay toolchain resolver — F-Droid's offline source scanner
# rejects org.gradle.toolchains.foojay-resolver (it can fetch a JDK over
# the network at build time).
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
# binary scanner rejects as an extra signing block.
set -euo pipefail
APP="app/build.gradle.kts"
SETTINGS="settings.gradle.kts"
fail=0
# 1. AGP VCS-info must be disabled on the release build. -z reads the whole file
# as one record so the match can span newlines; [^}] keeps it inside the block.
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$APP"; then
echo "OK: vcsInfo { include = false } — no env-dependent VCS metadata embedded."
else
echo "ERROR: '$APP' release build is missing 'vcsInfo { include = false }'." >&2
echo " AGP would embed version-control-info.textproto, breaking reproducibility." >&2
fail=1
fi
# 2. The foojay toolchain resolver must not be present in any Gradle script.
# This includes the floret-kit submodule: it's an included build (composite
# build via `includeBuild`), so F-Droid evaluates its Gradle scripts too when
# building from source — the same offline-scanner bar applies to it.
gradle_files=("$SETTINGS" "$APP")
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
if [ -d floret-kit ]; then
while IFS= read -r f; do gradle_files+=("$f"); done \
< <(find floret-kit -name '*.gradle.kts' -not -path '*/build/*')
fi
if grep -qi 'foojay' "${gradle_files[@]}"; then
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
echo " (it can fetch a JDK over the network). Remove the plugin." >&2
fail=1
else
echo "OK: no foojay toolchain resolver — offline build scanner stays happy."
fi
# 3. AGP dependency-metadata block must not be embedded in the APK.
if grep -Pzoq 'dependenciesInfo\s*\{[^}]*includeInApk\s*=\s*false' "$APP"; then
echo "OK: dependenciesInfo { includeInApk = false } — no extra APK signing block."
else
echo "ERROR: '$APP' is missing 'dependenciesInfo { includeInApk = false }'." >&2
echo " AGP would embed a 'Dependency metadata' block (0x504b4453) in the APK" >&2
echo " Signing Block, which F-Droid's binary scanner rejects." >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
echo >&2
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
echo "stall. Fix the above before merging." >&2
exit 1
fi
echo "All reproducible-release invariants hold."

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Single source of truth: fastlane/metadata/android/<locale>/ feeds BOTH the
# official F-Droid repo (harvested from source automatically) and the
# self-hosted repo. This script transforms the fastlane layout into the F-Droid
# "localized" layout that the self-hosted `fdroid update` consumes, so we don't
# maintain two copies.
#
# usage: fastlane_to_fdroid_localized.sh <fastlane_android_dir> <out_localized_dir>
# e.g. scripts/fastlane_to_fdroid_localized.sh \
# fastlane/metadata/android \
# fdroid/metadata/de.jeanlucmakiola.agendula
#
# Mapping (fastlane -> F-Droid repo localized):
# short_description.txt -> summary.txt
# full_description.txt -> description.txt
# title.txt -> name.txt
# images/icon.png -> icon.png
# images/phoneScreenshots/* -> phoneScreenshots/*
# changelogs/<versionCode>.txt -> changelogs/<versionCode>.txt
# (changelogs are seeded into the fastlane tree by
# scripts/sync_changelog_to_fastlane.sh.)
set -euo pipefail
SRC="${1:?need fastlane android dir, e.g. fastlane/metadata/android}"
OUT="${2:?need output localized dir, e.g. fdroid/metadata/<appid>}"
shopt -s nullglob
for locdir in "$SRC"/*/; do
loc="$(basename "$locdir")"
dst="$OUT/$loc"
mkdir -p "$dst"
[ -f "$locdir/short_description.txt" ] && cp "$locdir/short_description.txt" "$dst/summary.txt"
[ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt"
[ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt"
[ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png"
if [ -d "$locdir/images/phoneScreenshots" ]; then
mkdir -p "$dst/phoneScreenshots"
cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/"
fi
# Per-version changelogs live in the same fastlane tree (see
# scripts/sync_changelog_to_fastlane.sh) and map straight across.
if [ -d "$locdir/changelogs" ]; then
mkdir -p "$dst/changelogs"
cp "$locdir"changelogs/* "$dst/changelogs/"
fi
done
echo "Built F-Droid localized metadata in '$OUT' from '$SRC'"

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Write the current version's CHANGELOG.md section into the fastlane changelog
# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/<code>.txt
# (en-US is F-Droid's fallback locale, so it covers every language).
#
# Run this when cutting a release (after editing CHANGELOG.md and bumping
# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL
# F-Droid repo — which reads the changelog from the tagged source tree — shows
# this version's "What's New". The self-hosted release pipeline also runs it so
# its changelog never depends on the file having been committed. Idempotent.
#
# Extraction matches the awk used for the Gitea release notes so all three
# (release notes, self-hosted changelog, official changelog) stay in sync.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
CL_DIR="fastlane/metadata/android/en-US/changelogs"
mkdir -p "$CL_DIR"
OUT="$CL_DIR/${VERSION_CODE}.txt"
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > "$OUT"
# Trim leading blank lines (same as the pipeline did).
sed -i -e '/./,$!d' "$OUT"
if [ ! -s "$OUT" ]; then
echo "See CHANGELOG.md for $VERSION." > "$OUT"
fi
CHARS=$(wc -m < "$OUT" | tr -d ' ')
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
if [ "$CHARS" -gt 500 ]; then
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
fi

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

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
#
# Build the release-candidate APK and install it on a connected device for the
# mandatory pre-release 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.agendula.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 tasks + notification permissions)…"
# Force the permission-not-granted state so the permission gate / onboarding is
# exercised every time — R8-only breakage and first-run crashes never show up in
# the unminified debug build, nor on a device that already holds the permission.
# Both tasks-provider permission sets are declared; revoke each so whichever the
# device's provider uses starts ungranted.
adb shell pm revoke "$PKG" org.dmfs.permission.READ_TASKS 2>/dev/null || true
adb shell pm revoke "$PKG" org.dmfs.permission.WRITE_TASKS 2>/dev/null || true
adb shell pm revoke "$PKG" org.tasks.permission.READ_TASKS 2>/dev/null || true
adb shell pm revoke "$PKG" org.tasks.permission.WRITE_TASKS 2>/dev/null || true
adb shell pm revoke "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true
echo
echo "Installed and reset. Now verify ON THE DEVICE before releasing:"
echo " 1. Launch from a clean state — the permission screen must appear (no crash)."
echo " 2. Grant tasks access — the task list must load."
echo " 3. Create a task with a due reminder and confirm the notification fires."
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)."