From 31163da8688f0e6bb1d97ae9bac3f188c2e7ec42 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 15 Jun 2026 21:47:50 +0200 Subject: [PATCH] =?UTF-8?q?ci(release):=20P1=20hardening=20=E2=80=94=20ver?= =?UTF-8?q?sioning,=20F-Droid=20changelogs,=20R8=20mapping,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1.3 Versioning: the git tag is already the de-facto single source of truth (every published versionCode uses MAJOR*10000+MINOR*100+PATCH; committed 13 was a stale outlier). Align the committed default to 20000 and document the scheme in a comment + docs/RELEASING.md. P1.4 F-Droid changelogs: a tag-only step extracts the tag's CHANGELOG section into metadata/.../en-US/changelogs/.txt so clients show a per-version "What's New". Also upload metadata/ (non-secret, never web-served) alongside repo/ so changelog history survives across releases. P1.5 R8 mapping: attach mapping-.txt.gz to the Gitea release (best-effort, continue-on-error) so user crash stacktraces stay deobfuscatable. The gitea-release notes step is now an upsert (PATCH if the release already exists) so it composes with the mapping step creating the release first. P1.6 docs/RELEASING.md: release ritual, versioning scheme, secrets inventory, key custody/recovery, manual re-sign path, F-Droid repo details. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/release.yaml | 97 ++++++++++++++++++++++++++------ app/build.gradle.kts | 7 ++- docs/RELEASING.md | 101 ++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 17 deletions(-) create mode 100644 docs/RELEASING.md diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index bcdb668..643a817 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -140,6 +140,9 @@ jobs: 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" - name: Setup Android Keystore if: startsWith(github.ref, 'refs/tags/') @@ -229,6 +232,27 @@ jobs: 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.calendula/en-US/changelogs" + mkdir -p "$CL_DIR" + cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt" + echo "Wrote $CL_DIR/${VERSION_CODE}.txt" + - name: Generate F-Droid Index run: | cd fdroid @@ -246,9 +270,45 @@ jobs: -mkdir dev -mkdir dev/fdroid SFTP - # Publish ONLY the signed repo/. keystore.p12 and config.yml never - # leave CI, so they can no longer end up in the web-served tree. - sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo "$USER@$HOST:dev/fdroid/" + # 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. + 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 + 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="${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 @@ -288,13 +348,6 @@ jobs: run: | set -e TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" - # Re-runs must not fail on an already-published release. - STATUS=$(curl -s -o /dev/null -w '%{http_code}' \ - -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG") - if [ "$STATUS" = "200" ]; then - echo "Release for $TAG already exists — skipping." - exit 0 - fi python3 - "$TAG" <<'PY' > payload.json import json, sys print(json.dumps({ @@ -305,12 +358,24 @@ jobs: "prerelease": False, })) PY - 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") + # 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. + 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) + 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" != "201" ]; then - echo "Release creation failed with HTTP $CODE" + if [ "$CODE" != "$OK" ]; then + echo "Release upsert failed with HTTP $CODE (expected $OK)" exit 1 fi diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f61df77..1205b62 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,7 +23,12 @@ android { applicationId = "de.jeanlucmakiola.calendula" minSdk = 29 targetSdk = 36 - versionCode = 13 + // 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. + versionCode = 20000 versionName = "2.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..f268511 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,101 @@ +# Releasing Calendula + +Calendula is distributed through a self-hosted F-Droid repository. Every +release is built, signed, and published automatically by +`.gitea/workflows/release.yaml` when a version tag is pushed. + +## Versioning — the git tag is the single source of truth + +A release is defined by its tag, `vMAJOR.MINOR.PATCH` (e.g. `v2.1.0`). At +release time the workflow derives both Gradle fields from the tag: + +- `versionName` = the tag without the leading `v` (`2.1.0`) +- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`2.1.0` → `20100`) + +So `MINOR` and `PATCH` each have room for 0–99. The values committed in +`app/build.gradle.kts` are only the dev/local default — CI overwrites them +from the tag. Keep the committed `versionCode`/`versionName` matching the +**latest released tag** so local builds are sanely versioned; the published +value always comes from the tag. + +Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`→20000. + +## Cutting a release + +1. Move the `## [Unreleased]` section of `CHANGELOG.md` 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 changelog. +2. Optionally bump the committed `versionCode`/`versionName` in + `app/build.gradle.kts` to match the new version (keeps local builds tidy). +3. Commit, then tag and push: + ```bash + git tag vX.Y.Z + git push origin vX.Y.Z + ``` +4. The push triggers the release workflow. **Hold UI releases for on-device + review and explicit go-ahead before tagging.** + +## What the pipeline does + +`release.yaml` has three jobs: + +- **ci** — unit tests + a debug assemble (sanity). +- **build-and-deploy** — derives the version, builds & signs the release APK + with the app key, copies it into the F-Droid repo, generates the per-version + changelog, re-signs the F-Droid index with the **repo key**, uploads + `repo/` + `metadata/` to the box, and attaches the R8 `mapping.txt` to the + Gitea release (best-effort). +- **gitea-release** — creates/updates the Gitea release carrying the tag's + CHANGELOG section as notes. Gated on `ci` only (not the deploy) so notes + publish even if the F-Droid upload hiccups. + +### Manual re-sign / recovery + +A manual `workflow_dispatch` of the release workflow **from a branch** (not a +tag) runs a **re-sign-only** path: it skips the APK build 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. + +## Secrets (Gitea → repo Settings → Actions → Secrets) + +| 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. | + +The two keys are independent: the **app key** signs APKs; the **repo key** +signs the index (its fingerprint is what users pin). Neither key nor the +F-Droid `config.yml` is ever uploaded to the server — they live only in CI +secrets and are reconstructed in-runner. If `FDROID_KEYSTORE_BASE64` / +`FDROID_CONFIG_BASE64` are unset the workflow **fails loudly** rather than +minting a new repo key (which would break every user's pinned fingerprint). + +## Key custody & recovery + +- **Offline backups** of both keys (and passwords) live in a password manager. + These are the only safe copies — losing them is unrecoverable. +- **App key lost** → no existing install can be updated again; you'd have to + ship a new app under a new applicationId. +- **Repo key lost or compromised** → rotate it, publish the new fingerprint in + the README, and have users remove + re-add the repo. To rotate: generate a + new `keystore.p12` + `config.yml`, set them as the `FDROID_*` secrets, update + the README fingerprint, and run the manual re-sign dispatch above. + +## F-Droid repo + +- URL: `https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo` +- Fingerprint (current): `C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425` +- Served from the Hetzner storage box. **nginx serves only `…/fdroid/repo/`** — + the working dir (key, config, metadata) sits above it and must never be + web-reachable. After any webserver change, verify `keystore.p12` and + `config.yml` return 404 while `repo/index-v2.json` returns 200. + +## Crash deobfuscation + +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`.