ci(release): P1 hardening — versioning, F-Droid changelogs, R8 mapping, docs
All checks were successful
CI / ci (push) Successful in 8m11s

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/<versionCode>.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-<version>.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) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:47:50 +02:00
parent 9a1903e6ed
commit 31163da868
3 changed files with 188 additions and 17 deletions

View File

@@ -140,6 +140,9 @@ jobs:
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' 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 - name: Setup Android Keystore
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
@@ -229,6 +232,27 @@ jobs:
mkdir -p fdroid/metadata mkdir -p fdroid/metadata
cp -r fdroid-metadata/* 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/<versionCode>.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 - name: Generate F-Droid Index
run: | run: |
cd fdroid cd fdroid
@@ -246,9 +270,45 @@ jobs:
-mkdir dev -mkdir dev
-mkdir dev/fdroid -mkdir dev/fdroid
SFTP SFTP
# Publish ONLY the signed repo/. keystore.p12 and config.yml never # Publish the signed repo/ plus metadata/ (descriptions, screenshots,
# leave CI, so they can no longer end up in the web-served tree. # per-version changelogs) so changelog history survives across
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo "$USER@$HOST:dev/fdroid/" # 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 # A Gitea release per tag, carrying the tag's CHANGELOG section as its
# notes. Deliberately no APK assets — distribution stays with the F-Droid # notes. Deliberately no APK assets — distribution stays with the F-Droid
@@ -288,13 +348,6 @@ jobs:
run: | run: |
set -e set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" 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 python3 - "$TAG" <<'PY' > payload.json
import json, sys import json, sys
print(json.dumps({ print(json.dumps({
@@ -305,12 +358,24 @@ jobs:
"prerelease": False, "prerelease": False,
})) }))
PY PY
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \ # Upsert: the build-and-deploy job may have created a bare release
-H "Authorization: token $TOKEN" \ # first (to attach the mapping asset), so PATCH the notes if it
-H "Content-Type: application/json" \ # exists, otherwise POST a new one. Both paths are re-run safe.
-d @payload.json "$API/releases") 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 cat response.json
if [ "$CODE" != "201" ]; then if [ "$CODE" != "$OK" ]; then
echo "Release creation failed with HTTP $CODE" echo "Release upsert failed with HTTP $CODE (expected $OK)"
exit 1 exit 1
fi fi

View File

@@ -23,7 +23,12 @@ android {
applicationId = "de.jeanlucmakiola.calendula" applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29 minSdk = 29
targetSdk = 36 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" versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

101
docs/RELEASING.md Normal file
View File

@@ -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 099. 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] — <date>` 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-<version>.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`.