ci(release): P1 hardening — versioning, F-Droid changelogs, R8 mapping, docs
All checks were successful
CI / ci (push) Successful in 8m11s
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:
@@ -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
|
||||||
|
# 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 \
|
CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \
|
||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @payload.json "$API/releases")
|
-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
|
||||||
|
|||||||
@@ -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
101
docs/RELEASING.md
Normal 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 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] — <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`.
|
||||||
Reference in New Issue
Block a user