From 217d5d7afdb67dfabc1c05b580fcbc0c2265e7e0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 1 Aug 2026 16:34:20 +0200 Subject: [PATCH] Move the canonical forge to Codeberg, and add Renovate + Weblate (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline. Ports the setup Calendula already runs on, adapted where Agendula genuinely differs. **This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home. ### 1 · Workflows split by directory Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync: | Directory | Runs on | Contains | Secrets | | --- | --- | --- | --- | | `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** | | `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them | The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API. CI also gains three fixes: an explicit `SKIP_RE` skip-list that names the build-relevant files in the log, base-ref normalisation, and a fully-qualified `android-actions/setup-android` — Codeberg resolves bare `uses:` refs against `data.forgejo.org`, which does not carry that action. ### 2 · Three release-pipeline safety changes - `detect` and the Renovate job get an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running these on the contributor-facing runner, without secrets. - `detect` now 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 reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not. - **The Codeberg publish step pushes the tag itself** instead of waiting for it to arrive by mirror. That wait was correct while Gitea mirrored *to* Codeberg; under Codeberg-canonical the mirror runs the other way and it would never resolve. Attaching the release to an already-pushed ref (no `target_commitish`) is what avoids the empty-bodied 500s, and the create call retries with backoff because Codeberg 500s on a tag it has only just received. The step stays **fail-loud**, not `continue-on-error` — it reported green through 0.2.1–0.3.2 while never once publishing, and that must not be possible again. ### 3 · Renovate `renovate.json5` plus a Gitea-side job targeting Codeberg's API. `managerFilePatterns` covers **both** workflow directories, so the pinned Renovate image tag and the action versions in either file keep getting bumped. Needs two new Gitea secrets: `RENOVATE_TOKEN` (Codeberg bot, repo read/write + PR scope) and `GITHUB_COM_TOKEN` (read-only github.com PAT, for changelog lookups). ### 4 · Weblate A parity check (`scripts/check_translations.py`) runs on every PR without a path filter, so the required `Translations` status is always reported. Partial translations are expected, so `MissingTranslation` and `MissingQuantity` become informational — `ExtraTranslation` stays fatal. Agendula had no `lint` block at all, so the first locale to land would otherwise have failed the build. **Settings → App language** now opens a picker carrying a "Help translate" header. That is why it drops floret-kit's `LanguagePickerRow` for a local row: the shared recipe has no `header` slot, and the framing is app-specific rather than a family primitive. ### 5 · Links repointed In-app Source / License / report-issue URLs, F-Droid metadata, README (now with a Codeberg CI badge), and the docs. `floret-kit` follows suit — `.gitmodules` points at `codeberg.org/jlmakiola/floret-kit`, so a clone no longer needs to reach the personal Gitea instance to resolve it. The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases still resolve. ### 6 · Housekeeping Drops `release-notes.md` — a release-pipeline scratch file that got committed — and gitignores the five others the release job writes into the workspace. ### Not in this PR The Codeberg → Gitea push mirror, the Weblate component, and the Codeberg bot account (all browser-side). Until the mirror is flipped, merging this does **not** reach the Gitea runner. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/2 --- .../ISSUE_TEMPLATE/bug_report.md | 0 .forgejo/ISSUE_TEMPLATE/config.yml | 24 +++ .../ISSUE_TEMPLATE/crash_report.md | 0 .../ISSUE_TEMPLATE/feature_request.md | 0 .../ISSUE_TEMPLATE/question.md | 0 .forgejo/PULL_REQUEST_TEMPLATE.md | 42 +++++ {.gitea => .forgejo}/workflows/ci.yaml | 36 +++- .forgejo/workflows/translations.yaml | 39 +++++ .gitea/workflows/release.yaml | 155 +++++++++++------- .gitea/workflows/renovate.yml | 61 +++++++ .gitignore | 10 ++ .gitmodules | 2 +- CHANGELOG.md | 12 ++ CONTRIBUTING.md | 25 ++- README.md | 15 +- app/build.gradle.kts | 12 ++ .../agendula/ui/settings/SettingsScreen.kt | 68 +++++++- app/src/main/res/values/strings.xml | 9 +- docs/ARCHITECTURE.md | 6 +- docs/README.md | 2 +- docs/RELEASING.md | 126 ++++++++++---- .../de.jeanlucmakiola.agendula.yml | 4 +- release-notes.md | 5 - renovate.json5 | 122 ++++++++++++++ scripts/check_translations.py | 94 +++++++++++ 25 files changed, 747 insertions(+), 122 deletions(-) rename {.gitea => .forgejo}/ISSUE_TEMPLATE/bug_report.md (100%) create mode 100644 .forgejo/ISSUE_TEMPLATE/config.yml rename {.gitea => .forgejo}/ISSUE_TEMPLATE/crash_report.md (100%) rename {.gitea => .forgejo}/ISSUE_TEMPLATE/feature_request.md (100%) rename {.gitea => .forgejo}/ISSUE_TEMPLATE/question.md (100%) create mode 100644 .forgejo/PULL_REQUEST_TEMPLATE.md rename {.gitea => .forgejo}/workflows/ci.yaml (74%) create mode 100644 .forgejo/workflows/translations.yaml create mode 100644 .gitea/workflows/renovate.yml delete mode 100644 release-notes.md create mode 100644 renovate.json5 create mode 100755 scripts/check_translations.py 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.

+CI Android 10+ Kotlin + Compose Material 3 Expressive @@ -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())