From 7f58f81fe169d2f428c5d1a27bde16559f6ef526 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 21:53:13 +0200 Subject: [PATCH] ci: adopt the modern calendula pipeline + Codeberg mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port Calendula's current CI/release pipeline: - ci.yaml: pull_request-triggered, change-scope classification (docs/metadata-only PRs skip the Android build but still report a green CI), and a reproducible-release invariant guard. - release.yaml: the committed versionName is the source of truth — a bump reaching main triggers the release, which builds, signs, publishes to the F-Droid repo, then mints the vX.Y.Z tag + Gitea release and mirrors it to Codeberg with the signed APK + SHA-256 checksum. workflow_dispatch runs the re-sign-only recovery path. - Gitea releases are flagged as pre-releases while MAJOR is 0. - build.gradle.kts: reproducible-release invariants (vcsInfo, dependenciesInfo) + a releaseTest variant for the on-device gate. - fastlane/ becomes the single source of truth for store metadata; the localized F-Droid layout is generated from it at release time. - Port scripts/, .gitea/ISSUE_TEMPLATE/, and rewrite docs/RELEASING.md for the versionName-in-main model; fix stale references elsewhere. Co-Authored-By: Claude Opus 4.8 --- .gitea/ISSUE_TEMPLATE/bug_report.md | 23 + .gitea/ISSUE_TEMPLATE/crash_report.md | 27 ++ .gitea/ISSUE_TEMPLATE/feature_request.md | 16 + .gitea/ISSUE_TEMPLATE/question.md | 19 + .gitea/workflows/ci.yaml | 64 ++- .gitea/workflows/release.yaml | 411 ++++++++++-------- CHANGELOG.md | 5 +- CONTRIBUTING.md | 13 +- app/build.gradle.kts | 42 +- docs/ARCHITECTURE.md | 2 +- docs/RELEASING.md | 194 ++++++--- .../android/en-US/changelogs/.gitkeep | 0 .../metadata/android/en-US/changelogs/200.txt | 30 ++ .../android/en-US/full_description.txt | 0 .../android/en-US/short_description.txt | 0 fastlane/metadata/android/en-US/title.txt | 1 + scripts/check_reproducible_release.sh | 71 +++ scripts/fastlane_to_fdroid_localized.sh | 48 ++ scripts/sync_changelog_to_fastlane.sh | 41 ++ scripts/verify-release.sh | 49 +++ 20 files changed, 790 insertions(+), 266 deletions(-) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/crash_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/ISSUE_TEMPLATE/question.md create mode 100644 fastlane/metadata/android/en-US/changelogs/.gitkeep create mode 100644 fastlane/metadata/android/en-US/changelogs/200.txt rename fdroid-metadata/de.jeanlucmakiola.agendula/en-US/description.txt => fastlane/metadata/android/en-US/full_description.txt (100%) rename fdroid-metadata/de.jeanlucmakiola.agendula/en-US/summary.txt => fastlane/metadata/android/en-US/short_description.txt (100%) create mode 100644 fastlane/metadata/android/en-US/title.txt create mode 100755 scripts/check_reproducible_release.sh create mode 100755 scripts/fastlane_to_fdroid_localized.sh create mode 100755 scripts/sync_changelog_to_fastlane.sh create mode 100755 scripts/verify-release.sh diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e7b128b --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: Bug report +about: Something doesn't work the way it should +title: "" +labels: + - bug +--- + +### What happened + + +### What you expected + + +### Steps to reproduce +1. +2. +3. + +### Environment +- Agendula version: +- Android version: +- Device: diff --git a/.gitea/ISSUE_TEMPLATE/crash_report.md b/.gitea/ISSUE_TEMPLATE/crash_report.md new file mode 100644 index 0000000..b5d5a57 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/crash_report.md @@ -0,0 +1,27 @@ +--- +name: Crash report +about: Report a crash. Agendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form. +title: "Crash: " +labels: + - bug + - crash + - priority:high +--- + + + +### What happened + + +### Crash report + +``` +(paste the crash report here) +``` diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c55040c --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,16 @@ +--- +name: Feature request +about: Suggest an idea or improvement +title: "" +labels: + - feat +--- + +### What would you like Agendula to do? + + +### Why — what problem does it solve? + + +### Anything else + diff --git a/.gitea/ISSUE_TEMPLATE/question.md b/.gitea/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..a529a20 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/question.md @@ -0,0 +1,19 @@ +--- +name: Question +about: Ask how something works or get help using Agendula +title: "" +labels: + - question +--- + +### Your question + + +### What you've tried + + + +### Context +- Agendula version: +- Android version: +- Device: diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index eb7ad29..9f15571 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,18 +1,23 @@ name: CI +# One gate per pull request. Branch pushes no longer trigger CI on their own, +# so a change is built once on its PR (covering feature -> release/* and +# release/* -> main) instead of once per push and again on the merge to main. +# The merge itself is handled by release.yaml, which only does heavy work when +# the merge actually cuts a release. on: - push: - branches: - - '**' - tags-ignore: - - '**' + pull_request: -# Cancel superseded runs on the same branch. +# Cancel superseded runs for the same PR. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: + # Single job named `ci` so the required "CI" status check is always reported, + # even for docs-only PRs: those just skip the Android build and the job still + # succeeds (fast green check) instead of being filtered out and leaving the + # required check pending forever. ci: runs-on: docker env: @@ -22,15 +27,53 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: + # Full history so the base..HEAD diff below has a merge-base. + fetch-depth: 0 submodules: recursive + # Cheap, always-on guard: the release build must stay reproducible for the + # official F-Droid repo (no AGP VCS-info embedding). Runs regardless of + # change scope so a regression can't slip through on a "docs-only" PR. + - name: Reproducible-release invariant + run: bash scripts/check_reproducible_release.sh + + # Decide whether anything that affects the app build changed. Docs, + # F-Droid metadata and the licence don't, so those PRs skip the SDK + + # Gradle work below but still report a green `ci`. + - name: Classify change scope + id: scope + run: | + set -e + BASE="${{ github.base_ref }}" + # Full (not --depth=1) base fetch so the merge-base is present even when + # the PR branch forked several commits back; a shallow tip has no merge + # base with a divergent branch and `git diff base...HEAD` aborts. + git fetch --no-tags origin "$BASE" + MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true) + if [ -z "$MB" ]; then + # No common ancestor available — don't risk skipping the build. + echo "No merge base with origin/$BASE — running the full build to be safe." + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + CHANGED=$(git diff --name-only "$MB" HEAD) + echo "Changed files:"; echo "$CHANGED" + if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then + echo "code=true" >> "$GITHUB_OUTPUT" + else + echo "code=false" >> "$GITHUB_OUTPUT" + echo "Docs/metadata-only change — skipping the Android build." + fi + - name: Setup Java + if: steps.scope.outputs.code == 'true' uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '17' - name: Setup Android SDK + if: steps.scope.outputs.code == 'true' uses: android-actions/setup-android@v3 with: # Default ("tools platform-tools") drags in the Android Emulator @@ -38,12 +81,14 @@ jobs: packages: '' - name: Setup Android SDK cache + if: steps.scope.outputs.code == 'true' uses: actions/cache@v4 with: path: /opt/android-sdk key: ${{ runner.os }}-android-sdk-37-36.0.0 - name: Install Android SDK packages + if: steps.scope.outputs.code == 'true' run: | yes | sdkmanager --licenses >/dev/null || true sdkmanager \ @@ -52,6 +97,7 @@ jobs: "build-tools;36.0.0" - name: Setup Gradle cache + if: steps.scope.outputs.code == 'true' uses: actions/cache@v4 with: path: | @@ -62,21 +108,25 @@ jobs: ${{ runner.os }}-gradle- - name: Grant execute permission for gradlew + if: steps.scope.outputs.code == 'true' run: chmod +x ./gradlew # No --no-daemon: the daemon lives only as long as this job container # and lets the following steps skip JVM startup + reconfiguration. - name: Lint (debug variant only) + if: steps.scope.outputs.code == 'true' run: ./gradlew lintDebug - name: Unit tests + if: steps.scope.outputs.code == 'true' run: ./gradlew testDebugUnitTest - name: Assemble debug APK + if: steps.scope.outputs.code == 'true' run: ./gradlew assembleDebug - name: Trivy filesystem scan - if: github.ref == 'refs/heads/main' + if: steps.scope.outputs.code == 'true' run: | set -e SUDO="" diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 0fef70f..064b37b 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -1,77 +1,94 @@ -name: Release — F-Droid repo + Gitea release +name: Release — F-Droid repo + Gitea/Codeberg release +# A release is cut by merging a release branch into main with a bumped +# versionName (see docs/RELEASING.md). This workflow reads that versionName and, +# if no matching tag exists yet, runs tests, builds + signs the APK, publishes +# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors +# that release to Codeberg with the signed APK + a SHA-256 checksum as a +# direct-download channel — the tag is an output of the pipeline, not its +# trigger. Ordinary merges (no version bump) fall through `detect` and do +# nothing. +# +# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery +# path: it re-signs the existing F-Droid index with the repo key and re-uploads, +# without building an APK or creating a release. Used for key rotation / repo +# recovery. on: push: - tags: - - '*' + branches: [main] workflow_dispatch: +concurrency: + group: release + cancel-in-progress: false + jobs: - ci: + # Cheap gate: resolve the version from the committed build.gradle and decide + # whether this push actually cuts a new release (no tag for it yet). Keeps the + # heavy job from running on every merge to main. + detect: runs-on: docker - env: - ANDROID_HOME: /opt/android-sdk - ANDROID_SDK_ROOT: /opt/android-sdk + outputs: + is_release: ${{ steps.v.outputs.is_release }} + version: ${{ steps.v.outputs.version }} + version_code: ${{ steps.v.outputs.version_code }} + prerelease: ${{ steps.v.outputs.prerelease }} steps: - name: Checkout uses: actions/checkout@v4 with: submodules: recursive - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: '17' - - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - with: - packages: '' - - - name: Setup Android SDK cache - uses: actions/cache@v4 - with: - path: /opt/android-sdk - key: ${{ runner.os }}-android-sdk-37-36.0.0 - - - name: Install Android SDK packages + - name: Resolve version and whether it is a new release + id: v + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} run: | - yes | sdkmanager --licenses >/dev/null || true - sdkmanager \ - "platform-tools" \ - "platforms;android-37.0" \ - "build-tools;36.0.0" + set -e + VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) + if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi + MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3) + MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} + VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT" + # Pre-1.0 is not stable yet: mark the Gitea release as a pre-release + # while MAJOR is 0. Graduates to a stable release automatically at 1.0.0. + if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi + echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" + echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "Manual dispatch — re-sign path, not a release." + echo "is_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # A tag for this version already existing means the release shipped on + # an earlier push; do nothing. Absent => this merge cuts the release. + STATUS=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION") + if [ "$STATUS" = "200" ]; then + echo "Tag v$VERSION already exists — nothing to release." + echo "is_release=false" >> "$GITHUB_OUTPUT" + else + echo "No tag for v$VERSION yet — cutting the release." + echo "is_release=true" >> "$GITHUB_OUTPUT" + fi - - name: Setup Gradle cache - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew - - # Lint already enforced on every push to main via ci.yaml. - # Release sanity only re-runs tests + a debug build to catch - # any tag-resolved drift (e.g. version code substitution issues). - - - name: Unit tests - run: ./gradlew testDebugUnitTest - - - name: Assemble debug APK (sanity) - run: ./gradlew assembleDebug - - build-and-deploy: - needs: ci + # Releases: build + sign + publish, then mint the tag and Gitea release. + # Also runs on manual dispatch, where it skips the build and just re-signs and + # re-uploads the existing index (recovery path). + release: + needs: detect + if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch' runs-on: docker env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk + VERSION: ${{ needs.detect.outputs.version }} + VERSION_CODE: ${{ needs.detect.outputs.version_code }} + IS_RELEASE: ${{ needs.detect.outputs.is_release }} + PRERELEASE: ${{ needs.detect.outputs.prerelease }} steps: - name: Checkout uses: actions/checkout@v4 @@ -125,31 +142,26 @@ jobs: $SUDO apk add --no-cache jq fi - # Tag-only build steps. On a manual workflow_dispatch (ref = a branch, - # not a tag) these are skipped: the job then just re-signs the existing - # index with the configured repo key and re-uploads — used for key - # rotation / repo recovery without publishing a new APK. - - name: Set version from git tag - if: startsWith(github.ref, 'refs/tags/') + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # The committed versionName is the source of truth. Pin versionCode to the + # value derived from it so the published APK's code is always + # MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten. + - name: Pin versionCode to versionName + if: env.IS_RELEASE == 'true' run: | set -e - RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" - VERSION="${RAW_TAG#v}" - MAJOR=$(echo "$VERSION" | cut -d. -f1) - MINOR=$(echo "$VERSION" | cut -d. -f2) - PATCH=$(echo "$VERSION" | cut -d. -f3) - MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} - VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) - echo "Version: $VERSION, VersionCode: $VERSION_CODE" - sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts grep -E 'versionName|versionCode' app/build.gradle.kts - # Export for later steps (F-Droid changelog, mapping asset name). - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV" + + # Test the exact commit being shipped (only on a real release). + - name: Unit tests + if: env.IS_RELEASE == 'true' + run: ./gradlew testDebugUnitTest - name: Setup Android Keystore - if: startsWith(github.ref, 'refs/tags/') + if: env.IS_RELEASE == 'true' env: KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} @@ -164,11 +176,8 @@ jobs: storeFile=upload-keystore.jks EOF - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew - - name: Build release APK - if: startsWith(github.ref, 'refs/tags/') + if: env.IS_RELEASE == 'true' run: ./gradlew assembleRelease - name: Setup F-Droid Server Tools @@ -206,8 +215,7 @@ jobs: set -euo pipefail # Fail loudly if the repo key is not configured. NEVER auto-generate # one: a fresh key changes the repo fingerprint and breaks every - # user's pinned repo. (Replaces the old `fdroid update --create-key` - # path, which silently rotated the key on a wiped server.) + # user's pinned repo. if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2 echo "Refusing to continue — will not auto-generate a new repo key." >&2 @@ -220,42 +228,33 @@ jobs: mkdir -p fdroid/repo/icons - name: Copy new APK to repo - if: startsWith(github.ref, 'refs/tags/') + if: env.IS_RELEASE == 'true' run: | set -e mkdir -p fdroid/repo - REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" - SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')" - if [ -z "$SAFE_REF_NAME" ]; then - SAFE_REF_NAME="${GITHUB_SHA:-manual}" - fi - cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_${SAFE_REF_NAME}.apk" + cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk" - - name: Copy metadata to F-Droid repo + # Per-version "What's New": ensure this version's changelog exists in the + # fastlane tree (committed at release-cut time for the official repo; this + # regenerates it from CHANGELOG.md so the self-hosted repo never depends on + # the commit having happened). The transform below then carries it across. + - name: Ensure this version's changelog is in the fastlane tree + if: env.IS_RELEASE == 'true' + run: bash scripts/sync_changelog_to_fastlane.sh + + - name: Build F-Droid metadata from fastlane (single source of truth) run: | mkdir -p fdroid/metadata - cp -r fdroid-metadata/* fdroid/metadata/ - - # Per-version "What's New" for F-Droid clients: the tag's CHANGELOG - # section written to changelogs/.txt (same extraction as the - # Gitea release notes). en-US only — F-Droid falls back to it for locales - # without their own changelog. fdroid update bakes this into the index. - - name: Generate F-Droid changelog for this version - if: startsWith(github.ref, 'refs/tags/') - run: | - set -e - awk -v ver="$VERSION" ' - $0 ~ "^## \\[" ver "\\]" { flag = 1; next } - /^## \[/ { flag = 0 } - flag' CHANGELOG.md > /tmp/changelog.txt - sed -i -e '/./,$!d' /tmp/changelog.txt - if [ ! -s /tmp/changelog.txt ]; then - echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt - fi - CL_DIR="fdroid/metadata/de.jeanlucmakiola.agendula/en-US/changelogs" - mkdir -p "$CL_DIR" - cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt" - echo "Wrote $CL_DIR/${VERSION_CODE}.txt" + # App-level control file (Categories/License/links) for the self-hosted + # repo's `fdroid update`. + cp fdroid-metadata/de.jeanlucmakiola.agendula.yml fdroid/metadata/ + # Localized text + graphics + per-version changelogs come from the SAME + # fastlane tree the official F-Droid repo harvests from source, + # transformed into the F-Droid repo "localized" layout. One source of + # truth, both channels. + bash scripts/fastlane_to_fdroid_localized.sh \ + fastlane/metadata/android \ + fdroid/metadata/de.jeanlucmakiola.agendula - name: Generate F-Droid Index run: | @@ -276,99 +275,47 @@ jobs: SFTP # Publish the signed repo/ plus metadata/ (descriptions, screenshots, # per-version changelogs) so changelog history survives across - # releases. keystore.p12 and config.yml are NEVER uploaded, so they - # can't re-enter the web-served tree; nginx serves only repo/ anyway. + # releases. keystore.p12 and config.yml are NEVER uploaded. sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/" - # Archive the R8 mapping so user crash stacktraces stay deobfuscatable. - # Attached to the Gitea release (it's not an APK, so it fits the - # no-binaries rule). Best-effort: never fail a release over it. - - name: Attach R8 mapping to Gitea release - if: startsWith(github.ref, 'refs/tags/') - continue-on-error: true + # The APK is published and the index re-signed — now record the release. + # Creating it with target_commitish makes Gitea create the vX.Y.Z tag at + # this commit, so the tag only ever marks a fully-shipped release (and a + # failure before here leaves no tag, so re-running the workflow retries). + - name: Create tag + Gitea release + if: env.IS_RELEASE == 'true' env: TOKEN: ${{ secrets.GITHUB_TOKEN }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + SHA: ${{ github.sha }} run: | set -e - MAP="app/build/outputs/mapping/release/mapping.txt" - if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi - TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" - ASSET="mapping-${VERSION:-$TAG}.txt.gz" - gzip -c "$MAP" > "/tmp/$ASSET" - # The release is created by the gitea-release job; ensure it exists - # (idempotent) so this job doesn't race it to a 404. - ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty') - if [ -z "$ID" ]; then - ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \ - "$API/releases" | jq -r '.id // empty') - fi - if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi - # Replace any prior asset of the same name (re-run safe). - OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ - | jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id') - [ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true - curl -s -X POST -H "Authorization: token $TOKEN" \ - -F "attachment=@/tmp/$ASSET" \ - "$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n" - - # A Gitea release per tag, carrying the tag's CHANGELOG section as its - # notes. Deliberately no APK assets — distribution stays with the F-Droid - # repo; the release is the human-readable record. Gated on the tests-only - # ci job (not the deploy) so notes appear even if the F-Droid upload has - # an infrastructure hiccup. - gitea-release: - needs: ci - if: startsWith(github.ref, 'refs/tags/') - runs-on: docker - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Extract changelog section for this tag - run: | - set -e - TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" - VERSION="${TAG#v}" - # Everything between "## []" and the next "## [" heading. + TAG="v$VERSION" + # Notes = this version's CHANGELOG section. awk -v ver="$VERSION" ' $0 ~ "^## \\[" ver "\\]" { flag = 1; next } /^## \[/ { flag = 0 } flag' CHANGELOG.md > release-notes.md - # Trim leading blank lines. sed -i -e '/./,$!d' release-notes.md if [ ! -s release-notes.md ]; then echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md fi - echo "--- release notes ---" - cat release-notes.md - - - name: Create Gitea release - env: - TOKEN: ${{ secrets.GITHUB_TOKEN }} - API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} - run: | - set -e - TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" - python3 - "$TAG" <<'PY' > payload.json + python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json import json, sys print(json.dumps({ "tag_name": sys.argv[1], + "target_commitish": sys.argv[2], "name": sys.argv[1], "body": open("release-notes.md").read(), "draft": False, - "prerelease": False, + # Pre-1.0 releases are flagged as pre-releases (see detect job). + "prerelease": sys.argv[3] == "true", })) PY - # Upsert: the build-and-deploy job may have created a bare release - # first (to attach the mapping asset), so PATCH the notes if it - # exists, otherwise POST a new one. Both paths are re-run safe. + # Upsert (re-run safe): PATCH if a release for the tag already exists, + # else POST a new one (which also creates the tag at target_commitish). curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json - ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true) + ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true) if [ -n "$ID" ]; then CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ @@ -382,6 +329,110 @@ jobs: fi cat response.json if [ "$CODE" != "$OK" ]; then - echo "Release upsert failed with HTTP $CODE (expected $OK)" + echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2 exit 1 fi + echo "Created/updated release $TAG at $SHA" + + # Archive the R8 mapping so user crash stacktraces stay deobfuscatable. + # Attached to the release (it's not an APK, so it fits the no-binaries + # rule). Best-effort: never fail a release over it. + - name: Attach R8 mapping to Gitea release + if: env.IS_RELEASE == 'true' + continue-on-error: true + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + run: | + set -e + MAP="app/build/outputs/mapping/release/mapping.txt" + if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi + TAG="v$VERSION" + ASSET="mapping-${VERSION}.txt.gz" + gzip -c "$MAP" > "/tmp/$ASSET" + ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty') + if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi + # Replace any prior asset of the same name (re-run safe). + OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ + | jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id') + [ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true + curl -s -X POST -H "Authorization: token $TOKEN" \ + -F "attachment=@/tmp/$ASSET" \ + "$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n" + + # Mirror the release to the Codeberg mirror as a direct-download channel + # for users who don't want F-Droid. Gitea already push-mirrors branches + + # tags to Codeberg, but releases aren't git objects so they don't sync — + # we create the release there over the API and attach the signed APK plus + # a SHA-256 checksum. The APK is identical to the F-Droid one (same app + # key), so this adds no trust surface. Best-effort: a Codeberg outage + # (it 504s under load) must never fail an already-published F-Droid + # release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset. + - name: Publish release to Codeberg + if: env.IS_RELEASE == 'true' + continue-on-error: true + env: + TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }} + API: https://codeberg.org/api/v1/repos/jlmakiola/agendula + SHA: ${{ github.sha }} + run: | + set -e + if [ -z "${TOKEN:-}" ]; then + echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish." + exit 0 + fi + TAG="v$VERSION" + APK="app/build/outputs/apk/release/app-release.apk" + if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi + ASSET_APK="agendula_v${VERSION}.apk" + ASSET_SUM="${ASSET_APK}.sha256" + cp "$APK" "/tmp/$ASSET_APK" + ( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" ) + + # Release notes: reuse the section extracted for the Gitea release, + # fall back to the CHANGELOG entry if that step's file is gone. + if [ ! -s release-notes.md ]; then + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { flag = 1; next } + /^## \[/ { flag = 0 } + flag' CHANGELOG.md > release-notes.md + sed -i -e '/./,$!d' release-notes.md + fi + [ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md + python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > cb-payload.json + import json, sys + print(json.dumps({ + "tag_name": sys.argv[1], + "target_commitish": sys.argv[2], + "name": sys.argv[1], + "body": open("release-notes.md").read(), + "draft": False, + # Pre-1.0 releases are flagged as pre-releases (see detect job). + "prerelease": sys.argv[3] == "true", + })) + PY + # Upsert (re-run safe). POST also creates the tag at target_commitish + # if the push mirror hasn't synced it yet. + ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty') + if [ -n "$ID" ]; then + curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d @cb-payload.json "$API/releases/$ID" + else + curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d @cb-payload.json "$API/releases" + ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true) + fi + if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi + + # Attach APK + checksum, replacing any prior asset of the same name. + for A in "$ASSET_APK" "$ASSET_SUM"; do + OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ + | jq -r --arg n "$A" '.[] | select(.name==$n) | .id') + [ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true + curl -s -X POST -H "Authorization: token $TOKEN" \ + -F "attachment=@/tmp/$A" \ + "$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n" + done + echo "Published $TAG to Codeberg." diff --git a/CHANGELOG.md b/CHANGELOG.md index 747f79f..16d2262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Changelog All notable changes to this project are documented here. The format follows -[Keep a Changelog](https://keepachangelog.com/); the latest released git tag is -the source of truth for version codes (see Calendula's `docs/RELEASING.md`). +[Keep a Changelog](https://keepachangelog.com/); the `versionName` committed in +`app/build.gradle.kts` is the source of truth for a release (see +`docs/RELEASING.md`), and the `vX.Y.Z` tag is minted by the pipeline. ## [Unreleased] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5eef705..5a5d335 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,11 +30,12 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer. ```sh ./gradlew :app:assembleDebug # build the debug APK ./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) -./gradlew lintDebug # Android lint (CI runs this on every push) +./gradlew lintDebug # Android lint (CI runs this on every PR) ``` -CI (`.gitea/workflows/ci.yaml`) runs lint → unit tests → debug build on every -push, so run these locally before opening a PR. Keep CI green. +CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check, +then lint → unit tests → debug build on every pull request, so run these locally +before opening a PR. Keep CI green. ## Where to put code @@ -75,8 +76,10 @@ push, so run these locally before opening a PR. Keep CI green. - If your change shifts the architecture or completes a milestone, update [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md) in the same PR. -- Don't bump `versionName` / `versionCode` by hand — the git tag drives those at - release time. +- Don't bump `versionName` / `versionCode` in a regular PR — the committed + `versionName` is bumped only when **cutting a release** (that bump reaching + `main` is what triggers the release; the pipeline then mints the tag). See + [`docs/RELEASING.md`](docs/RELEASING.md). ## Scope diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2db835d..4d62636 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,11 +23,12 @@ android { applicationId = "de.jeanlucmakiola.agendula" minSdk = 29 targetSdk = 36 - // The git tag is the single source of truth for released builds: at - // release time .gitea/workflows/release.yaml derives both fields from - // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH - // (e.g. v2.0.0 -> 20000). These committed values are the dev/local - // default; keep them matching the latest released tag. See docs/RELEASING.md. + // These committed values ARE the source of truth for a release: merging + // a bumped versionName into main triggers .gitea/workflows/release.yaml, + // which builds this version and then creates the matching vX.Y.Z tag + + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + + // PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked + // as a pre-release while MAJOR is 0. See docs/RELEASING.md. versionCode = 200 versionName = "0.2.0" @@ -47,6 +48,11 @@ android { buildTypes { release { + // Keep release builds reproducible for F-Droid: don't let AGP embed + // build-environment git metadata (META-INF/version-control-info.textproto), + // whose `revision`/path content varies by build machine and is the only + // thing that otherwise differs from a clean from-source rebuild. + vcsInfo { include = false } isMinifyEnabled = true isShrinkResources = true proguardFiles( @@ -61,6 +67,22 @@ android { applicationIdSuffix = ".debug" isMinifyEnabled = false } + // A locally-installable twin of `release`: same R8 shrinking + obfuscation + // and resource shrinking, but debug-signed and given its own applicationId + // suffix so it installs alongside both the production app (signed with the + // real key) and the debug build. Used to smoke-test a release candidate on + // a real device before merging to main — R8-only breakage and first-run/ + // permission states don't surface in the unminified debug build, nor on a + // device that already holds the permission. Never published. See + // docs/RELEASING.md. + create("releaseTest") { + initWith(getByName("release")) + applicationIdSuffix = ".releasetest" + signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = true + isShrinkResources = true + matchingFallbacks += "release" + } } compileOptions { @@ -73,6 +95,16 @@ android { buildConfig = true } + // Don't embed AGP's dependency-metadata block in the APK signing block. It's + // a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any + // "extra signing block" — so leaving it in blocks publishing to the official + // repo. It lives in the signing block, not the zip entries, so disabling it + // doesn't change the build output (reproducibility is unaffected). + dependenciesInfo { + includeInApk = false + includeInBundle = false + } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 94526c4..4b7291c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -243,7 +243,7 @@ the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; | UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) | | Other | DataStore, kotlinx-datetime, kotlinx-coroutines | | Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam | -| Versioning | git tag is the source of truth; `versionCode = MAJOR*10000 + MINOR*100 + PATCH`, derived in CI at release. See [`RELEASING.md`](RELEASING.md). | +| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). | | CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) | | Distribution | F-Droid (`fdroid-metadata/`) | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 620464e..bd6c97d 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,101 +1,163 @@ # Agendula — releasing -Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with a -human-readable **Gitea release** per tag. Both are produced automatically by -`.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on -the Gitea release itself — distribution lives in the F-Droid repo; the release is -the changelog of record. +Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with +a human-readable **Gitea release** per version. Both are produced automatically +by `.gitea/workflows/release.yaml` when a **bumped `versionName` reaches `main`** +— the pipeline builds and publishes that version, then creates the matching +`vX.Y.Z` tag and Gitea release itself. There are no APK assets on the Gitea +release: distribution lives in the F-Droid repo; the release is the changelog of +record. + +While Agendula is pre-1.0 (`versionName` starts with `0.`), every Gitea release +is flagged as a **pre-release**. This happens automatically and graduates to a +stable release at `1.0.0` — no manual toggling. --- -## The one source of truth: the git tag +## The source of truth: the committed version -The git tag drives the version. You do **not** hand-edit version numbers for a -release — CI substitutes them from the tag: +A release is defined by the `versionName`/`versionCode` committed in +`app/build.gradle.kts` — **not** by a hand-pushed tag: -- `versionName` = the tag without a leading `v` (e.g. `v0.2.0` → `0.2.0`). -- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (e.g. `0.2.0` → `200`, - `1.3.4` → `10304`). +- `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`) +- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0` → `200`, + `1.3.4` → `10304`) -The values committed in `app/build.gradle.kts` are just the local/dev default; -keep them roughly matching the latest released tag, but the tag wins at release -time. +So `MINOR` and `PATCH` each have room for 0–99. The release pipeline reads +`versionName`, pins `versionCode` to the derived value, builds, publishes, and — +once the APK is live — creates the tag `v` at that commit. The tag +is an **output** of a successful release, not its trigger, so a tag always marks +a fully-shipped version (and a failure before publish leaves no tag, so +re-running the workflow safely retries). --- ## Cutting a release -1. **Update `CHANGELOG.md`.** Move items out of `[Unreleased]` into a new - `## [X.Y.Z]` section. The release pipeline extracts everything between - `## [X.Y.Z]` and the next `## [` heading and uses it verbatim as both the - Gitea release notes and the F-Droid per-version "What's New" - (`changelogs/.txt`). If no matching section exists, a fallback - line is used — so the heading **must** match the tag's version exactly. -2. **Commit** the changelog on `main`. -3. **Tag and push:** +1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the + feature/fix branches for this release into it. Everything below happens on + that branch, before it reaches `main`. +2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new + `## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading + and the next `## [` becomes both the Gitea release notes and the F-Droid + per-version "What's New". The heading **must** match the version exactly. +3. **Bump the committed `versionName`** (and `versionCode`) in + `app/build.gradle.kts`. **This bump is what triggers the release** when the + branch merges to `main`. Then run ```sh - git tag v0.2.0 - git push origin v0.2.0 + scripts/sync_changelog_to_fastlane.sh ``` -4. CI takes over (see below). Watch the run in Gitea Actions. + and commit the generated + `fastlane/metadata/android/en-US/changelogs/.txt` — this is what + makes the **official** F-Droid listing (which harvests the changelog from the + tagged source tree) show this version. The self-hosted pipeline regenerates it + regardless, so forgetting only affects the official listing. +4. **Verify the release build on a real device** — the mandatory gate: + ```sh + scripts/verify-release.sh + ``` + It builds the `releaseTest` variant (same R8 config as `release`, debug-signed + with a `.releasetest` suffix so it installs alongside the real app) and resets + it to a first-run state. Then, on the device: + - launch from a **clean / permission-not-granted** state — the permission + screen must appear, no crash; + - grant tasks access — the task list must load; + - create a task with a due reminder and confirm the notification fires; + - exercise this release's headline changes. + + Only proceed once all of that passes on-device. +5. **Merge `release/vX.Y.Z` into `main`.** That's it — no manual tagging. The + merge triggers `release.yaml`, which detects the new version, builds, signs, + publishes to F-Droid, and creates the `vX.Y.Z` tag + Gitea release. + +> The `releaseTest` build type exists only for step 4 — it is never published. +> The pipeline always builds and signs the real `release` variant. --- -## What CI does on a tag +## What the pipeline does -`release.yaml` runs three jobs: +CI and release are split so a change is built once on its PR and only does +release work when a merge actually cuts a release: -| Job | Purpose | -|---|---| -| `ci` | Sanity gate: unit tests + a debug build (catches version-substitution drift). The other jobs depend on this. | -| `build-and-deploy` | Substitute version from the tag → build signed release APK → fetch the existing F-Droid repo from Hetzner → add the new APK + per-version changelog → `fdroid update -c` → upload `repo/` + `metadata/` back. Also attaches the R8 `mapping.txt.gz` to the Gitea release (best-effort) so crash stacktraces stay deobfuscatable. | -| `gitea-release` | Create/update the Gitea release for the tag, body = the extracted CHANGELOG section. Gated on `ci` only (not deploy), so notes still publish if the F-Droid upload hiccups. | +- **`ci.yaml`** (on `pull_request`) — the reproducible-release invariant guard + (`scripts/check_reproducible_release.sh`), then lint + unit tests + a debug + assemble and a Trivy scan, once per PR. Docs/metadata-only PRs skip the Android + build but still report a green `CI` check. +- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap + `detect` job reads `versionName` and checks whether a tag for it already + exists. Only when it doesn't does the `release` job run: unit tests on the + merged commit, pin `versionCode`, build & sign the release APK with the **app + key**, copy it into the F-Droid repo, generate the per-version changelog from + the fastlane tree, re-sign the index with the **repo key**, upload `repo/` + + `metadata/`, then create the `vX.Y.Z` tag + Gitea release (CHANGELOG section as + notes, flagged pre-release while `MAJOR` is 0), attach the R8 `mapping.txt`, + and mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum + (both best-effort). Ordinary merges with no version bump fall through `detect` + and do nothing. -Both deploy and release steps are **re-run safe** (idempotent upserts), so a -failed run can be retried. +### Codeberg direct-download channel + +Alongside F-Droid, each release is mirrored to the Codeberg repo +(`jlmakiola/agendula`) as a plain download for users who don't want F-Droid. +Gitea already **push-mirrors** branches and tags to Codeberg, but releases +aren't git objects and don't sync, so the pipeline creates the release over the +Codeberg API and attaches `agendula_v.apk` + its `.sha256`. It's the +same APK the F-Droid repo serves (same **app key**), so it adds no trust surface. +The step is best-effort: a Codeberg outage never fails an already-published +F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset. +One-time setup: the Codeberg repo's **Releases** unit must be enabled and a +`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope) +added to Gitea Actions. + +### Manual re-sign / recovery + +A manual `workflow_dispatch` of the release workflow runs a **re-sign-only** +path: `detect` reports it's not a release, so the `release` job skips the APK +build, the version pin, and tag/release creation, and just re-signs the existing +F-Droid index with the configured repo key and re-uploads. Use this for key +rotation or repo recovery without publishing a new app version. --- -## Required CI secrets +## Secrets (Gitea → repo Settings → Actions → Secrets) -Configured in the Gitea repo settings; the workflow fails loudly if the F-Droid -ones are missing (it will **never** auto-generate a repo key — that would rotate -the repo fingerprint and break every user's pinned repo). +The workflow fails loudly if the F-Droid ones are missing — it will **never** +auto-generate a repo key (that would rotate the repo fingerprint and break every +user's pinned repo). -| Secret | Used for | -|---|---| -| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | App signing keystore (the APK). | -| `FDROID_KEYSTORE_BASE64`, `FDROID_CONFIG_BASE64` | The F-Droid **repo** signing key + `config.yml`. Never uploaded to the server. | -| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | SFTP target for the published `repo/` + `metadata/`. | -| `GITHUB_TOKEN` | Gitea API (release create/patch, asset upload). | +| Secret | Purpose | +| --- | --- | +| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key — signs the APK. Losing it means existing installs can't be updated. | +| `FDROID_KEYSTORE_BASE64` | **F-Droid repo** signing key (`keystore.p12`, base64). Signs the repo index. | +| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. | +| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. | +| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. | +| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. | -The repo signing key and `config.yml` come from secrets at build time and are -**never** pulled from or pushed back to the server, so they can't leak into the -web-served tree (nginx serves only `repo/`). +The app key signs APKs; the repo key signs the index (its fingerprint is what +users pin). Neither key nor `config.yml` is ever uploaded to the server — they +live only in CI secrets and are reconstructed in-runner (nginx serves only +`repo/`). --- -## Key rotation / repo recovery +## F-Droid metadata (single source of truth) -A manual `workflow_dispatch` run (ref = a branch, not a tag) skips all the -tag-only build steps: it just re-signs the existing index with the configured -repo key and re-uploads. Use this to recover the repo or rotate infrastructure -**without** publishing a new APK. +Store-listing text lives in **`fastlane/metadata/android//`** — the same +tree the official F-Droid repo harvests from source. At release time +`scripts/fastlane_to_fdroid_localized.sh` transforms it into the F-Droid repo's +"localized" layout, so there is no second copy to maintain. The app-level control +file (`Categories`/`License`/links) stays in +`fdroid-metadata/de.jeanlucmakiola.agendula.yml`. Per-version changelogs are +seeded into `fastlane/.../en-US/changelogs/.txt` by +`scripts/sync_changelog_to_fastlane.sh` (step 3 above) and carried across by the +transform. --- -## Push CI (non-tag) +## Crash deobfuscation -Every push to any branch runs `.gitea/workflows/ci.yaml`: `lintDebug` → -`testDebugUnitTest` → `assembleDebug`, plus a Trivy filesystem scan on `main`. -Keep this green before tagging. - ---- - -## F-Droid metadata - -App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.agendula.yml` -plus `en-US/` summary/description). Per-version changelogs are generated into the -repo's `metadata/.../en-US/changelogs/.txt` from `CHANGELOG.md` at -release time; metadata is uploaded alongside `repo/` so changelog history -survives across releases. +Each release attaches `mapping-.txt.gz` (the R8 mapping) to its Gitea +release. To deobfuscate a user stacktrace, download the mapping for that version +and run it through `retrace`. diff --git a/fastlane/metadata/android/en-US/changelogs/.gitkeep b/fastlane/metadata/android/en-US/changelogs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fastlane/metadata/android/en-US/changelogs/200.txt b/fastlane/metadata/android/en-US/changelogs/200.txt new file mode 100644 index 0000000..2b497c3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/200.txt @@ -0,0 +1,30 @@ +### Added +- M2 Material 3 Expressive UI — the app is fully navigable now: the + provider/permission onboarding gate, the lists overview (smart lists + user + lists grouped by account), and the task list (swipe-to-complete / -delete, + inline add, smart-list section headers), detail, and create/edit screens, all + wired to the M1 ViewModels. +- M3 detail/edit polish: a "Progress" slider (percent-complete, 5% detents, + written to `Tasks.PERCENT_COMPLETE`) and conflict-safe saves — `updateTask` + re-checks the provider's `last_modified` against the value captured when the + form loaded and surfaces an overwrite-or-cancel prompt instead of clobbering an + external change (e.g. a DAVx5 sync). +- M4 subtasks (UI): reparent — a full-width, searchable "Parent task" sheet on + the edit form, with candidates grouped by due-date section, files a task under + any active top-level task in its list (or "None" to promote it); switching list + clears the now-invalid parent. Tapping a subtask in the detail screen opens its + own detail, which shows a "Part of …" parent card. On the task list a parent has + a dedicated expand button that reveals its children as a nested grouped run, + ending with an inline "add a subtask" row (an opt-out toggle is planned for the + M6 settings screen). +- Priority is coloured by level (green / amber / red pastels) on the list, detail, + and edit screens. + +### Fixed +- Overview open-counts now count top-level tasks only, so subtasks — and + especially open subtasks under a completed parent — no longer inflate a list's + "N open" or the smart-list counts. +- Swipe-to-delete now reveals its red background + icon as you drag (tracking the + live direction, not just the settled target), and a floating "Deleted · Undo" + chip defers the actual delete so it can be restored. + diff --git a/fdroid-metadata/de.jeanlucmakiola.agendula/en-US/description.txt b/fastlane/metadata/android/en-US/full_description.txt similarity index 100% rename from fdroid-metadata/de.jeanlucmakiola.agendula/en-US/description.txt rename to fastlane/metadata/android/en-US/full_description.txt diff --git a/fdroid-metadata/de.jeanlucmakiola.agendula/en-US/summary.txt b/fastlane/metadata/android/en-US/short_description.txt similarity index 100% rename from fdroid-metadata/de.jeanlucmakiola.agendula/en-US/summary.txt rename to fastlane/metadata/android/en-US/short_description.txt diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..2c1774e --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Agendula \ No newline at end of file diff --git a/scripts/check_reproducible_release.sh b/scripts/check_reproducible_release.sh new file mode 100755 index 0000000..ed08135 --- /dev/null +++ b/scripts/check_reproducible_release.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Reproducibility guard for the official F-Droid repo (de.jeanlucmakiola.agendula). +# +# F-Droid only republishes OUR signed binary if a from-source build reproduces it +# byte-for-byte and the binary carries no extra signing blocks. If any invariant +# below regresses, the official repo silently stalls on the last good version +# (fails safe — but you'd be stuck on an old release without noticing). So fail +# loudly here, on every PR. +# +# Each invariant guards against a known fdroiddata CI rejection cause (learned on +# the sibling Calendula repo's official-repo submission): +# 1. vcsInfo { include = false } — else AGP embeds env-dependent git +# metadata (META-INF/version-control-info.textproto) -> not reproducible. +# 2. no foojay toolchain resolver — F-Droid's offline source scanner +# rejects org.gradle.toolchains.foojay-resolver (it can fetch a JDK over +# the network at build time). +# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency +# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's +# binary scanner rejects as an extra signing block. +set -euo pipefail + +APP="app/build.gradle.kts" +SETTINGS="settings.gradle.kts" +fail=0 + +# 1. AGP VCS-info must be disabled on the release build. -z reads the whole file +# as one record so the match can span newlines; [^}] keeps it inside the block. +if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$APP"; then + echo "OK: vcsInfo { include = false } — no env-dependent VCS metadata embedded." +else + echo "ERROR: '$APP' release build is missing 'vcsInfo { include = false }'." >&2 + echo " AGP would embed version-control-info.textproto, breaking reproducibility." >&2 + fail=1 +fi + +# 2. The foojay toolchain resolver must not be present in any Gradle script. +# This includes the floret-kit submodule: it's an included build (composite +# build via `includeBuild`), so F-Droid evaluates its Gradle scripts too when +# building from source — the same offline-scanner bar applies to it. +gradle_files=("$SETTINGS" "$APP") +[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts) +if [ -d floret-kit ]; then + while IFS= read -r f; do gradle_files+=("$f"); done \ + < <(find floret-kit -name '*.gradle.kts' -not -path '*/build/*') +fi +if grep -qi 'foojay' "${gradle_files[@]}"; then + echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2 + echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2 + echo " (it can fetch a JDK over the network). Remove the plugin." >&2 + fail=1 +else + echo "OK: no foojay toolchain resolver — offline build scanner stays happy." +fi + +# 3. AGP dependency-metadata block must not be embedded in the APK. +if grep -Pzoq 'dependenciesInfo\s*\{[^}]*includeInApk\s*=\s*false' "$APP"; then + echo "OK: dependenciesInfo { includeInApk = false } — no extra APK signing block." +else + echo "ERROR: '$APP' is missing 'dependenciesInfo { includeInApk = false }'." >&2 + echo " AGP would embed a 'Dependency metadata' block (0x504b4453) in the APK" >&2 + echo " Signing Block, which F-Droid's binary scanner rejects." >&2 + fail=1 +fi + +if [ "$fail" -ne 0 ]; then + echo >&2 + echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2 + echo "stall. Fix the above before merging." >&2 + exit 1 +fi +echo "All reproducible-release invariants hold." diff --git a/scripts/fastlane_to_fdroid_localized.sh b/scripts/fastlane_to_fdroid_localized.sh new file mode 100755 index 0000000..044e56e --- /dev/null +++ b/scripts/fastlane_to_fdroid_localized.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Single source of truth: fastlane/metadata/android// feeds BOTH the +# official F-Droid repo (harvested from source automatically) and the +# self-hosted repo. This script transforms the fastlane layout into the F-Droid +# "localized" layout that the self-hosted `fdroid update` consumes, so we don't +# maintain two copies. +# +# usage: fastlane_to_fdroid_localized.sh +# e.g. scripts/fastlane_to_fdroid_localized.sh \ +# fastlane/metadata/android \ +# fdroid/metadata/de.jeanlucmakiola.agendula +# +# Mapping (fastlane -> F-Droid repo localized): +# short_description.txt -> summary.txt +# full_description.txt -> description.txt +# title.txt -> name.txt +# images/icon.png -> icon.png +# images/phoneScreenshots/* -> phoneScreenshots/* +# changelogs/.txt -> changelogs/.txt +# (changelogs are seeded into the fastlane tree by +# scripts/sync_changelog_to_fastlane.sh.) +set -euo pipefail + +SRC="${1:?need fastlane android dir, e.g. fastlane/metadata/android}" +OUT="${2:?need output localized dir, e.g. fdroid/metadata/}" + +shopt -s nullglob +for locdir in "$SRC"/*/; do + loc="$(basename "$locdir")" + dst="$OUT/$loc" + mkdir -p "$dst" + [ -f "$locdir/short_description.txt" ] && cp "$locdir/short_description.txt" "$dst/summary.txt" + [ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt" + [ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt" + [ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png" + if [ -d "$locdir/images/phoneScreenshots" ]; then + mkdir -p "$dst/phoneScreenshots" + cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/" + fi + # Per-version changelogs live in the same fastlane tree (see + # scripts/sync_changelog_to_fastlane.sh) and map straight across. + if [ -d "$locdir/changelogs" ]; then + mkdir -p "$dst/changelogs" + cp "$locdir"changelogs/* "$dst/changelogs/" + fi +done + +echo "Built F-Droid localized metadata in '$OUT' from '$SRC'" diff --git a/scripts/sync_changelog_to_fastlane.sh b/scripts/sync_changelog_to_fastlane.sh new file mode 100755 index 0000000..f6d0e93 --- /dev/null +++ b/scripts/sync_changelog_to_fastlane.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Write the current version's CHANGELOG.md section into the fastlane changelog +# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/.txt +# (en-US is F-Droid's fallback locale, so it covers every language). +# +# Run this when cutting a release (after editing CHANGELOG.md and bumping +# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL +# F-Droid repo — which reads the changelog from the tagged source tree — shows +# this version's "What's New". The self-hosted release pipeline also runs it so +# its changelog never depends on the file having been committed. Idempotent. +# +# Extraction matches the awk used for the Gitea release notes so all three +# (release notes, self-hosted changelog, official changelog) stay in sync. +set -euo pipefail +cd "$(dirname "$0")/.." # repo root + +VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts) +[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; } +MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.} +MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} +VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) + +CL_DIR="fastlane/metadata/android/en-US/changelogs" +mkdir -p "$CL_DIR" +OUT="$CL_DIR/${VERSION_CODE}.txt" + +awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { flag = 1; next } + /^## \[/ { flag = 0 } + flag' CHANGELOG.md > "$OUT" +# Trim leading blank lines (same as the pipeline did). +sed -i -e '/./,$!d' "$OUT" +if [ ! -s "$OUT" ]; then + echo "See CHANGELOG.md for $VERSION." > "$OUT" +fi + +CHARS=$(wc -m < "$OUT" | tr -d ' ') +echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)" +if [ "$CHARS" -gt 500 ]; then + echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2 +fi diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh new file mode 100755 index 0000000..a30adad --- /dev/null +++ b/scripts/verify-release.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Build the release-candidate APK and install it on a connected device for the +# mandatory pre-release on-device check (see docs/RELEASING.md). +# +# It builds the `releaseTest` variant: the same R8 shrinking + obfuscation and +# resource shrinking as the published `release` build, but debug-signed and +# with a `.releasetest` applicationId suffix so it installs alongside the +# production and debug apps. This is what surfaces release-only breakage (R8 +# stripping) and first-run states (permission not yet granted) that the +# unminified debug build — or a device that already holds the permission — +# silently hides. +# +# Usage: scripts/verify-release.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +PKG="de.jeanlucmakiola.agendula.releasetest" +APK="app/build/outputs/apk/releaseTest/app-releaseTest.apk" + +echo "==> Building release-candidate APK (releaseTest, R8 minified)…" +./gradlew :app:assembleReleaseTest + +echo "==> Installing $PKG …" +adb install -r "$APK" + +echo "==> Resetting to a first-run state (revoking tasks + notification permissions)…" +# Force the permission-not-granted state so the permission gate / onboarding is +# exercised every time — R8-only breakage and first-run crashes never show up in +# the unminified debug build, nor on a device that already holds the permission. +# Both tasks-provider permission sets are declared; revoke each so whichever the +# device's provider uses starts ungranted. +adb shell pm revoke "$PKG" org.dmfs.permission.READ_TASKS 2>/dev/null || true +adb shell pm revoke "$PKG" org.dmfs.permission.WRITE_TASKS 2>/dev/null || true +adb shell pm revoke "$PKG" org.tasks.permission.READ_TASKS 2>/dev/null || true +adb shell pm revoke "$PKG" org.tasks.permission.WRITE_TASKS 2>/dev/null || true +adb shell pm revoke "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true + +echo +echo "Installed and reset. Now verify ON THE DEVICE before releasing:" +echo " 1. Launch from a clean state — the permission screen must appear (no crash)." +echo " 2. Grant tasks access — the task list must load." +echo " 3. Create a task with a due reminder and confirm the notification fires." +echo " 4. Exercise the release's headline changes end to end." +echo +echo "Watch for crashes with: adb logcat -b crash" +echo "Only merge the release branch to main once all of the above pass on a device" +echo "(the merge is what publishes the release — see docs/RELEASING.md)."