diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.forgejo/ISSUE_TEMPLATE/bug_report.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/bug_report.md
rename to .forgejo/ISSUE_TEMPLATE/bug_report.md
diff --git a/.forgejo/ISSUE_TEMPLATE/config.yml b/.forgejo/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..ce8f6e3
--- /dev/null
+++ b/.forgejo/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,24 @@
+# Kept enabled so anything that doesn't fit the four templates still has a way
+# in.
+blank_issues_enabled: true
+
+contact_links:
+ - name: Translate Agendula
+ url: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
+ about: >-
+ Translations are managed on Weblate, not here — it owns every values-*
+ file, so a hand-edited translation gets overwritten on the next sync.
+ No coding needed: pick or request a language and translate in the browser.
+
+ - name: Contributing guide
+ url: https://codeberg.org/jlmakiola/agendula/src/branch/main/CONTRIBUTING.md
+ about: >-
+ Before opening a pull request: how to build (there's a submodule), where
+ code goes, and the one architectural rule a change is reviewed against.
+
+ - name: Sync sources and scope
+ url: https://codeberg.org/jlmakiola/agendula/src/branch/main/README.md
+ about: >-
+ Agendula is a front-end over the OpenTasks provider, so it works with
+ DAVx5, SmoothSync, DecSync and friends. Google Tasks and Microsoft To Do
+ are out of scope by design — check here before requesting a backend.
diff --git a/.gitea/ISSUE_TEMPLATE/crash_report.md b/.forgejo/ISSUE_TEMPLATE/crash_report.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/crash_report.md
rename to .forgejo/ISSUE_TEMPLATE/crash_report.md
diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.forgejo/ISSUE_TEMPLATE/feature_request.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/feature_request.md
rename to .forgejo/ISSUE_TEMPLATE/feature_request.md
diff --git a/.gitea/ISSUE_TEMPLATE/question.md b/.forgejo/ISSUE_TEMPLATE/question.md
similarity index 100%
rename from .gitea/ISSUE_TEMPLATE/question.md
rename to .forgejo/ISSUE_TEMPLATE/question.md
diff --git a/.forgejo/PULL_REQUEST_TEMPLATE.md b/.forgejo/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..94555d0
--- /dev/null
+++ b/.forgejo/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,42 @@
+
+
+### What this changes
+
+
+### Why
+
+
+
+
+### How it was tested
+
+
+
+
+### Checklist
+
+- [ ] `./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug` passes locally
+- [ ] New domain logic comes with JVM unit tests under `app/src/test/`
+- [ ] Provider details stay inside `data/tasks/`
+- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
+- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
+- [ ] No `versionName` / `versionCode` bump
+- [ ] No planning or design documents committed
diff --git a/.gitea/workflows/ci.yaml b/.forgejo/workflows/ci.yaml
similarity index 74%
rename from .gitea/workflows/ci.yaml
rename to .forgejo/workflows/ci.yaml
index 9f15571..af678bc 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.forgejo/workflows/ci.yaml
@@ -37,14 +37,29 @@ jobs:
- 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`.
+ # Decide whether anything that affects the app build changed. Docs, store
+ # metadata, licence texts and forge housekeeping don't, so those PRs skip
+ # the SDK + Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
+ env:
+ # Deliberately a skip-list, not a build-list: a path nobody thought
+ # about defaults to building. Only paths the Gradle build provably
+ # never reads belong here — note that the workflows themselves, the
+ # `.gitmodules` submodule pointer and `scripts/` are *not* in it.
+ SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^design/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
run: |
set -e
BASE="${{ github.base_ref }}"
+ # Normally the bare branch name; tolerate a full ref, which would
+ # otherwise make the merge-base lookup fail and quietly degrade this
+ # guard into "always build".
+ BASE="${BASE#refs/heads/}"
+ if [ -z "$BASE" ]; then
+ echo "No base branch on this event — running the full build to be safe."
+ echo "code=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
# 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.
@@ -58,11 +73,15 @@ jobs:
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
+ RELEVANT=$(echo "$CHANGED" | grep -vE "$SKIP_RE" || true)
+ if [ -n "$RELEVANT" ]; then
+ # Naming them makes "why did my docs PR build for four minutes?"
+ # answerable from the log alone.
+ echo "Build-relevant changes:"; echo "$RELEVANT"
echo "code=true" >> "$GITHUB_OUTPUT"
else
- echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
+ echo "code=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Java
@@ -72,9 +91,14 @@ jobs:
distribution: 'zulu'
java-version: '17'
+ # Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
+ # data.forgejo.org, Forgejo's own action mirror — actions/checkout,
+ # setup-java and cache all exist there, but android-actions/setup-android
+ # does not, and the job dies with "repository not found". Gitea's instance
+ # defaults to GitHub, which is why this never surfaced before the split.
- name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
- uses: android-actions/setup-android@v3
+ uses: https://github.com/android-actions/setup-android@v3
with:
# Default ("tools platform-tools") drags in the Android Emulator
# (~300 MB) which the build never uses.
diff --git a/.forgejo/workflows/translations.yaml b/.forgejo/workflows/translations.yaml
new file mode 100644
index 0000000..c621258
--- /dev/null
+++ b/.forgejo/workflows/translations.yaml
@@ -0,0 +1,39 @@
+name: Translations
+
+# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
+# only touch values-*/strings.xml) get quick feedback without the full Android
+# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
+#
+# Runs on every PR (no path filter) so the required "Translations / check"
+# status is always reported — like the `ci` job. A path-filtered workflow is
+# skipped on unrelated PRs and never posts its status, which leaves that
+# required check pending forever and blocks the merge of any code-only PR into a
+# release/* branch. The check itself is cheap and simply passes when the
+# committed translations are consistent, so always running it costs nothing.
+on:
+ pull_request:
+
+concurrency:
+ group: translations-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check:
+ runs-on: docker
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Ensure python3
+ run: |
+ if ! command -v python3 >/dev/null 2>&1; then
+ if command -v apt-get >/dev/null 2>&1; then
+ apt-get update && apt-get install -y python3
+ elif command -v apk >/dev/null 2>&1; then
+ apk add --no-cache python3
+ fi
+ fi
+ python3 --version
+
+ - name: Check translation parity
+ run: python3 scripts/check_translations.py
diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml
index 48ef4ec..38d729b 100644
--- a/.gitea/workflows/release.yaml
+++ b/.gitea/workflows/release.yaml
@@ -3,12 +3,18 @@ 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
+# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and publishes
+# the release on 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.
#
+# This file lives in .gitea/workflows on purpose: Codeberg is canonical for git,
+# issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner
+# credentials) lives on the self-hosted Gitea instance, and this is the only
+# directory Codeberg cannot see. Contributor-triggerable work lives in
+# .forgejo/workflows and references no secret. See docs/RELEASING.md.
+#
# 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
@@ -27,6 +33,14 @@ jobs:
# 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:
+ # Gitea only. The workflow directory split already keeps this file invisible
+ # to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
+ # exists — but that only holds while .forgejo/ is non-empty. Move the last
+ # file out of it and Codeberg would fall back to .gitea/workflows and start
+ # running the release pipeline on the contributor-facing runner, with no
+ # secrets. repository_owner differs between the two forges regardless of
+ # URL, proxy or instance rename, so this closes it permanently.
+ if: github.repository_owner == 'makiolaj'
runs-on: docker
outputs:
is_release: ${{ steps.v.outputs.is_release }}
@@ -42,8 +56,16 @@ jobs:
- 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 }}
+ # Tags are read from Codeberg, which is canonical — deliberately NOT
+ # from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
+ # is a push mirror, i.e. `git push --mirror`, which deletes refs the
+ # source does not have. A tag minted here on Gitea is therefore wiped
+ # by the next sync (Codeberg does not have it yet) and only reappears
+ # once the tag push at the end of this workflow propagates back.
+ # Asking Gitea inside that window would report "no tag" for a release
+ # that already shipped, and cut it a second time.
+ # Public repo, so this read needs no token.
+ TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
run: |
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
@@ -65,15 +87,28 @@ jobs:
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
+ #
+ # Anything other than a clean 200/404 is treated as fatal rather than
+ # as "no tag". A Codeberg outage or a network blip would otherwise
+ # read as absent and re-cut a release that has already shipped —
+ # republishing to F-Droid. Failing here is recoverable; a duplicate
+ # release is not.
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
+ case "$STATUS" in
+ 200)
+ echo "Tag v$VERSION already exists on Codeberg — nothing to release."
+ echo "is_release=false" >> "$GITHUB_OUTPUT"
+ ;;
+ 404)
+ echo "No tag for v$VERSION on Codeberg yet — cutting the release."
+ echo "is_release=true" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
+ echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
+ exit 1
+ ;;
+ esac
# 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
@@ -360,14 +395,15 @@ jobs:
-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.
+ # Publish the release on Codeberg, which is canonical for tags and
+ # releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
+ # to Gitea, but releases aren't git objects and don't sync in either
+ # direction — so this step pushes the tag straight to Codeberg and creates
+ # the release there over the API, attaching the signed APK plus a SHA-256
+ # checksum as the direct-download channel for users who don't want
+ # F-Droid. The APK is identical to the F-Droid one (same app key), so this
+ # adds no trust surface. Needs the CODEBERG_RELEASE_TOKEN secret; skips
+ # cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
@@ -377,6 +413,7 @@ jobs:
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
@@ -401,34 +438,21 @@ jobs:
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
- # Never mint the tag here. Gitea's push mirror owns getting it to
- # Codeberg; this step's only job is to attach a release to a tag that
- # has already landed. That split matters because every way of creating
- # a tag from here — git push, or a release POST carrying
- # target_commitish for a tag Codeberg lacks — is a ref WRITE, and ref
- # writes are what fail on this repo ("cannot lock references" on push,
- # an empty-bodied 500 on the API). Attaching to a tag that is already
- # present needs no ref write and succeeds.
+ # Push the tag to Codeberg ourselves. Under Codeberg-canonical the
+ # mirror runs Codeberg -> Gitea, so waiting for a tag to arrive here
+ # from Gitea (what 0.3.2 did) would wait forever. The tag this
+ # pipeline minted on Gitea is in fact *deleted* by the next mirror
+ # sync until Codeberg has it — so pushing it here is what makes it
+ # durable on both forges.
#
- # So: wait for the mirror, verify, then attach. If the tag never shows
- # up, fail — do NOT fall back to creating it, which is what produced
- # the silent breakage across 0.2.1 through 0.3.2.
- TAG_OK=""
- for i in $(seq 1 30); do
- if [ "$(curl -s -o /dev/null -w '%{http_code}' \
- -H "Authorization: token $TOKEN" "$API/tags/$TAG")" = "200" ]; then
- TAG_OK=1; echo "Codeberg has $TAG (after ~$((i*10))s)"; break
- fi
- sleep 10
- done
- if [ -z "$TAG_OK" ]; then
- echo "Codeberg never received $TAG from the push mirror (waited 300s)." >&2
- echo "Not creating it here: ref writes to this repo fail, so that" >&2
- echo "would 500. Check the mirror, then re-run once the tag is there." >&2
- exit 1
- fi
- # No target_commitish: the tag exists, so the API must attach to it
- # rather than resolve a commit and mint one.
+ # Pushing the ref first and attaching with NO target_commitish is
+ # deliberate: a release POST carrying a target_commitish for a commit
+ # or tag Codeberg hasn't received yet is what produced the
+ # empty-bodied 500s. Attaching to a ref that already exists doesn't
+ # need the API to write one.
+ git tag -f "$TAG" "$SHA"
+ git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
+ "refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
import json, sys
tag, pre = sys.argv[1:3]
@@ -441,20 +465,31 @@ jobs:
"prerelease": pre == "true",
}))
PY
- # Upsert (re-run safe): a release already attached to this tag is
- # PATCHed in place, so re-running never disturbs a published release.
- 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 \
+ # Create (or update) the release. Codeberg 500s on a POST/GET against a
+ # tag it has only just received — the release request outruns the
+ # indexing of the ref we pushed a moment ago — so a single attempt can
+ # fail even though the very same call succeeds seconds later. Retry
+ # with backoff, and PATCH in place if a release already exists (re-run
+ # safe, so re-running never disturbs a published release). A 5xx body
+ # still exits curl 0, so the loop, not `set -e`, controls the flow.
+ ID=""
+ for attempt in 1 2 3 4 5 6; do
+ EXIST=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
+ if [ -n "$EXIST" ]; 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/$EXIST"
+ ID="$EXIST"; break
+ fi
+ CODE=$(curl -s -o cb-response.json -w "%{http_code}" -X POST \
-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"
+ -d @cb-payload.json "$API/releases")
+ echo "release POST attempt $attempt HTTP $CODE"
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
+ [ -n "$ID" ] && break
+ sleep $((attempt * 10))
+ done
+ if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml
new file mode 100644
index 0000000..1e1c6a0
--- /dev/null
+++ b/.gitea/workflows/renovate.yml
@@ -0,0 +1,61 @@
+name: Renovate
+
+on:
+ # Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
+ # renovate.json5 deliberately has no internal schedule (avoids double-gating).
+ schedule:
+ - cron: '0 5 * * 1'
+ # Manual run for an on-demand sweep from the Actions tab.
+ workflow_dispatch:
+
+# Never let two Renovate runs touch the repo at once.
+concurrency:
+ group: renovate
+ cancel-in-progress: false
+
+jobs:
+ renovate:
+ # Gitea only — same guard, and the same reason, as release.yaml's `detect`:
+ # this file is invisible to Codeberg only while .forgejo/ is non-empty, and
+ # a repo-write token must never run on the contributor-facing runner.
+ if: github.repository_owner == 'makiolaj'
+ runs-on: docker
+ # Run the Renovate image *as* the job container and invoke the `renovate`
+ # binary directly. The renovatebot/github-action wrapper is a thin Node
+ # action that shells out to `docker run …` — it needs a Docker CLI + socket
+ # inside the job, which the Gitea runner's plain node container has not, so
+ # it died on "Unable to locate executable file: docker". Running the image
+ # directly drops the docker-in-docker requirement entirely.
+ # Full tag pinned; Renovate's github-actions manager keeps it bumped.
+ container:
+ image: ghcr.io/renovatebot/renovate:43.232.0
+ steps:
+ - name: Run Renovate
+ run: renovate
+ env:
+ # Renovate targets Codeberg (canonical) while still RUNNING on the
+ # Gitea runner. Moving the job to Codeberg would put a repo-write
+ # token on the contributor-facing runner, which is exactly what the
+ # .forgejo/ vs .gitea/ split exists to prevent — so the token stays
+ # where the other secrets live and only the API calls cross over.
+ #
+ # Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
+ # pinned image ships a distinct forgejo platform module.
+ RENOVATE_PLATFORM: forgejo
+ RENOVATE_ENDPOINT: https://codeberg.org/api/v1
+ # Codeberg bot-account token (Gitea secret). Needs repo read/write +
+ # PR scope on jlmakiola/agendula.
+ RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
+ # Scope to this repo only — no org-wide autodiscovery.
+ RENOVATE_AUTODISCOVER: 'false'
+ RENOVATE_REPOSITORIES: '["jlmakiola/agendula"]'
+ # Commits/PRs authored as the bot, not a real maintainer. This address
+ # must be a verified email on the Codeberg bot account, otherwise the
+ # commits show up unattributed there.
+ RENOVATE_GIT_AUTHOR: 'Renovate Bot '
+ # Read-only github.com PAT (no scopes needed). Nearly every dependency
+ # is *released* on GitHub, and without this, changelog/release-note
+ # lookups hit the 60/h anonymous rate limit and PRs arrive with an
+ # empty "Release Notes" section.
+ RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
+ LOG_LEVEL: info
diff --git a/.gitignore b/.gitignore
index 05f0cb2..db92862 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,5 +53,15 @@ Thumbs.db
# F-Droid local artifacts (the pipeline generates them in CI)
/fdroid/
+# Release-pipeline scratch files. release.yaml writes these into the workspace
+# while cutting a release; a self-hosted runner reuses that workspace, so they
+# must never end up committed (release-notes.md did, through 0.3.2).
+/release-notes.md
+/payload.json
+/existing.json
+/response.json
+/cb-payload.json
+/cb-response.json
+
# KSP
.ksp/
diff --git a/.gitmodules b/.gitmodules
index 103c066..b908ca7 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
[submodule "floret-kit"]
path = floret-kit
- url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
+ url = https://codeberg.org/jlmakiola/floret-kit.git
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4bfc3ad..b4b4ed8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
+### Added
+- Agendula can now be translated. Pick or request a language on Weblate and
+ translate in the browser — the link sits at the top of the language picker in
+ **Settings → App language**. Partial translations are fine; anything
+ untranslated falls back to English.
+
+### Changed
+- Agendula's home is now **Codeberg** (`jlmakiola/agendula`) — that's where the
+ source, issues, pull requests and releases live. The Source and License links
+ in Settings, the issue-reporting link and the F-Droid metadata all point there
+ now. The self-hosted Gitea instance stays as build infrastructure.
+
## [0.3.2] - 2026-07-20
### Fixed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5a5d335..c9155d1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,10 +33,33 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
./gradlew lintDebug # Android lint (CI runs this on every PR)
```
-CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check,
+CI (`.forgejo/workflows/ci.yaml`, on Codeberg) 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.
+## Translations
+
+**Never edit a `values-*/strings.xml` file in a pull request.** Translations are
+owned by a self-hosted Weblate that writes to this repository directly, and a
+hand-edit is overwritten on the next sync.
+
+→ **[Translate Agendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
+
+Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
+picks it up and offers it to translators. Partial translations are expected and
+fine — missing keys are informational. Stale and orphaned keys are not, so run
+
+```sh
+python3 scripts/check_translations.py
+```
+
+before pushing. It reports those more clearly than lint's `MissingTranslation`
+does, and it's what the `Translations` check runs on every PR.
+
+A new language also needs one `` line in
+`app/src/main/res/xml/locales_config.xml` — that file is the single source of
+truth for both the in-app picker and the Android 13+ per-app language setting.
+
## Where to put code
| Layer | Lives in | Rule of thumb |
diff --git a/README.md b/README.md
index 5b5a127..a67a934 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@
Reads, writes, and reminds — on top of an existing tasks provider, with no own
sync stack.
+
@@ -13,7 +14,7 @@ sync stack.
-Agendula is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula).
+Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
@@ -40,6 +41,18 @@ adapter — because it builds on the provider, not on any one sync app. Google
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
+## Translations
+
+Agendula ships in English so far, and would like not to. Translations are
+managed on a self-hosted **Weblate**, and partial ones are fine — an
+untranslated string simply falls back to English.
+
+**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
+
+No coding needed: register on the Weblate server, pick (or request) a language,
+and translate the strings in your browser. You can also reach this link in the
+app from the top of **Settings → App language**.
+
## License
MIT — see [LICENSE](LICENSE).
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2b1dcb8..9698ad7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -111,6 +111,18 @@ android {
}
}
+ lint {
+ // Community translations are expected to be partial — a missing string
+ // falls back to the English base at runtime — so don't fail the build on
+ // it. Likewise a translated may not fill every CLDR quantity
+ // form its locale defines (e.g. Arabic needs "zero"); the missing form
+ // falls back to "other" at runtime, so MissingQuantity is informational
+ // too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
+ // check_translations.py guards the same invariants with clearer,
+ // translator-facing messages.
+ informational += listOf("MissingTranslation", "MissingQuantity")
+ }
+
testOptions {
unitTests {
all { it.useJUnitPlatform() }
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt
index 465f5ba..712a61d 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt
@@ -43,6 +43,7 @@ import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
+import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Circle
@@ -84,7 +85,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.floret.components.CollapsingScaffold
-import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
@@ -92,6 +92,7 @@ import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
+import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
@@ -211,19 +212,70 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) },
)
- LanguagePickerRow(
- position = Position.Middle,
- title = stringResource(R.string.settings_language),
- autoLabel = stringResource(R.string.settings_language_auto),
- localesConfig = R.xml.locales_config,
- leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
- )
+ LanguageRow(position = Position.Middle)
ReportProblemRow(position = Position.Bottom)
AppVersionText()
}
}
+/**
+ * The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the
+ * picker it opens carries a "Help translate" header, and inviting contributions
+ * right where a user goes looking for their language is app-specific framing,
+ * not a family primitive. Everything else matches that recipe.
+ */
+@Composable
+private fun LanguageRow(position: Position) {
+ val context = LocalContext.current
+ // Setting a locale recreates the activity; mirror the choice locally so the
+ // row updates instantly even before the recreation lands.
+ var current by remember { mutableStateOf(AppLanguage.currentTag()) }
+ var showDialog by remember { mutableStateOf(false) }
+
+ // null = follow the system; the rest are BCP-47 tags from locales_config.xml.
+ val options = remember { listOf(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
+
+ GroupedRow(
+ title = stringResource(R.string.settings_language),
+ summary = languageLabel(current),
+ position = position,
+ leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
+ onClick = { showDialog = true },
+ )
+
+ if (showDialog) {
+ OptionPicker(
+ title = stringResource(R.string.settings_language),
+ predictiveBack = true,
+ options = options,
+ selected = current,
+ label = { languageLabel(it) },
+ onSelect = {
+ current = it
+ AppLanguage.apply(it)
+ },
+ onDismiss = { showDialog = false },
+ // Invite contributions right where users pick their language.
+ header = {
+ val translateUrl = stringResource(R.string.about_translate_url)
+ GroupedRow(
+ title = stringResource(R.string.settings_translate),
+ summary = stringResource(R.string.settings_translate_hint),
+ position = Position.Alone,
+ leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
+ onClick = { openUrl(context, translateUrl) },
+ )
+ Spacer(Modifier.height(16.dp))
+ },
+ )
+ }
+}
+
+@Composable
+private fun languageLabel(tag: String?): String =
+ if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
+
/** Opens the project's issue tracker; no data leaves the device until submitted. */
@Composable
private fun ReportProblemRow(position: Position) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c9f7155..1f8b7cd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -190,13 +190,16 @@
Agendula app icon
App language
System default
+ Help translate
+ Add or improve a language on Weblate
Report a problem
Open the issue tracker
- https://gitea.jeanlucmakiola.de/makiolaj/agendula
+ https://codeberg.org/jlmakiola/agendula
Crash report
- https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new
- https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE
+ https://codeberg.org/jlmakiola/agendula/issues/new
+ https://codeberg.org/jlmakiola/agendula/src/branch/main/LICENSE
https://ko-fi.com/jeanlucmakiola
+ https://weblate.dev.jeanlucmakiola.de/engage/agendula/
Theme
Follow system
Light
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 4b7291c..d7c562f 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -12,7 +12,7 @@ Agendula is a Material 3 Expressive **front-end** over the OpenTasks
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
-sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
+sibling to [Calendula](https://codeberg.org/jlmakiola/calendula),
which does the same thing for `CalendarContract`.
The whole design hangs off one rule:
@@ -244,8 +244,8 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
| Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
| 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/`) |
+| CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). |
+| Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs |
---
diff --git a/docs/README.md b/docs/README.md
index 5832d04..eb146c7 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -3,7 +3,7 @@
Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
over CalDAV), with no own database or sync stack. Sibling to
-[Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the
+[Calendula](https://codeberg.org/jlmakiola/calendula). See the
top-level [`../README.md`](../README.md) for the project pitch.
## Index
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index bd6c97d..614a6a3 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -1,15 +1,18 @@
# Agendula — releasing
-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.
+Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) and a
+**Codeberg release** per version carrying the signed APK as a direct download.
+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 the releases itself. The
+parallel **Gitea release** is the changelog of record on the build instance and
+carries no APK assets.
-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
+Codeberg is the canonical forge; Gitea is build infrastructure. See
+[Two forges, one repo](#two-forges-one-repo) for how the two are wired.
+
+While Agendula is pre-1.0 (`versionName` starts with `0.`), every release is
+flagged as a **pre-release**. This happens automatically and graduates to a
stable release at `1.0.0` — no manual toggling.
---
@@ -80,35 +83,49 @@ re-running the workflow safely retries).
CI and release are split so a change is built once on its PR and only does
release work when a merge actually cuts a release:
-- **`ci.yaml`** (on `pull_request`) — the reproducible-release invariant guard
+- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — 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.
+- **`translations.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**)
+ — an SDK-free parity check (`scripts/check_translations.py`) over
+ `values-*/strings.xml`, so Weblate PRs get fast feedback. Runs on every PR
+ without a path filter, so the required `Translations / check` status is always
+ reported.
+- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC plus
+ `workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the pinned
+ Renovate image as its job container and targets Codeberg's API; the cadence
+ lives here, not in `renovate.json5`, so the two don't double-gate.
+- **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus
+ `workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and
+ checks **Codeberg** for a tag for it. Only when there isn't one 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 publish the release on
+ **Codeberg** with the signed APK + a SHA-256 checksum. Ordinary merges with no
+ version bump fall through `detect` and do nothing.
### Codeberg direct-download channel
-Alongside F-Droid, each release is mirrored to the Codeberg repo
+Alongside F-Droid, each release is published on 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.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.
+Releases aren't git objects and don't sync with the push mirror in either
+direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates
+the release over the Codeberg API, and attaches `agendula_v.apk` + its
+`.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it
+adds no trust surface. It skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset,
+but it is **not** `continue-on-error`: through 0.2.1–0.3.2 this step reported
+green while never once publishing, which is how a crash-fix release reached
+F-Droid but not the Codeberg/Obtainium users who needed it. A broken mirror
+fails the release loudly.
+
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.
+`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope
+— it pushes the tag as well as creating the release) added to Gitea Actions.
### Manual re-sign / recovery
@@ -120,6 +137,51 @@ rotation or repo recovery without publishing a new app version.
---
+## Two forges, one repo
+
+**Codeberg (`jlmakiola/agendula`) is canonical** — git, issues, PRs, tags and
+releases. The self-hosted Gitea instance is build infrastructure: it holds the
+signing key, publishes the F-Droid repo, and runs the release pipeline. Codeberg
+push-mirrors `main` and tags to Gitea, and a bumped `versionName` arriving there
+triggers `release.yaml` exactly as before.
+
+Workflows are separated by **directory**, not by conditionals. Forgejo looks in
+`.forgejo/workflows` → `.gitea/workflows` → `.github/workflows` and stops at the
+first that exists; Gitea doesn't know `.forgejo/` at all:
+
+| Directory | Runs on | Contains | Secrets |
+| --- | --- | --- | --- |
+| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
+| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | signing key, F-Droid, Hetzner, bot tokens |
+
+The line is drawn at **secrets, not at CI-vs-release**. That's what makes fork
+PRs safe: everything a contributor can trigger lives in `.forgejo/` and can
+reference no secret. Renovate stays on the Gitea runner *even though it opens
+PRs on Codeberg* — it talks to Codeberg's API rather than moving its token onto
+the contributor-facing runner. `detect` (and the Renovate job) additionally
+carries a
+`github.repository_owner == 'makiolaj'` guard, because the directory split only
+holds while `.forgejo/` is non-empty — empty it and Codeberg would fall back to
+`.gitea/` and run the release pipeline on the contributor-facing runner.
+
+Two consequences worth remembering:
+
+- **`detect` reads tags from Codeberg**, not from the Gitea instance it runs on.
+ Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by
+ the next sync until the Codeberg tag push propagates back. Asking Gitea inside
+ that window would re-cut a shipped release.
+- **Any ref that exists only on Gitea gets deleted** by the mirror. That's
+ correct under Codeberg-canonical, but don't debug a "vanished" branch without
+ remembering it.
+
+`floret-kit` is a submodule of this repo and follows the same move: `.gitmodules`
+points at `https://codeberg.org/jlmakiola/floret-kit.git`, so a clone resolves
+without reaching the personal Gitea instance. The Gitea copy is **kept** — every
+existing tag records the old submodule URL, so rebuilds of past releases still
+resolve.
+
+---
+
## Secrets (Gitea → repo Settings → Actions → Secrets)
The workflow fails loudly if the F-Droid ones are missing — it will **never**
@@ -133,7 +195,9 @@ user's pinned repo).
| `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. |
+| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — pushes the tag to Codeberg, creates the release there and uploads the APK/checksum. If unset the step skips; if set and failing, the release fails. |
+| `RENOVATE_TOKEN` | Codeberg bot-account token — repo read/write + PR scope on `jlmakiola/agendula`. Used only by `renovate.yml`. |
+| `GITHUB_COM_TOKEN` | Read-only github.com PAT (no scopes). Without it Renovate's changelog lookups hit the 60/h anonymous rate limit and PRs arrive with empty release notes. |
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
diff --git a/fdroid-metadata/de.jeanlucmakiola.agendula.yml b/fdroid-metadata/de.jeanlucmakiola.agendula.yml
index ed74069..6f7f9d9 100644
--- a/fdroid-metadata/de.jeanlucmakiola.agendula.yml
+++ b/fdroid-metadata/de.jeanlucmakiola.agendula.yml
@@ -6,5 +6,5 @@ Summary: A modern Material 3 Expressive task app for Android.
Categories:
- Time
-SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/agendula
-IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues
+SourceCode: https://codeberg.org/jlmakiola/agendula
+IssueTracker: https://codeberg.org/jlmakiola/agendula/issues
diff --git a/release-notes.md b/release-notes.md
deleted file mode 100644
index 9bfebc3..0000000
--- a/release-notes.md
+++ /dev/null
@@ -1,5 +0,0 @@
-### Fixed
-- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
- release build stripped a constructor that the background-work scheduler needs
- to open its database, and that happens before the app draws anything.
-
diff --git a/renovate.json5 b/renovate.json5
new file mode 100644
index 0000000..a036746
--- /dev/null
+++ b/renovate.json5
@@ -0,0 +1,122 @@
+{
+ $schema: "https://docs.renovatebot.com/renovate-schema.json",
+
+ extends: [
+ "config:recommended",
+ // chore(deps): … — match the repo's conventional-commit style.
+ ":semanticCommits",
+ ],
+
+ // `config:recommended` brings in mergeConfidence:age-confidence-badges, whose
+ // Age column is a Mend badge. Mend's Merge Confidence index only covers Maven
+ // Central: org.jetbrains.kotlin, junit, truth, turbine et al resolve, but
+ // every androidx/compose artifact lives on Google's Maven repo and comes back
+ // as a grey UNKNOWN — i.e. most of this project. Renovate already knows the
+ // real answer, since it derives release timestamps itself for the
+ // minimumReleaseAge rules below (Google Maven serves `last-modified` on its
+ // POMs), so take the age from there and leave Mend to the Confidence column,
+ // which still carries signal for the Maven Central half.
+ prBodyDefinitions: {
+ Age: "{{#if releaseTimestamp}}{{{newVersionAgeInDays}}} d{{else}}unknown{{/if}}",
+ },
+ // Default heading links to the Merge Confidence docs; this column is ours now.
+ prBodyHeadingDefinitions: {
+ Age: "Age",
+ },
+
+ // No automerge: a dependency bump goes through the same review (and, for
+ // anything touching the build, the same on-device check) as a feature
+ // before it can ride a release — see docs/RELEASING.md and the mandatory
+ // `scripts/verify-release.sh` gate.
+ automerge: false,
+
+ // One reviewable surface; the dashboard issue lists everything pending.
+ dependencyDashboard: true,
+
+ // The cooling-off periods below are advisory, not a gate. "flexible" still
+ // prefers a version that has cleared its window, but when every candidate is
+ // too young it opens the PR at the newest one anyway, so merging early stays
+ // a judgement call. (The default, "strict", would suppress the PR entirely
+ // until a release aged in.) A still-young branch carries a yellow
+ // `renovate/stability-days` check so it's visible which side of the line
+ // it's on; with automerge off, nothing acts on that check by itself.
+ //
+ // NOT "none": that short-circuits the candidate loop in filter-checks.ts, and
+ // that loop is what calls postprocessRelease — the only thing that fetches a
+ // Maven artifact's Last-Modified header. Skipping it leaves releaseTimestamp
+ // unset, which empties the Age column and quietly makes minimumReleaseAge and
+ // the stability check no-ops, since both need that timestamp to compare.
+ internalChecksFilter: "flexible",
+
+ labels: ["dependencies"],
+ prConcurrentLimit: 5,
+ prHourlyLimit: 0,
+
+ // Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml,
+ // Mondays) — no internal `schedule` here, so the two don't double-gate and
+ // silently skip a run.
+
+ // Workflows are split by forge and neither directory is .github: CI lives in
+ // .forgejo/workflows (Codeberg) and the release/renovate jobs in
+ // .gitea/workflows (Gitea). Extend the github-actions manager (same syntax)
+ // to watch both — otherwise the pinned Renovate image tag and the action
+ // versions in either file would never get bumped. See docs/RELEASING.md.
+ // `fileMatch` is deprecated; the replacement takes the regex delimited, and
+ // Renovate's config migration was already rewriting this on every run.
+ "github-actions": {
+ managerFilePatterns: ["/^\\.(gitea|forgejo)/workflows/[^/]+\\.ya?ml$/"],
+ },
+
+ packageRules: [
+ // Cooling-off period, scaled by blast radius: how long a release should
+ // have been out (and un-yanked, un-hotfixed) before it's considered
+ // settled. Advisory only — see `internalChecksFilter` above.
+ {
+ matchUpdateTypes: ["major"],
+ minimumReleaseAge: "30 days",
+ },
+ {
+ matchUpdateTypes: ["minor"],
+ minimumReleaseAge: "20 days",
+ },
+ {
+ matchUpdateTypes: ["patch", "digest", "pin", "rollback"],
+ minimumReleaseAge: "10 days",
+ },
+ // material3 is deliberately pinned to the 1.5 *alpha* line for the
+ // Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
+ // but keep it in its own PR, reviewed in isolation; revisit the pin when
+ // 1.5.0 stable lands.
+ {
+ matchPackageNames: ["androidx.compose.material3:material3"],
+ ignoreUnstable: false,
+ groupName: "material3 (alpha)",
+ },
+ // Test-only deps: group into one low-noise PR.
+ {
+ matchPackageNames: [
+ "org.junit.jupiter:**",
+ "org.junit.platform:**",
+ "com.google.truth:**",
+ "app.cash.turbine:**",
+ "androidx.test:**",
+ "androidx.test.espresso:**",
+ "androidx.test.ext:**",
+ ],
+ groupName: "test dependencies",
+ },
+ // Last word on the PR table. The merge-confidence preset sets prBodyColumns
+ // from inside a packageRule of its own, and only for the datasources Mend
+ // supports — so a plain top-level prBodyColumns would lose to it for maven
+ // deps, and the Gradle wrapper / Actions / container bumps would keep the
+ // default columns and show no age at all. A rule declared after it wins,
+ // and gives every PR the same table.
+ // "Pending" earns its place under a flexible filter: when the bump lands on
+ // a version that has cleared its window but a newer one hasn't, that newer
+ // version is named here rather than silently withheld.
+ {
+ matchPackageNames: ["*"],
+ prBodyColumns: ["Package", "Type", "Change", "Age", "Pending", "Confidence"],
+ },
+ ],
+}
diff --git a/scripts/check_translations.py b/scripts/check_translations.py
new file mode 100755
index 0000000..fc3699c
--- /dev/null
+++ b/scripts/check_translations.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""Validate Android translation resources against the base strings.xml.
+
+Community translations live in ``app/src/main/res/values-/strings.xml``
+and are produced via Weblate. This guard keeps incoming translation PRs honest:
+
+ * every translation file must be well-formed XML;
+ * a translation must not define keys absent from the base — those are stale
+ keys left behind after a rename/removal upstream;
+ * a translation must not translate strings marked ``translatable="false"`` in
+ the base (URLs, IDs and the like).
+
+Missing keys are *allowed* and only reported as coverage: a missing string
+falls back to the English base at runtime, so partial translations are fine
+(this mirrors the lint config, which downgrades ``MissingTranslation``).
+
+Exits non-zero if any error is found. Errors are emitted as Gitea/GitHub
+Actions ``::error`` annotations so they surface inline on the PR.
+"""
+from __future__ import annotations
+
+import sys
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+RES_DIR = Path("app/src/main/res")
+BASE = RES_DIR / "values" / "strings.xml"
+RESOURCE_TAGS = ("string", "plurals", "string-array")
+
+
+def entries(path: Path) -> dict[str, bool]:
+ """Map resource name -> is-translatable for every entry in ``path``."""
+ root = ET.parse(path).getroot()
+ return {
+ el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
+ for el in root
+ if el.tag in RESOURCE_TAGS and "name" in el.attrib
+ }
+
+
+def main() -> int:
+ if not BASE.exists():
+ print(f"::error::base resource file {BASE} not found", file=sys.stderr)
+ return 1
+
+ base = entries(BASE)
+ base_keys = set(base)
+ nontranslatable = {name for name, ok in base.items() if not ok}
+ translatable_total = len(base_keys - nontranslatable)
+
+ files = sorted(RES_DIR.glob("values-*/strings.xml"))
+ if not files:
+ print("No translation files found (values-*/strings.xml).")
+ return 0
+
+ errors = 0
+ for path in files:
+ locale = path.parent.name[len("values-"):]
+ try:
+ translated = entries(path)
+ except ET.ParseError as exc:
+ print(f"::error file={path}::{locale}: malformed XML: {exc}")
+ errors += 1
+ continue
+
+ keys = set(translated)
+ stale = sorted(keys - base_keys)
+ translated_fixed = sorted(keys & nontranslatable)
+ missing = base_keys - nontranslatable - keys
+
+ for name in stale:
+ print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
+ errors += 1
+ for name in translated_fixed:
+ print(
+ f"::error file={path}::{locale}: key '{name}' is translatable=\"false\" "
+ "in the base and must not be translated"
+ )
+ errors += 1
+
+ covered = translatable_total - len(missing)
+ pct = covered * 100 // translatable_total if translatable_total else 100
+ verdict = "OK" if not (stale or translated_fixed) else "FAIL"
+ print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
+
+ if errors:
+ print(f"\n{errors} translation error(s) found.", file=sys.stderr)
+ return 1
+ print("\nAll translation files are consistent with the base.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())