10 Commits
Author SHA1 Message Date
Jean-Luc Makiolaandmakiolaj fe0c7d83a9 ci(release): beta releases as Codeberg-only pre-releases (#38)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 52s
### What this changes

Adds beta releases. Pushing a `release/*` branch whose committed `versionName` is `X.Y.Z-beta.N` makes the new `.gitea/workflows/beta.yaml` run the unit tests, build and sign the APK with the app key, and publish it as a **Codeberg pre-release** (APK + `.sha256`), plus a Gitea pre-release with the R8 mapping. F-Droid (self-hosted and official) and Play never get a beta; Obtainium only offers it with *Include prereleases* on.

- **`scripts/version_info.sh`** is the single source for `versionName` → `versionCode`, used by `release.yaml`, `beta.yaml`, the changelog sync and the store-listing check. From 1.1.0: `X*1000000 + Y*10000 + Z*100 + N` for betas (N = 1–98), `+ 99` for stable, so `1.1.0-beta.1` → `1010001`, `1.1.0` → `1010099`. All 1.0.x versions keep the legacy formula, so `release/v1.0.1` (code `10001`) stays valid.
- **Shared publish scripts:** `scripts/publish_codeberg_release.sh`, `scripts/publish_gitea_release.sh` and `scripts/release_notes.sh`, moved out of `release.yaml`. The stable path behaves as before.
- **Guards:**
  - CI fails a PR whose committed `versionCode` doesn't match its `versionName`.
  - CI fails a PR into `main` that carries a beta version.
  - `release.yaml`'s `detect` refuses a beta on `main` as a backstop.
  - `beta.yaml` refuses a beta of a version that has already shipped as stable.
  - Betas get no store What's New file.
- **Docs:** "Cutting a beta" and the versionCode table in `docs/RELEASING.md`; a note on beta tags in `docs/fdroid-official/README.md`; how to opt in to betas in the README.

### Why

To ship a test build of an upcoming version (e.g. 1.1.0) to opted-in testers before the stable release, without it reaching F-Droid or Play users.

### How it was tested

- `scripts/version_info.sh` against stable, beta, legacy and invalid version names.
- Both publish scripts against a mock forge API: create, re-run (PATCH plus asset replacement), Codeberg's 500-then-retry path, and the skip when no token is set.
- A scratch copy with `1.1.0-beta.1` committed: the version check passes, the changelog sync and `check_store_listing.py --complete` pass without a What's New, the PR-into-main guard trips, and a wrong `versionCode` is rejected.
- `sync_changelog_to_fastlane.sh` and `check_store_listing.py` (with and without `--complete`) still pass on the current `1.0.0`.
- All three workflow files parse as YAML.

Not run on the real runners yet. The first beta push is the live test of `beta.yaml`, which assumes a mirrored branch push starts a workflow on Gitea, the same way pushes to `main` already do.

### Checklist

- [x] No `versionName` / `versionCode` bump
- [x] No `values-*/strings.xml` touched
- [x] `CHANGELOG.md` not updated: this is release infrastructure, not a user-visible change

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/38
2026-10-05 18:47:57 +02:00
Jean-Luc Makiolaandmakiolaj f92f9bcdad chore(renovate): rebase conflicted PRs on every merge to main (#36)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 10s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 53s
Renovate only rebases while it runs, and it ran once a week, so merging one dependency PR left the others conflicted until the next Monday.

- `renovate.yml`: also run on push to `main` (mirror syncs fire push events, like `release.yaml`).
- `renovate.json5`: new PRs only in a Monday window (`* 4-6 * * 1`) around the weekly cron; on-merge runs only rebase conflicted PRs (`rebaseWhen: "conflicted"`).

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/36
2026-10-05 13:30:11 +02:00
Jean-Luc Makiola 53d5e2c5aa Translations update from Weblate (#26)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/26
2026-10-02 18:13:09 +02:00
Robin Schanbacher 6888965f5a Translated using Weblate (German)
Currently translated at 62.4% (359 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-01 21:35:13 +00:00
Robin Schanbacher f1ba21e7fa Translated using Weblate (German)
Currently translated at 57.3% (330 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-01 19:19:02 +00:00
Robin Schanbacher 8abc44bbdf Translated using Weblate (German)
Currently translated at 57.0% (328 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-09-30 20:57:48 +00:00
Robin Schanbacher a6e3a35f04 Translated using Weblate (German)
Currently translated at 45.0% (259 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-09-29 20:37:10 +00:00
Jean-Luc Makiolaandmakiolaj a3de9f3d93 docs(readme): show the store screenshots (#24)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Adds the five phone screenshots from `fastlane/metadata/android/en-US/images/phoneScreenshots/` to the README header, as a centered row beneath the badges.

The README references the fastlane files directly rather than copies, so the README and the store listings stay in sync. Each image has descriptive alt text.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/24
2026-09-28 14:11:27 +02:00
Jean-Luc Makiola c823243e53 Merge pull request 'docs: sync the F-Droid recipe with the submitted one' (#22) from docs/fdroid-recipe into main
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
2026-09-24 22:35:14 +02:00
makiolaj d5a74213d6 docs: sync the F-Droid recipe with the submitted one
Pinned to the v1.0.0 commit hash as fdroiddata requires; the README now
tracks the submission (fdroiddata!49998) instead of the steps to make it.
2026-09-24 22:32:01 +02:00
17 changed files with 943 additions and 245 deletions
+19 -1
View File
@@ -37,11 +37,29 @@ jobs:
- name: Reproducible-release invariant - name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh run: bash scripts/check_reproducible_release.sh
# The committed versionName must parse (X.Y.Z or X.Y.Z-beta.N) and the
# committed versionCode must be the one it derives: the official F-Droid
# repo builds the tag as committed. And a beta must never reach main,
# where the release pipeline would ship it to F-Droid and Play; betas are
# cut from release/* branches (docs/RELEASING.md).
- name: Committed version is well-formed
env:
BASE: ${{ github.base_ref }}
run: |
set -e
bash scripts/version_info.sh --check
if [ "${BASE#refs/heads/}" = "main" ] && [ "$(bash scripts/version_info.sh channel)" = "beta" ]; then
echo "ERROR: versionName $(bash scripts/version_info.sh version) is a beta." >&2
echo "Set the stable version (and its versionCode) before merging into main." >&2
exit 1
fi
# Also cheap, also always-on. Two failures in one: the script exits # Also cheap, also always-on. Two failures in one: the script exits
# non-zero if this version's changelog is over the 500-character limit, # non-zero if this version's changelog is over the 500-character limit,
# and the porcelain check below catches a version with no committed # and the porcelain check below catches a version with no committed
# changelog, which would otherwise ship the CHANGELOG.md section instead # changelog, which would otherwise ship the CHANGELOG.md section instead
# of a hand-written summary. # of a hand-written summary. A beta version passes both: it ships no
# What's New, so the script writes nothing for it.
- name: Changelog fits the stores, and is committed - name: Changelog fits the stores, and is committed
run: | run: |
set -e set -e
+206
View File
@@ -0,0 +1,206 @@
name: Beta — Codeberg pre-release
# A beta is cut by pushing a release branch whose committed versionName is
# X.Y.Z-beta.N (see docs/RELEASING.md). Same model as release.yaml: the
# committed version is the trigger and the vX.Y.Z-beta.N tag is an output. If
# no tag exists for that version yet, this runs the unit tests, builds and
# signs the APK with the app key, records a Gitea pre-release (with the R8
# mapping) and publishes a Codeberg pre-release with the APK + SHA-256.
#
# Deliberately nothing else. A beta never reaches the F-Droid repos or Play:
# Obtainium hides pre-releases unless a user opts in, the official F-Droid
# recipe only picks up `^v[0-9.]+$` tags, and Codeberg's "latest release"
# skips pre-releases. Pushes of a release branch carrying a stable version
# (the usual state) fall through `detect` and do nothing.
#
# Lives in .gitea/workflows next to release.yaml for the same reason: it needs
# the app signing key, which only the self-hosted Gitea instance holds.
on:
push:
branches: ['release/**']
concurrency:
group: beta
cancel-in-progress: false
jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: docker
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and whether it is a new beta
id: v
env:
# Codeberg, not Gitea: see the same lookup in release.yaml.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
run: |
set -e
INFO=$(bash scripts/version_info.sh)
echo "$INFO"
echo "$INFO" >> "$GITHUB_OUTPUT"
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
BASE=$(echo "$INFO" | sed -n 's/^base_version=//p')
if [ "$(echo "$INFO" | sed -n 's/^channel=//p')" != "beta" ]; then
echo "versionName $VERSION is not a beta — nothing to do."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fatal on anything but a clean 200/404, as in release.yaml: guessing
# "no tag" during an outage would re-cut a published beta.
tag_status() {
curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/$1" || echo 000
}
# A beta of a version that already shipped stable would carry a lower
# versionCode than the stable one: nobody could install it over it.
case "$(tag_status "v$BASE")" in
200)
echo "v$BASE already shipped as stable; a beta of it is pointless. Bump the version." >&2
exit 1 ;;
404) ;;
*) echo "Codeberg tag lookup for v$BASE failed — refusing to guess." >&2; exit 1 ;;
esac
STATUS=$(tag_status "v$VERSION")
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_beta=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the beta."
echo "is_beta=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 published beta." >&2
exit 1
;;
esac
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager \
"platform-tools" \
"platforms;android-37.0" \
"build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install jq
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Pin versionCode to versionName
run: |
set -e
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts
- name: Unit tests
run: ./gradlew testDebugUnitTest
# The real app key, same as a stable release: a beta has to update in
# place to the next beta and to the stable version.
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
run: |
mkdir -p app
echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks
cat > key.properties <<EOF
storePassword=$KEY_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
EOF
- name: Build release APK
run: ./gradlew assembleRelease
# Notes = a `## [X.Y.Z-beta.N]` section if there is one, else
# `## [Unreleased]`. Gitea creates the tag at this commit.
- name: Create tag + Gitea pre-release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -e
bash scripts/release_notes.sh "$VERSION" > release-notes.md
cat release-notes.md
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
MAPPING=app/build/outputs/mapping/release/mapping.txt \
bash scripts/publish_gitea_release.sh
# The point of the whole workflow, so NOT continue-on-error.
- name: Publish pre-release to Codeberg
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
SHA: ${{ github.sha }}
run: |
set -e
TAG="v$VERSION" PRERELEASE=true NOTES_FILE=release-notes.md \
APK=app/build/outputs/apk/release/app-release.apk \
bash scripts/publish_codeberg_release.sh
+34 -177
View File
@@ -7,7 +7,9 @@ name: Release — F-Droid repo + Gitea/Codeberg release + Play
# the release on Codeberg with the signed APK + a SHA-256 checksum as a # 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 # direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do # trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing. # nothing. Betas (X.Y.Z-beta.N) never come through here: beta.yaml cuts them
# from release/* branches as Codeberg-only pre-releases, and `detect` refuses
# one that reaches main.
# #
# A trailing `play` job then uploads the App Bundle to Google Play. It is last # A trailing `play` job then uploads the App Bundle to Google Play. It is last
# and separate because Play can reject a good build for reasons the pipeline # and separate because Play can reject a good build for reasons the pipeline
@@ -73,23 +75,25 @@ jobs:
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
run: | run: |
set -e set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) # versionName -> versionCode, channel and the pre-release flag (set
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi # while MAJOR was 0) all come from the one script beta.yaml uses too.
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3) INFO=$(bash scripts/version_info.sh)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} echo "$INFO"
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) echo "$INFO" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT" VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT" CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release." echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT" echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# Betas ship from release/* branches via beta.yaml and must never be
# cut here: this path publishes to F-Droid and Play. CI blocks such a
# PR into main; this is the backstop if one gets through anyway.
if [ "$CHANNEL" != "stable" ]; then
echo "versionName $VERSION on main is a beta. Set the stable version before merging to main." >&2
exit 1
fi
# A tag for this version already existing means the release shipped on # A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release. # an earlier push; do nothing. Absent => this merge cuts the release.
# #
@@ -196,8 +200,8 @@ jobs:
run: chmod +x ./gradlew run: chmod +x ./gradlew
# The committed versionName is the source of truth. Pin versionCode to the # The committed versionName is the source of truth. Pin versionCode to the
# value derived from it so the published APK's code is always # value scripts/version_info.sh derives from it, so the published APK's
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten. # code follows the scheme even if the committed code was forgotten.
- name: Pin versionCode to versionName - name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
run: | run: |
@@ -332,6 +336,8 @@ jobs:
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at # Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# this commit, so the tag only ever marks a fully-shipped release (and a # this commit, so the tag only ever marks a fully-shipped release (and a
# failure before here leaves no tag, so re-running the workflow retries). # failure before here leaves no tag, so re-running the workflow retries).
# Also attaches the R8 mapping (best-effort) so user crash stacktraces
# stay deobfuscatable. Notes = this version's CHANGELOG section.
- name: Create tag + Gitea release - name: Create tag + Gitea release
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
env: env:
@@ -340,85 +346,17 @@ jobs:
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
run: | run: |
set -e set -e
TAG="v$VERSION" bash scripts/release_notes.sh "$VERSION" > release-notes.md
# Notes = this version's CHANGELOG section. TAG="v$VERSION" NOTES_FILE=release-notes.md \
awk -v ver="$VERSION" ' MAPPING=app/build/outputs/mapping/release/mapping.txt \
$0 ~ "^## \\[" ver "\\]" { flag = 1; next } bash scripts/publish_gitea_release.sh
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true",
}))
PY
# Upsert (re-run safe): PATCH if a release for the tag already exists,
# else POST a new one (which also creates the tag at target_commitish).
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @payload.json "$API/releases")
OK=201
fi
cat response.json
if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# Publish the release on Codeberg, which is canonical for tags and # Publish the release on Codeberg, which is canonical for tags and
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags # releases (see docs/RELEASING.md): push the tag there and attach the
# to Gitea, but releases aren't git objects and don't sync in either # signed APK plus a SHA-256 checksum as the direct-download channel for
# direction — so this step pushes the tag straight to Codeberg and creates # users who don't want F-Droid. The APK is identical to the F-Droid one
# the release there over the API, attaching the signed APK plus a SHA-256 # (same app key), so this adds no trust surface. Needs the
# checksum as the direct-download channel for users who don't want # CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
# 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 - name: Publish release to Codeberg
if: env.IS_RELEASE == 'true' if: env.IS_RELEASE == 'true'
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2, # NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
@@ -431,91 +369,10 @@ jobs:
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
run: | run: |
set -e set -e
if [ -z "${TOKEN:-}" ]; then [ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish." TAG="v$VERSION" NOTES_FILE=release-notes.md \
exit 0 APK=app/build/outputs/apk/release/app-release.apk \
fi bash scripts/publish_codeberg_release.sh
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="agendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# 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.
#
# 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]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": pre == "true",
}))
PY
# 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")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
[ -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
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
# Play takes an App Bundle, not the APK: a second artifact from the same # Play takes an App Bundle, not the APK: a second artifact from the same
# source and signing config. Play treats the release key only as the # source and signing config. Play treats the release key only as the
+11 -2
View File
@@ -1,8 +1,17 @@
name: Renovate name: Renovate
on: on:
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's # Every merge to main. Mirror syncs from Codeberg fire push events here (the
# renovate.json5 deliberately has no internal schedule (avoids double-gating). # same trigger release.yaml relies on), so a merged Renovate PR is followed
# within minutes by a run that rebases the sibling PRs it just conflicted —
# most bumps touch gradle/libs.versions.toml. Outside renovate.json5's
# `schedule` window such a run only maintains existing branches
# (updateNotScheduled), it never opens new PRs. Renovate's own rebases push
# to renovate/* branches, not main, so this cannot loop.
push:
branches: [main]
# Weekly sweep for new updates. Mondays 05:00 UTC, inside the schedule window
# in renovate.json5 — keep the two in step.
schedule: schedule:
- cron: '0 5 * * 1' - cron: '0 5 * * 1'
# Manual run for an on-demand sweep from the Actions tab. # Manual run for an on-demand sweep from the Actions tab.
+10
View File
@@ -20,6 +20,14 @@ required.</p>
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a> <a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p> </p>
<p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" alt="Home screen: today's progress, overdue and upcoming tasks, and your lists" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" alt="Onboarding: sync with your own CalDAV server" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" alt="Upcoming list with a task expanded into its subtasks" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" alt="Task detail: a monthly recurring task with a reminder and priority" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" alt="Reminder notification with Done and Snooze actions" width="18%">
</p>
</div> </div>
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula). Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
@@ -111,6 +119,8 @@ APK and a `.sha256` checksum attached — the same APK the F-Droid repository
serves. For automatic updates from there, use serves. For automatic updates from there, use
**[Obtainium](https://github.com/ImranR98/Obtainium)** and **[Obtainium](https://github.com/ImranR98/Obtainium)** and
**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**. **[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
Betas of upcoming versions are published there too, as pre-releases; to test
them, switch on *Include prereleases* for Agendula in Obtainium.
### Build from source ### Build from source
+6 -4
View File
@@ -26,10 +26,12 @@ android {
// These committed values ARE the source of truth for a release: merging // These committed values ARE the source of truth for a release: merging
// a bumped versionName into main triggers .gitea/workflows/release.yaml, // a bumped versionName into main triggers .gitea/workflows/release.yaml,
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as // branch instead cuts a Codeberg-only pre-release (beta.yaml).
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the // versionCode is derived from versionName by scripts/version_info.sh
// pipeline graduates it on its own. See docs/RELEASING.md. // (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 10000 versionCode = 10000
versionName = "1.0.0" versionName = "1.0.0"
+246 -6
View File
@@ -82,7 +82,7 @@
<string name="lists_header">Listen</string> <string name="lists_header">Listen</string>
<string name="new_task">Neue Aufgabe</string> <string name="new_task">Neue Aufgabe</string>
<string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string> <string name="lists_failure">Deine Aufgaben konnten nicht gelesen werden.</string>
<string name="lists_empty">Keine Aufgaben-Listen bisher. Füge eine in deiner Aufgaben-App hinzu oder mit der + Schaltfläche.</string> <string name="lists_empty">Keine Aufgaben-Listen bisher.</string>
<string name="smart_today">Heute</string> <string name="smart_today">Heute</string>
<string name="smart_overdue">Überfällig</string> <string name="smart_overdue">Überfällig</string>
<string name="smart_upcoming">Anstehend</string> <string name="smart_upcoming">Anstehend</string>
@@ -99,23 +99,23 @@
<string name="reminder_channel_name">Aufgaben Erinnerungen</string> <string name="reminder_channel_name">Aufgaben Erinnerungen</string>
<string name="reminder_channel_desc">Benachrichtigungen für fällige Aufgaben.</string> <string name="reminder_channel_desc">Benachrichtigungen für fällige Aufgaben.</string>
<string name="onboarding_no_provider_title">Eine Task-App wird benötigt</string> <string name="onboarding_no_provider_title">Eine Task-App wird benötigt</string>
<string name="onboarding_no_provider_body">Agendula zeigt und bearbeitet die Aufgaben, die in OpenTasks oder tasks.org gespeichert sind und per DAVx5 synchronisiert werden. Installiere eine davon, um zu starten.</string> <string name="onboarding_no_provider_body">Es ist keine App installiert, in der man Aufgaben speichern könnte. Installieren OpenTasks oder tasks.org oder wechsel zu Agendulas eigenem Speicher.</string>
<string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string> <string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string> <string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
<string name="onboarding_permission_button">Zugriff gewähren</string> <string name="onboarding_permission_button">Zugriff gewähren</string>
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string> <string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
<string name="reminder_onboarding_body">Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist.</string> <string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string>
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string> <string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
<string name="reminder_onboarding_skip_button">Nicht jetzt</string> <string name="reminder_onboarding_skip_button">Nicht jetzt</string>
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string> <string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
<string name="reminder_benefit_delivery_body">Es plant eine Benachrichtigung für jede Aufgabe mit einem Fälligkeitsdatum.</string> <string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string>
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string> <string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
<string name="reminder_benefit_timing_body">Wähle in den Einstellungen, wie weit im Voraus du erinnert werden möchtest.</string> <string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string>
<string name="reminder_benefit_reversible_title">Jederzeit änderbar</string> <string name="reminder_benefit_reversible_title">Jederzeit änderbar</string>
<string name="reminder_benefit_reversible_body">Schalte Erinnerungen aus, wann immer du willst — es ist nur ein Schalter.</string> <string name="reminder_benefit_reversible_body">Schalte Erinnerungen aus, wann immer du willst — es ist nur ein Schalter.</string>
<string name="settings_title">Einstellungen</string> <string name="settings_title">Einstellungen</string>
<string name="settings_section_appearance">Darstellung</string> <string name="settings_section_appearance">Darstellung</string>
<string name="settings_appearance_subtitle">Theme und Farbe</string> <string name="settings_appearance_subtitle">Theme, Farbe und Zeitformat</string>
<string name="settings_section_task_form">Aufgaben-Formular</string> <string name="settings_section_task_form">Aufgaben-Formular</string>
<string name="settings_task_form_subtitle">Standard Felder, Liste und Unter-Aufgaben</string> <string name="settings_task_form_subtitle">Standard Felder, Liste und Unter-Aufgaben</string>
<string name="settings_reminders_subtitle">Benachrichtigungen für fällige Aufgaben</string> <string name="settings_reminders_subtitle">Benachrichtigungen für fällige Aufgaben</string>
@@ -169,4 +169,244 @@
</plurals> </plurals>
<string name="settings_translate">Hilf beim Übersetzen</string> <string name="settings_translate">Hilf beim Übersetzen</string>
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string> <string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
<string name="task_completed">Fertig</string>
<string name="cd_repeats">Wiederholt</string>
<string name="shortcut_new_task">Neue Aufgabe</string>
<string name="shortcut_today">Heute</string>
<string name="qs_tile_new_task">Neue Aufgabe</string>
<string name="lists_reorder">Liste umsortieren</string>
<string name="lists_reorder_done">Ferig umsortiert</string>
<string name="lists_reorder_handle">Ziehen zum Verschieben</string>
<string name="sort_title">Sortieren nach</string>
<string name="sort_due">Fälligkeitsdatum</string>
<string name="sort_priority">Priorität</string>
<string name="sort_title_alpha">Titel</string>
<string name="sort_created">Kürzlich hinzugefügt</string>
<plurals name="clear_completed_body">
<item quantity="one">%1$d erledigte Aufgabe in dieser Liste wird gelöscht.</item>
<item quantity="other">%1$d erledigte Aufgaben in dieser Liste werden gelöscht.</item>
</plurals>
<string name="clear_completed_confirm">Löschen</string>
<string name="settings_list_notifications">Benachrichtigungen</string>
<string name="settings_list_notifications_hint">Töne, Vibration und Wichtigkeit für Erinnerungen der Liste</string>
<string name="recurrence_generic">Wiederholende Aufgabe</string>
<string name="recurrence_daily">Jeden Tag</string>
<string name="recurrence_weekly">Jede Woche</string>
<string name="recurrence_monthly">Jedem Monat</string>
<string name="recurrence_yearly">Jedes Jahr</string>
<plurals name="recurrence_every_n_days">
<item quantity="one">Jeden Tag</item>
<item quantity="other">Alle %1$d Tage</item>
</plurals>
<plurals name="recurrence_every_n_weeks">
<item quantity="one">Jede Woche</item>
<item quantity="other">Alle %1$d Wochen</item>
</plurals>
<plurals name="recurrence_every_n_months">
<item quantity="one">Jeden Monat</item>
<item quantity="other">Allw %1$d Monate</item>
</plurals>
<plurals name="recurrence_every_n_years">
<item quantity="one">Jedes Jahr</item>
<item quantity="other">Alle %1$d Jahre</item>
</plurals>
<string name="recurrence_on_days">%1$s am %2$s</string>
<string name="recurrence_with_until">%1$s bis %2$s</string>
<string name="recurrence_none">Wiederholt sich nicht</string>
<string name="recurrence_custom">Benutzerdefiniert</string>
<string name="recurrence_unit_days">Tage</string>
<string name="recurrence_unit_weeks">Wochen</string>
<string name="recurrence_unit_months">Monate</string>
<string name="recurrence_unit_years">Jahre</string>
<string name="recurrence_incomplete">Gebe eine Nummer zwischen 1 und 999 ein</string>
<string name="recurrence_end_never">Nie</string>
<string name="recurring_scope_occurrence">Nur diese Aufgabe</string>
<string name="recurring_scope_following">Diese und folgende Aufgaben</string>
<string name="error_recurrence_without_date">Eine sich wiederholende Aufgabe braucht ein Start- oder Fälligkeitsdatum</string>
<string name="edit_location_label">Standort</string>
<string name="edit_url_label">Link</string>
<string name="edit_url_hint">https://…</string>
<string name="edit_discard_title">Änderungen verwerfen?</string>
<string name="edit_discard_body">Deine Änderungen an dieser Aufgabe wurden nicht gespeichert.</string>
<string name="edit_discard_confirm">Verwerfen</string>
<string name="edit_keep_editing">Weiter bearbeiten</string>
<string name="edit_reminder_add">Erinnerung hinzufügen</string>
<string name="edit_reminder_add_first">Erinnerung hinzufügen (sonst wird Standard angewendet)</string>
<string name="edit_reminder_remove">Erinnerung entfernen</string>
<string name="subtask_progress">%1$d / %2$d</string>
<string name="progress_fraction_compact">%1$d/%2$d</string>
<string name="detail_delete_title">Aufgabe löschen?</string>
<string name="detail_delete_body">Die Aufgabe wird gelöscht.</string>
<plurals name="detail_delete_body_subtasks">
<item quantity="one">Diese Aufgabe und ihre Unteraufgabe werden gelöscht.</item>
<item quantity="other">Diese Aufgabe und ihre %1$d Unteraufgaben werden gelöscht.</item>
</plurals>
<string name="detail_status_cancelled">Abgebrochen</string>
<string name="detail_status_in_process">In Bearbeitung</string>
<string name="more_options">Mehr Optionen</string>
<string name="detail_cancel_task">Aufgabe abbrechen</string>
<string name="detail_restore">Aufgabe wiederherstellen</string>
<string name="detail_duplicate">Duplizieren</string>
<string name="lists_empty_action">Liste erstellen</string>
<string name="list_add">Neue Liste</string>
<string name="list_new_title">Neue Liste</string>
<string name="list_edit_title">Liste bearbeiten</string>
<string name="list_name_hint">Listenname</string>
<string name="list_color">Farbe</string>
<string name="list_delete">Liste löschen</string>
<string name="list_delete_confirm_title">Liste löschen?</string>
<string name="list_where_device">Nur auf diesem Gerät</string>
<string name="detail_pane_empty">Wähle eine Aufgabe, um sie hier zu sehen</string>
<string name="percent_complete">%1$d%%</string>
<string name="reminder_on_the_day">Ab dem Tag</string>
<string name="recurrence_next">Als nächstes: %1$s</string>
<string name="recurrence_end_until">An einem Tag</string>
<plurals name="home_today_remaining_count">
<item quantity="one">%1$d übrig</item>
<item quantity="other">%1$d übrig</item>
</plurals>
<string name="reminder_day_today">heute</string>
<string name="reminder_day_tomorrow">morgen</string>
<string name="reminder_day_yesterday">gestern</string>
<string name="reminder_action_done">Fertig</string>
<string name="onboarding_welcome_title">Alles, was du machen wolltest</string>
<string name="onboarding_welcome_body">Ein ruhiger Ort für deine Aufgaben auf deinem Smartphone, das deins bleibt. Lass es uns einrichten, es braucht eine Minute.</string>
<string name="onboarding_welcome_button">Loslegen</string>
<string name="onboarding_welcome_lists_title">Listen und Unteraufgaben</string>
<string name="onboarding_welcome_reminders_title">Erinnerungen, die ankommen</string>
<string name="onboarding_welcome_reminders_body">Eine Benachrichtigung, wenn etwas fällig wird, nach deinem Plan</string>
<string name="onboarding_welcome_sync_body">Synchronisiere über CalDAV mit einem Server, den du wählst, oder behalte alles auf diesem Gerät.</string>
<string name="onboarding_sync_title">Mit deinem eigenen Server synchronisieren lassen?</string>
<string name="onboarding_sync_body">Agendula spricht CalDav — Nextcloud, Radicale, Posteo, Fastmail und alles, was auch CalDAV spricht. Keine Konten bei uns, nie.</string>
<string name="onboarding_sync_connect">Konto verbinden</string>
<string name="onboarding_sync_skip">Nicht jetzt</string>
<string name="onboarding_sync_devices_body">Hake etwas hier ab, und es wird Überfall abgehakt.</string>
<string name="onboarding_sync_copy_body">Deine Aufgaben leben auch auf dem Server, also ist ein verlorenes Gerät nicht verlorene Arbeit.</string>
<string name="onboarding_list_title">Deine erste Liste</string>
<string name="onboarding_list_default_name">Persönlich</string>
<string name="onboarding_list_create">Liste erstellen</string>
<string name="onboarding_backup_title">Eine Sicherung behalten</string>
<string name="onboarding_backup_continue">Fortsetzen</string>
<string name="onboarding_quick_add_title">Wie möchtest du Aufgaben hinzufügen?</string>
<string name="clear_completed_action">Löschen</string>
<string name="clear_completed_title">Erledigte Aufgaben löschen?</string>
<string name="sync_notice_retry">Wiederholen</string>
<string name="sync_notice_cause_server_newer">Durch die neuere Kopie vom Server ersetzt</string>
<string name="sync_sign_in_channel_name">Anmeldung nötig</string>
<string name="sync_sign_in_title">Bei %1$s wieder anmelden</string>
<string name="sync_notification_channel">Synchronisieren</string>
<string name="sync_notification_title">Aufgaben synchronisieren</string>
<string name="add_account_server_label">Server-Adresse</string>
<string name="add_account_server_hint">https://cloud.example.com</string>
<string name="add_account_username_label">Benutzername</string>
<string name="add_account_password_label">Passwort</string>
<string name="add_account_sign_in">Anmelden</string>
<string name="add_account_browser_title">Auf Browser warten</string>
<string name="add_account_browser_body">Bestätige Agendula im Browser, komme dann zurück hierher. Dein Passwort gelangt niemals zu dieser App, der Server vergibt ein seperates App-Passwort, das du jederzeit löschen kannst.</string>
<string name="add_account_browser_use_password">Stattdessen Passwort nutzen</string>
<string name="add_account_browser_retry">Nochmal versuchen</string>
<string name="add_account_browser_open">Öffne die Anmeldeseite</string>
<string name="add_account_browser_insecure">Diese Anmeldeseite ist unverschlüsselt (http). Alles, was du darin schreibst, auch dein Passwort, wird unverschlüsselt versendet.</string>
<string name="add_account_lists_title">Welche Listen sollen synchronisiert werden?</string>
<string name="add_account_lists_shared">Mit dir geteilt</string>
<string name="add_account_save">Konto hinzufügen</string>
<string name="add_account_no_lists_selected">Wähle mindestens eine Liste</string>
<string name="widget_tasks_label">Aufgaben</string>
<string name="widget_empty">Hier ist nichts zu tun</string>
<string name="widget_unavailable">Öffne Agendula und die Einrichtung abzuschließen</string>
<string name="widget_config_title">Im Widget zeigen</string>
<string name="widget_config_smart_lists">Intelligente Listen</string>
<string name="list_color_blue">Blau</string>
<string name="list_color_purple">Lila</string>
<string name="list_color_pink">Rosa</string>
<plurals name="home_today_progress_count">
<item quantity="one">%1$d von %2$d abgeschlossen</item>
<item quantity="other">%1$d von %2$d abgeschlossen</item>
</plurals>
<string name="settings_group_look">Aussehen und Verhalten</string>
<string name="settings_group_data">Daten</string>
<string name="settings_group_app">App</string>
<string name="settings_group_about">Über</string>
<string name="settings_autofocus_title">Fokusiere den Titel bei Erstellung neuer Aufgaben</string>
<plurals name="duration_minutes">
<item quantity="one">%d Minute</item>
<item quantity="other">%d Minuten</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d Stunde</item>
<item quantity="other">%d Stunden</item>
</plurals>
<string name="settings_section_accounts">Konten</string>
<string name="accounts_empty_title">Noch keine Konten</string>
<string name="accounts_add">Konto hinzufügen</string>
<string name="accounts_sync_interval">Automatisch synchronisieren</string>
<plurals name="accounts_sync_every_minutes">
<item quantity="one">Jede %d Minute</item>
<item quantity="other">Jede %d Minuten</item>
</plurals>
<string name="list_save_failed">Liste konnte nicht gespeichert werden.</string>
<string name="list_delete_failed">Liste konnte nicht gelöscht werden.</string>
<string name="list_save_failed_offline">Server konnte nicht erreicht werden. Versuche es bitte erneut, wenn du wieder online bist.</string>
<string name="list_save_failed_unsupported">Dieses Konto lässt Apps keine Aufgabenlisten erstellen.</string>
<string name="list_delete_confirm_message">\"%1$s\" und alle Aufgaben davon werden gelöscht. Das kann nicht rückgängig gemacht werden.</string>
<string name="list_color_red">Rot</string>
<string name="list_color_orange">Orange</string>
<string name="list_color_olive">Olive</string>
<string name="list_color_green">Grün</string>
<string name="list_color_cyan">Cyan</string>
<string name="settings_time_format">Zeitformat</string>
<string name="settings_time_format_auto">Automatisch</string>
<string name="settings_week_start">Woche startet am</string>
<string name="settings_week_start_auto">Automatisch</string>
<string name="settings_week_start_auto_summary">Momentan: %1$s</string>
<string name="settings_licences">Open-Source Lizenzen</string>
<string name="settings_licences_subtitle">Die Projekte, auf denen Agendula aufgebaut ist</string>
<string name="licences_title">Open-Source Lizenzen</string>
<string name="licences_intro">Agendula beinhaltet die unten gelistete Arbeit. Drücke eine, um ihren Quellcode zu erhalten.</string>
<string name="settings_privacy">Datenschutzbestimmungen</string>
<string name="settings_notifications_blocked">Benachrichtigungen sind deaktiviert</string>
<plurals name="import_done">
<item quantity="one">%1$d Aufgabe in %2$s importiert</item>
<item quantity="other">%1$d Aufgaben in %2$s importiert</item>
</plurals>
<plurals name="import_done_skipped_existing">
<item quantity="one">%1$d schon vorhanden. Wird übersprungen.</item>
<item quantity="other">%1$d schon vorhanden. Werden übersprungen.</item>
</plurals>
<string name="import_failed_unreadable">Die Datei konnte nicht als iCalendar eingelesen werden</string>
<string name="import_failed_no_tasks">Die Datei beinhaltet keine Aufgaben</string>
<string name="import_failed_write">Die Aufgabe konnte nicht gespeichert werden</string>
<plurals name="accounts_sync_every_hours">
<item quantity="one">Jede Stunde</item>
<item quantity="other">Alle %d Stunden</item>
</plurals>
<string name="accounts_remove">Konto entfernen</string>
<string name="accounts_generic_provider">CalDAV-Konto</string>
<string name="accounts_sync_now">Jetzt synchronisieren</string>
<string name="accounts_removing">Entfernen…</string>
<string name="accounts_lists">Listen</string>
<string name="add_account_step_of">Schritt %1$d von %2$d</string>
<string name="add_account_provider_other_summary">Baïkal, DAViCal, SOGo, Radicale oder einen, den du selber laufen lässt</string>
<string name="add_account_provider_other_header">Jeglichen CalDAV-Server</string>
<string name="add_account_provider_other">Andere CalDAV-Server</string>
<string name="add_account_email_title">Was ist dort deine Adresse?</string>
<string name="add_account_email_body">Die E-Mail-Adresse, mit der du diesen Dienst nutzt. Agendula findet den Server selbst.</string>
<string name="add_account_email_label">E-Mail-Adresse</string>
<string name="add_account_summary_title">Du bist eingerichtet</string>
<string name="add_account_summary_done">Fertig</string>
<string name="add_account_server_title">Wo sind deine Aufgaben?</string>
<string name="add_account_credentials_title">Anmelden</string>
<string name="add_account_error_not_an_address">Das scheint keine Web-Adresse zu sein. Versuche etwas wie cloud.example.com.</string>
<string name="add_account_working_signing_in">Anmelden</string>
<string name="add_account_working_reading_lists">Aufgaben auslesen</string>
<string name="add_account_working_saving">Konto hinzufügen</string>
<string name="add_account_error_google_unsupported">Google Kalendar unterstützt nach eigener Dokumentation nicht Aufgaben über CalDAV, also kann Agendula nicht damit synchronisieren.</string>
<string name="add_account_error_already_exists">Konto ist schon eingerichtet.</string>
<string name="add_account_error_credentials_rejected">Diser Benutzername oder dieses Passwort wurde nicht akzeptiert.</string>
<string name="add_account_browser_error_maintenance">Der Server befindet sich um Wartungsmodus. Versuche es erneut, wenn er wieder zurück ist.</string>
<string name="add_account_setup_icloud_title">iCloud braucht ein App-spezifisches Passwort</string>
<string name="accounts_sign_in_again">Erneut anmelden</string>
<string name="sync_notice_body">%1$s</string>
<string name="sync_notices_title">Änderungen durch letzte Synchronisierung</string>
<string name="sync_notices_dismiss">Verstanden</string>
</resources> </resources>
+85 -19
View File
@@ -11,9 +11,12 @@ carries no APK assets.
Codeberg is the canonical forge; Gitea is build infrastructure. See Codeberg is the canonical forge; Gitea is build infrastructure. See
[Two forges, one repo](#two-forges-one-repo) for how the two are wired. [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 Before a stable release you can ship **betas** (`X.Y.Z-beta.N`) from the
flagged as a **pre-release**. This happens automatically and graduates to a release branch. They go to **Codeberg only**, flagged as pre-releases; F-Droid
stable release at `1.0.0` — no manual toggling. and Play never see them. See [Cutting a beta](#cutting-a-beta).
While Agendula was pre-1.0 (`versionName` started with `0.`), every release was
flagged as a **pre-release** automatically.
--- ---
@@ -22,16 +25,30 @@ stable release at `1.0.0` — no manual toggling.
A release is defined by the `versionName`/`versionCode` committed in A release is defined by the `versionName`/`versionCode` committed in
`app/build.gradle.kts` — **not** by a hand-pushed tag: `app/build.gradle.kts` — **not** by a hand-pushed tag:
- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`) - `versionName` = `MAJOR.MINOR.PATCH` (e.g. `1.1.0`), or
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0` → `200`, `MAJOR.MINOR.PATCH-beta.N` for a beta (e.g. `1.1.0-beta.2`)
`1.3.4` → `10304`) - `versionCode` is derived from it by `scripts/version_info.sh`:
So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads | `versionName` | `versionCode` | Example |
`versionName`, pins `versionCode` to the derived value, builds, publishes, and — | --- | --- | --- |
once the APK is live — creates the tag `v<versionName>` at that commit. The tag | `X.Y.Z` below 1.1.0 (legacy) | `X*10000 + Y*100 + Z` | `1.0.1` → `10001` |
is an **output** of a successful release, not its trigger, so a tag always marks | `X.Y.Z-beta.N` (N = 1–98) | `X*1000000 + Y*10000 + Z*100 + N` | `1.1.0-beta.2` → `1010002` |
a fully-shipped version (and a failure before publish leaves no tag, so | `X.Y.Z` from 1.1.0 | `X*1000000 + Y*10000 + Z*100 + 99` | `1.1.0` → `1010099` |
re-running the workflow safely retries).
A beta's code sits below its own stable release and above everything before
it, so a beta install updates in place to the next beta and then to the stable
version. Every 1.0.x keeps the code it already has, and `1.1.0-beta.1`
(`1010001`) is above all of them. `MINOR` and `PATCH` each have room for 0–99.
Run `scripts/version_info.sh` to see what the committed version resolves to;
CI fails a PR whose committed `versionCode` doesn't match, because the official
F-Droid repo builds the tag exactly as committed.
The release pipeline reads `versionName`, pins `versionCode` to the derived
value, builds, publishes, and — once the APK is live — creates the tag
`v<versionName>` at that commit. The tag is an **output** of a successful
release, not its trigger, so a tag always marks a fully-shipped version (and a
failure before publish leaves no tag, so re-running the workflow safely
retries).
--- ---
@@ -44,8 +61,8 @@ re-running the workflow safely retries).
`## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading `## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
and the next `## [` becomes the Gitea and Codeberg release notes. The and the next `## [` becomes the Gitea and Codeberg release notes. The
heading **must** match the version exactly. heading **must** match the version exactly.
3. **Bump the committed `versionName`** (and `versionCode`) in 3. **Bump the committed `versionName`** (and `versionCode`, which
`app/build.gradle.kts`. **This bump is what triggers the release** when the `scripts/version_info.sh` prints) in `app/build.gradle.kts`. **This bump is what triggers the release** when the
branch merges to `main`. branch merges to `main`.
Then write this version's **"What's New"** by hand: Then write this version's **"What's New"** by hand:
`fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt`, one per `fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt`, one per
@@ -79,6 +96,46 @@ re-running the workflow safely retries).
--- ---
## Cutting a beta
A beta is a test build of the next version, published as a **pre-release on
Codeberg** and nowhere else. It is signed with the real app key, so testers
install it over their stable install and it updates in place to later betas
and to the stable release.
1. **On `release/vX.Y.Z`**, with the features merged in, set
`versionName = "X.Y.Z-beta.1"` and the matching `versionCode`
(`scripts/version_info.sh` prints it; `1.1.0-beta.1` → `1010001`).
No What's New file — betas don't ship to the stores. Notes come from a
`## [X.Y.Z-beta.N]` section of `CHANGELOG.md` if you write one, otherwise
from `## [Unreleased]`.
2. **Optionally verify it on a device** with `scripts/verify-release.sh`, as for
a stable release.
3. **Push the branch to Codeberg.** When it reaches Gitea, `beta.yaml` sees a
beta version without a tag, runs the unit tests, builds and signs the APK,
creates the `vX.Y.Z-beta.1` tag + a Gitea pre-release (with the R8 mapping)
and publishes the **Codeberg pre-release** with the APK + `.sha256`.
4. **Next round:** bump to `-beta.2` (and its `versionCode`) and push again.
5. **Going stable:** set `versionName = "X.Y.Z"` and its `versionCode`
(`1.1.0` → `1010099`), then continue from step 2 of
[Cutting a release](#cutting-a-release).
Who gets a beta:
- **Obtainium** users only with *Include prereleases* switched on for the app.
That is how a tester opts in; everyone else stays on stable.
- **F-Droid** (self-hosted and official) never: `beta.yaml` doesn't touch the
self-hosted repo, and the official recipe's `UpdateCheckMode: Tags ^v[0-9.]+$`
ignores `-beta` tags.
- **Play** never.
Guards: a beta version can't reach `main` (CI fails the PR, and `release.yaml`'s
`detect` refuses one as a backstop), `beta.yaml` refuses a beta of a version
that already shipped stable, and betas start at 1.1.0 (the 1.0.x codes have no
room below them).
---
## What the pipeline does ## What the pipeline does
CI and release are split so a change is built once on its PR and only does CI and release are split so a change is built once on its PR and only does
@@ -94,10 +151,12 @@ release work when a merge actually cuts a release:
`values-*/strings.xml`, so Weblate PRs get fast feedback. Runs on every PR `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 without a path filter, so the required `Translations / check` status is always
reported. reported.
- **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC plus - **`renovate.yml`** (`.gitea/workflows/`, Mondays 05:00 UTC, on push to
`workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the pinned `main`, plus `workflow_dispatch`, **Gitea**) — the dependency sweep. Runs the
Renovate image as its job container and targets Codeberg's API; the cadence pinned Renovate image as its job container and targets Codeberg's API. New
lives here, not in `renovate.json5`, so the two don't double-gate. PRs only open in the Monday `schedule` window in `renovate.json5` (keep it
bracketing the cron); the on-merge runs fall outside it and only rebase
existing PRs that the merge left conflicted.
- **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus - **`release.yaml`** (`.gitea/workflows/`, on push to `main` plus
`workflow_dispatch`, **Gitea**) — a cheap `detect` job reads `versionName` and `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 checks **Codeberg** for a tag for it. Only when there isn't one does the
@@ -110,6 +169,12 @@ release work when a merge actually cuts a release:
**Codeberg** with the signed APK + a SHA-256 checksum, and finally builds the **Codeberg** with the signed APK + a SHA-256 checksum, and finally builds the
App Bundle. Ordinary merges with no version bump fall through `detect` and do App Bundle. Ordinary merges with no version bump fall through `detect` and do
nothing. nothing.
- **`beta.yaml`** (`.gitea/workflows/`, on push to `release/**`, **Gitea**) —
the same `detect` idea for betas: only when the committed `versionName` is
`X.Y.Z-beta.N` and Codeberg has no tag for it does the `beta` job run: unit
tests, pin `versionCode`, build & sign the release APK with the **app key**,
create the tag + a Gitea pre-release with the R8 mapping, and publish the
**Codeberg pre-release** with the APK + SHA-256. No F-Droid, no Play.
- **`play` job** (same workflow, after `release`) — uploads the App Bundle and - **`play` job** (same workflow, after `release`) — uploads the App Bundle and
every locale's "What's New" to Google Play. Runs last and separately so a Play every locale's "What's New" to Google Play. Runs last and separately so a Play
rejection can't endanger a release that already shipped; skips cleanly until rejection can't endanger a release that already shipped; skips cleanly until
@@ -123,7 +188,8 @@ 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 direction, so the pipeline pushes the `vX.Y.Z` tag straight to Codeberg, creates
the release over the Codeberg API, and attaches `agendula_v<version>.apk` + its the release over the Codeberg API, and attaches `agendula_v<version>.apk` + its
`.sha256`. It's the same APK the F-Droid repo serves (same **app key**), so it `.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, adds no trust surface. The logic lives in `scripts/publish_codeberg_release.sh`,
which `beta.yaml` uses too (with the pre-release flag set). 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 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 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 F-Droid but not the Codeberg/Obtainium users who needed it. A broken mirror
+11 -9
View File
@@ -34,17 +34,19 @@ Nothing listing-related goes in the recipe. F-Droid harvests
is fed from: title, descriptions, icon, feature graphic, screenshots and is fed from: title, descriptions, icon, feature graphic, screenshots and
`changelogs/<versionCode>.txt`. `title.txt` becomes the app name per locale. `changelogs/<versionCode>.txt`. `title.txt` becomes the app name per locale.
## Submitting ## Status
After `v1.0.0` is tagged and `agendula_v1.0.0.apk` is live on the self-hosted Submitted as [fdroiddata!49998](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/49998)
repo: on 2026-09-24. Its CI rebuilt v1.0.0 and verified it against our published APK
("compared built binary to supplied reference binary successfully").
1. Fork `https://gitlab.com/fdroid/fdroiddata`, copy the recipe to The file here is a copy of the submitted recipe. fdroiddata's copy is the one
`metadata/de.jeanlucmakiola.agendula.yml`. that counts; after the merge F-Droid picks up new `vX.Y.Z` tags on its own
2. `fdroid readmeta && fdroid lint de.jeanlucmakiola.agendula`, then (`AutoUpdateMode`), so there is no per-release work there. Beta tags
`fdroid build -v -l de.jeanlucmakiola.agendula` to rebuild and verify. (`vX.Y.Z-beta.N`) don't match `UpdateCheckMode: Tags ^v[0-9.]+$`, so betas stay
3. Open the merge request. Later versions are picked up from new `vX.Y.Z` tags off the official repo; keep that pattern if the recipe ever changes. Only a change to the
(`AutoUpdateMode`), so there is no per-release work in fdroiddata. recipe itself (a new submodule, a build flag) needs an MR, pinned to a full
commit hash, never a tag.
The recipe says `License: MIT`; `:dav` is vendored MPL-2.0 (`dav/PROVENANCE.md`). The recipe says `License: MIT`; `:dav` is vendored MPL-2.0 (`dav/PROVENANCE.md`).
If the reviewer asks, change it to `MIT AND MPL-2.0`. If the reviewer asks, change it to `MIT AND MPL-2.0`.
@@ -1,11 +1,3 @@
# Draft fdroiddata recipe for the OFFICIAL F-Droid repository. Submitted as
# metadata/de.jeanlucmakiola.agendula.yml in fdroiddata; see README.md here.
# Not the self-hosted control file (that is ../../fdroid-metadata/).
#
# Reproducible build + developer-signed binary: F-Droid rebuilds from source,
# compares against our APK from `Binaries`, and on a match publishes OUR binary,
# so official and self-hosted installs share one signature.
Categories: Categories:
- Task - Task
License: MIT License: MIT
@@ -22,15 +14,10 @@ RepoType: git
Repo: https://codeberg.org/jlmakiola/agendula.git Repo: https://codeberg.org/jlmakiola/agendula.git
Binaries: https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/agendula_v%v.apk Binaries: https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/agendula_v%v.apk
# 1.0.0 is the first release that carries every reproducibility fix
# (scripts/check_reproducible_release.sh); 0.x releases strip datastore's .so
# files host-dependently and won't verify. submodules: floret-kit is an
# included build. No key.properties on the buildserver, so the release build
# comes out unsigned, which is what F-Droid compares.
Builds: Builds:
- versionName: 1.0.0 - versionName: 1.0.0
versionCode: 10000 versionCode: 10000
commit: v1.0.0 commit: 671910f54b097306ed83d69f910eb328cb07dc3d
subdir: app subdir: app
submodules: true submodules: true
gradle: gradle:
+10 -3
View File
@@ -52,9 +52,16 @@
prConcurrentLimit: 5, prConcurrentLimit: 5,
prHourlyLimit: 0, prHourlyLimit: 0,
// Cadence is owned by the Gitea Actions cron (.gitea/workflows/renovate.yml, // New branches/PRs only in the Monday window (UTC), which brackets the weekly
// Mondays) — no internal `schedule` here, so the two don't double-gate and // cron in .gitea/workflows/renovate.yml with slack for a late start — keep
// silently skip a run. // the two in step, or the sweep lands outside the window and finds nothing.
// The same workflow also runs on every push to main; those runs fall outside
// the window and, via the default updateNotScheduled: true, only maintain
// existing branches — chiefly rebasing PRs a merge just conflicted.
schedule: ["* 4-6 * * 1"],
// Rebase only on conflict, not every time main moves (automerge is off, so
// this matches "auto"; spelled out because the on-merge trigger relies on it).
rebaseWhen: "conflicted",
// Workflows are split by forge and neither directory is .github: CI lives in // Workflows are split by forge and neither directory is .github: CI lives in
// .forgejo/workflows (Codeberg) and the release/renovate jobs in // .forgejo/workflows (Codeberg) and the release/renovate jobs in
+7 -5
View File
@@ -16,6 +16,7 @@ without setup.
""" """
import re import re
import struct import struct
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@@ -120,10 +121,11 @@ def check_images(locale_dir):
def version_code(): def version_code():
gradle = (ROOT / "app/build.gradle.kts").read_text() """The committed version's code, or None for a beta (it ships no What's New)."""
name = re.search(r'versionName\s*=\s*"([^"]+)"', gradle).group(1) def info(key):
parts = (name.split(".") + ["0", "0"])[:3] return subprocess.run(["bash", str(ROOT / "scripts/version_info.sh"), key],
return int(parts[0]) * 10000 + int(parts[1]) * 100 + int(parts[2]) check=True, capture_output=True, text=True).stdout.strip()
return None if info("channel") == "beta" else int(info("version_code"))
def shipped_languages(): def shipped_languages():
@@ -183,7 +185,7 @@ def main():
err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)") err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)")
if len(list((images / "phoneScreenshots").glob("*"))) < 2: if len(list((images / "phoneScreenshots").glob("*"))) < 2:
err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots") err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots")
for loc in REQUIRED_LOCALES: for loc in REQUIRED_LOCALES if current is not None else []:
if not (META / loc / "changelogs" / f"{current}.txt").exists(): if not (META / loc / "changelogs" / f"{current}.txt").exists():
err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version") err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version")
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Publish TAG on Codeberg, which is canonical for tags and releases, with the
# signed APK and its SHA-256 checksum attached: the direct-download channel
# that Obtainium follows. Shared by .gitea/workflows/release.yaml (stable) and
# beta.yaml (pre-release). Run from the repo checkout: it pushes the tag.
#
# Codeberg push-mirrors branches and tags to Gitea, but releases aren't git
# objects and don't sync in either direction, so this pushes the tag straight
# to Codeberg and creates the release over the API. Skips cleanly when TOKEN is
# unset; fails loudly on anything else (see release.yaml for why).
#
# Env:
# TOKEN CODEBERG_RELEASE_TOKEN (write:repository); empty = skip
# API https://codeberg.org/api/v1/repos/<owner>/<repo>
# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks
# PRERELEASE true | false
# NOTES_FILE release notes (scripts/release_notes.sh)
# APK the signed release APK
set -euo pipefail
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
: "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}" "${APK:?}"
if [ ! -f "$APK" ]; then echo "No release APK at $APK." >&2; exit 1; fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
ASSET_APK="agendula_${TAG}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "$WORK/$ASSET_APK"
( cd "$WORK" && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Push the tag to Codeberg ourselves. Under Codeberg-canonical the mirror runs
# Codeberg -> Gitea, so waiting for a tag to arrive from Gitea (what 0.3.2 did)
# would wait forever. The tag 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.
#
# Pushing the ref first and creating the release 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.
HOST=${API#https://}; HOST=${HOST%%/*}
REPO=${API#*/repos/}
git tag -f "$TAG" "$SHA"
git push -f "https://${REPO%%/*}:${TOKEN}@${HOST}/${REPO}.git" "refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
import json, sys
tag, pre, notes = sys.argv[1:4]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open(notes).read(),
"draft": False,
"prerelease": pre == "true",
}))
PY
# 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
# 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). 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 @"$WORK/payload.json" "$API/releases/$EXIST"
ID="$EXIST"; break
fi
CODE=$(curl -s -o "$WORK/response.json" -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases")
echo "release POST attempt $attempt HTTP $CODE"
ID=$(jq -r '.id // empty' "$WORK/response.json" 2>/dev/null || true)
[ -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
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Create (or update) the release for TAG on the Gitea build instance, the
# changelog of record there, and attach the R8 mapping. Shared by
# .gitea/workflows/release.yaml and beta.yaml.
#
# Creating it with target_commitish makes Gitea create the tag at SHA, so the
# tag only ever marks a fully-shipped version. Upsert, so re-runs are safe.
#
# Env:
# TOKEN, API Gitea token and .../api/v1/repos/<owner>/<repo>
# TAG, SHA vX.Y.Z[-beta.N] and the commit it marks
# PRERELEASE true | false
# NOTES_FILE release notes (scripts/release_notes.sh)
# MAPPING optional path to R8's mapping.txt
set -euo pipefail
: "${TOKEN:?}" "${API:?}" "${TAG:?}" "${SHA:?}" "${PRERELEASE:?}" "${NOTES_FILE:?}"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
python3 - "$TAG" "$SHA" "$PRERELEASE" "$NOTES_FILE" <<'PY' > "$WORK/payload.json"
import json, sys
tag, sha, pre, notes = sys.argv[1:5]
print(json.dumps({
"tag_name": tag,
"target_commitish": sha,
"name": tag,
"body": open(notes).read(),
"draft": False,
"prerelease": pre == "true",
}))
PY
# PATCH if a release for the tag already exists, else POST a new one (which
# also creates the tag at target_commitish).
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty' 2>/dev/null || true)
if [ -n "$ID" ]; then
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases/$ID")
OK=200
else
CODE=$(curl -s -o "$WORK/response.json" -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases")
OK=201
fi
cat "$WORK/response.json"; echo
if [ "$CODE" != "$OK" ]; then
echo "Gitea release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1
fi
ID=$(jq -r '.id' "$WORK/response.json")
echo "Created/updated Gitea release $TAG at $SHA"
# The R8 mapping keeps user crash stacktraces deobfuscatable. It's not an APK,
# so it fits the no-binaries rule for this release. Best-effort: never fail a
# release over it.
attach_mapping() {
local asset="mapping-${TAG#v}.txt.gz" old
gzip -c "$MAPPING" > "$WORK/$asset"
# Replace any prior asset of the same name (re-run safe).
old=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$asset" '.[] | select(.name==$n) | .id')
if [ -n "$old" ]; then
curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$old" >/dev/null
fi
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$asset" \
"$API/releases/$ID/assets?name=$asset" -o /dev/null -w "asset $asset HTTP %{http_code}\n"
}
if [ -z "${MAPPING:-}" ] || [ ! -f "$MAPPING" ]; then
echo "No mapping.txt (R8 off?) — skipping."
else
attach_mapping || echo "warning: could not attach the R8 mapping to $TAG" >&2
fi
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Print the forge release notes for VERSION: its `## [VERSION]` section of
# CHANGELOG.md. A beta (X.Y.Z-beta.N) normally has no section of its own, so it
# falls back to `## [Unreleased]`, i.e. what the release branch carries so far,
# under a line saying what a beta is.
#
# scripts/release_notes.sh 1.1.0 > release-notes.md
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
VERSION=${1:?usage: release_notes.sh VERSION}
section() {
awk -v ver="$1" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md | sed -e '/./,$!d'
}
NOTES=$(section "$VERSION")
if [[ "$VERSION" == *-beta.* ]]; then
[ -n "$NOTES" ] || NOTES=$(section Unreleased)
printf '%s\n\n' "**Beta of ${VERSION%%-*}, for testing.** It updates in place to later betas and to the stable release. Obtainium only offers betas with *Include prereleases* switched on; F-Droid and Play never get them."
fi
if [ -n "$NOTES" ]; then
printf '%s\n' "$NOTES"
else
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._"
fi
+9 -5
View File
@@ -11,11 +11,15 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." # repo root cd "$(dirname "$0")/.." # repo root
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) VERSION=$(bash scripts/version_info.sh version)
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; } VERSION_CODE=$(bash scripts/version_info.sh version_code)
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} # Betas only ship to Codeberg, whose notes come from CHANGELOG.md. A What's New
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) # file for one would sit in the tree F-Droid and Play read, so none is wanted.
if [ "$(bash scripts/version_info.sh channel)" = beta ]; then
echo "Beta $VERSION: no store What's New (betas only ship to Codeberg)."
exit 0
fi
# Play rejects a longer "What's New" and F-Droid truncates it in-client. # Play rejects a longer "What's New" and F-Droid truncates it in-client.
MAX_CHARS=${MAX_CHARS:-500} MAX_CHARS=${MAX_CHARS:-500}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# The one place that turns the committed versionName into a versionCode and a
# release channel. The release and beta pipelines, the changelog sync and the
# store-listing check all read it from here, so the scheme can't drift between
# them.
#
# versionName channel versionCode
# X.Y.Z (< 1.1.0) stable X*10000 + Y*100 + Z (legacy)
# X.Y.Z-beta.N beta X*1000000 + Y*10000 + Z*100 + N (N = 1..98)
# X.Y.Z (>= 1.1.0) stable X*1000000 + Y*10000 + Z*100 + 99
#
# A beta's code sits below its own stable release and above everything before
# it, so a beta install updates in place to the next beta and then to the
# stable version. Every 1.0.x keeps the legacy code it already shipped with;
# the first new-scheme code (1.1.0-beta.1 -> 1010001) is above all of them.
#
# scripts/version_info.sh print version, version_code, base_version,
# channel and prerelease as key=value lines
# (ready for $GITHUB_OUTPUT)
# scripts/version_info.sh <key> print just that value
# scripts/version_info.sh --check also fail unless the committed versionCode
# matches; the official F-Droid repo builds
# the tag as committed, so it must
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
GRADLE="app/build.gradle.kts"
NAME=$(grep -oP 'versionName\s*=\s*"\K[^"]+' "$GRADLE" || true)
COMMITTED=$(grep -oP 'versionCode\s*=\s*\K[0-9]+' "$GRADLE" || true)
[ -n "$NAME" ] || { echo "No versionName in $GRADLE" >&2; exit 1; }
if [[ ! "$NAME" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-beta\.([0-9]+))?$ ]]; then
echo "versionName '$NAME' is neither X.Y.Z nor X.Y.Z-beta.N" >&2
exit 1
fi
MAJOR=$((10#${BASH_REMATCH[1]})); MINOR=$((10#${BASH_REMATCH[2]})); PATCH=$((10#${BASH_REMATCH[3]}))
BETA=${BASH_REMATCH[5]:+$((10#${BASH_REMATCH[5]}))}
BASE="$MAJOR.$MINOR.$PATCH"
if [ "$MINOR" -gt 99 ] || [ "$PATCH" -gt 99 ]; then
echo "versionName '$NAME': MINOR and PATCH each have room for 0-99" >&2
exit 1
fi
LEGACY=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
if [ -n "$BETA" ]; then
if [ "$LEGACY" -lt 10100 ]; then
echo "versionName '$NAME': betas start at 1.1.0; 1.0.x codes have no room for them" >&2
exit 1
fi
if [ "$BETA" -lt 1 ] || [ "$BETA" -gt 98 ]; then
echo "versionName '$NAME': beta number must be 1-98 (99 is the stable release)" >&2
exit 1
fi
CHANNEL=beta
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + BETA ))
elif [ "$LEGACY" -lt 10100 ]; then
CHANNEL=stable
CODE=$LEGACY
else
CHANNEL=stable
CODE=$(( MAJOR * 1000000 + MINOR * 10000 + PATCH * 100 + 99 ))
fi
# Betas are always pre-releases; so was everything before 1.0.0.
if [ "$CHANNEL" = beta ] || [ "$MAJOR" = 0 ]; then PRERELEASE=true; else PRERELEASE=false; fi
case "${1:-}" in
"")
printf 'version=%s\nversion_code=%s\nbase_version=%s\nchannel=%s\nprerelease=%s\n' \
"$NAME" "$CODE" "$BASE" "$CHANNEL" "$PRERELEASE"
;;
--check)
if [ "$COMMITTED" != "$CODE" ]; then
echo "ERROR: $GRADLE has versionCode = ${COMMITTED:-<none>}, but versionName '$NAME' needs $CODE." >&2
echo "Set versionCode = $CODE (see docs/RELEASING.md for the scheme)." >&2
exit 1
fi
echo "OK: versionName $NAME -> versionCode $CODE ($CHANNEL)."
;;
version) echo "$NAME" ;;
version_code) echo "$CODE" ;;
base_version) echo "$BASE" ;;
channel) echo "$CHANNEL" ;;
prerelease) echo "$PRERELEASE" ;;
*) echo "Unknown key '$1'" >&2; exit 2 ;;
esac