ci(release): add tag-triggered signed-release and store-publish workflow

Rewrite the manual-dispatch release.yml into the issue-#19 pipeline:
v* tag push (or dispatch with dry-run/re-release inputs) runs the fast
CI gate, builds bundleRelease + assembleRelease signed from base64
keystore secrets (falling back to *-unsigned artifacts when unset),
generates a Conventional-Commit changelog and SHA-256 checksums, then
creates the GitHub release and fans out to secret-gated Google Play
publish (staged rollout supported), a documented Galaxy Store manual
stub, and an S3-compatible archive under releases/<tag>/. Every
credentialed stage skips with a clear notice while the store accounts
(#16/#17/#18) don't exist yet; no secret lives in the repo and
app/build.gradle.kts is unchanged. docs/release.md documents the
secrets, flows, and per-store manual fallbacks.

Part of #19

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:01:34 -05:00
co-authored by Claude Fable 5
parent 0880dd7f54
commit 0b0f6b7018
2 changed files with 690 additions and 29 deletions
+198
View File
@@ -0,0 +1,198 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# Releasing LibreMail
`.github/workflows/release.yml` turns a version tag into store-ready artifacts and
publications. Every stage that needs credentials is **gated on CI secrets**: when a
secret is missing the stage skips with a log notice instead of failing, so the pipeline
already runs end-to-end before the store accounts (issues #16/#17/#18) exist. No key,
password or token is ever committed to the repo.
```
tag push (v*) ─► fast CI gate ─► build + sign ─┬─► GitHub release (changelog + artifacts + checksums)
or manual (assemble, unit AAB + APK, ├─► Google Play [needs signing + Play secrets]
dispatch tests, lint, SHA-256, ├─► Galaxy Store [manual for now — see below]
ktlint, detekt) changelog └─► S3 archive [needs ARCHIVE_S3_* secrets]
```
F-Droid needs no stage at all: it builds (and signs) packages itself from the pushed tag
and the fastlane metadata in the repo (issue #18).
## Cutting a release
1. Bump `versionCode` (must strictly increase for Play/Galaxy) and `versionName` in
`app/build.gradle.kts`, land that on `main` through a normal PR.
2. Tag the release commit and push the tag:
```bash
git tag -a v0.2.0 -m "LibreMail 0.2.0"
git push origin v0.2.0
```
3. The `Release` workflow runs automatically: fast CI gate → signed build → GitHub
release with a Conventional-Commit changelog → store publication / archiving for every
stage whose secrets are configured. The run's summary page shows a per-stage table.
The workflow warns (but does not fail) when the tag does not match `versionName`.
### Manual runs (`workflow_dispatch`)
The workflow can also be dispatched from the Actions tab:
| Input | Default | Meaning |
| --- | --- | --- |
| `tag` | *(empty)* | Existing tag to (re-)release — e.g. to retry a failed publication. Empty = rehearse against the branch head (build only; nothing can be published without a tag). |
| `dry_run` | `true` | Build, sign and checksum only; skip the GitHub release, store publication and archiving. |
| `play_track` | `internal` | Google Play track: `internal`, `alpha`, `beta` or `production`. |
| `play_rollout_fraction` | `0.10` | Staged-rollout user fraction for the `production` track (`0 < f < 1`), or `1.0` for a full rollout. Ignored on other tracks. |
A dispatch with no `tag` (or with `dry_run: true`) is the way to exercise the pipeline
today, with or without secrets.
## Required secrets
Configure under **Settings → Secrets and variables → Actions**. All are optional — each
missing group just disables its stage (with a `::notice::` in the build job's
"Plan release" step explaining what was skipped and why).
| Secret | Stage | Format / where to get it |
| --- | --- | --- |
| `RELEASE_KEYSTORE_BASE64` | Signing | Base64 of the release keystore file: `base64 -w0 release.keystore`. See [Release signing](#release-signing). |
| `RELEASE_KEYSTORE_PASSWORD` | Signing | Keystore password. Avoid backslashes (the value passes through a Java properties file). |
| `RELEASE_KEY_ALIAS` | Signing | Key alias inside the keystore. |
| `RELEASE_KEY_PASSWORD` | Signing | Password of that key. |
| `PLAY_SERVICE_ACCOUNT_JSON` | Google Play | Entire JSON key of a Google Cloud service account with release access in the Play Console. See [Google Play](#google-play). |
| `GALAXY_SERVICE_ACCOUNT_ID` | Galaxy Store *(reserved)* | Service-account ID from Samsung Seller Portal → Assistance → API Service. |
| `GALAXY_PRIVATE_KEY` | Galaxy Store *(reserved)* | PEM private key created with that service account. |
| `GALAXY_CONTENT_ID` | Galaxy Store *(reserved)* | The app's content ID in Seller Portal. |
| `ARCHIVE_S3_BUCKET` | S3 archive | Bucket name. |
| `ARCHIVE_S3_ACCESS_KEY_ID` | S3 archive | Access key with write access to the bucket. |
| `ARCHIVE_S3_SECRET_ACCESS_KEY` | S3 archive | Matching secret key. |
| `ARCHIVE_S3_ENDPOINT` | S3 archive *(optional)* | Endpoint URL for S3-compatible providers (MinIO, Backblaze B2, Cloudflare R2, …). Leave unset for AWS S3. |
| `ARCHIVE_S3_REGION` | S3 archive *(optional)* | Region; defaults to `us-east-1` (fine for most S3-compatibles). |
## Release signing
Generate the release keystore **once**, off-CI, and keep the original in a password
manager / offline backup — losing it means losing the ability to update the app on
stores that use it:
```bash
keytool -genkeypair -v -keystore release.keystore -alias libremail \
-keyalg RSA -keysize 4096 -validity 10000
base64 -w0 release.keystore # → RELEASE_KEYSTORE_BASE64
```
At build time the workflow decodes the keystore to the runner's temp dir and writes the
git-ignored `secrets.properties` that `app/build.gradle.kts` already reads
(`RELEASE_STORE_FILE` / `RELEASE_STORE_PASSWORD` / `RELEASE_KEY_ALIAS` /
`RELEASE_KEY_PASSWORD` — same keys as `secrets.properties.example`). Local builds are
unaffected: without the file, `assembleRelease`/`bundleRelease` fall back to the debug
key exactly as before.
**Without the four signing secrets** the workflow still runs, but artifacts are named
`LibreMail-<tag>-unsigned.{apk,aab}` (they carry only the throwaway debug-key fallback
signature — installable for testing, **not** publishable), the GitHub release is marked
as a pre-release with a warning, and Google Play publication is skipped.
If Play App Signing is enabled (recommended, and the default for new Play apps), this
keystore is the *upload key*; Google holds the actual app-signing key and an upload key
can be reset through Play support if lost. Galaxy Store and GitHub-release APKs are
signed directly with this key.
## Google Play
Prerequisites (issue #16): a Play developer account with the LibreMail listing created
and the **first AAB uploaded manually** through the Play Console (the API cannot create
the app or complete the initial listing/data-safety forms).
Service-account setup:
1. In Google Cloud, create a service account and a JSON key
(`IAM & Admin → Service accounts → Keys → Add key → JSON`).
2. In the Play Console: `Users and permissions → Invite new users` → the service
account's email → grant *Release to production* (or at least *Release apps to testing
tracks*) for LibreMail.
3. Store the whole JSON file as the `PLAY_SERVICE_ACCOUNT_JSON` secret.
The publish stage uploads the AAB (plus the R8 `mapping.txt` and a generated
`whatsnew-en-US` release note) with
[`r0adkll/upload-google-play`](https://github.com/r0adkll/upload-google-play).
**Staged rollout:** tag pushes publish to the safe default track `internal`. To go wider,
dispatch the workflow with the same `tag`, `play_track: production` and a
`play_rollout_fraction` such as `0.10` — that creates an `inProgress` (staged) release
for 10 % of users. Increase the fraction / complete the rollout from the **Play Console →
Releases** afterwards; re-dispatching with the same `versionCode` cannot re-upload the
bundle.
**Manual fallback:** download the AAB from the GitHub release, verify it against
`SHA256SUMS.txt`, and upload it in the Play Console (`Production → Create new release`).
## Galaxy Store
Automated submission is **deliberately stubbed** for now: Samsung ships no maintained
GitHub Action, community Gradle plugins are effectively unmaintained, and the
[Content Publish API](https://developer.samsung.com/galaxy-store/galaxy-store-developer-api/content-publish-api/overview.html)
is mid-migration — `contentUpdate` stops accepting `binaryList` in **July 2026** in favor
of new add/modify/delete-binary endpoints. Wiring an API in the middle of that change,
with no seller account to test against (issue #17), would only produce untested code.
The `galaxy-store` job therefore prints the manual procedure (and notices when the
reserved `GALAXY_*` secrets are present) and always succeeds.
**Manual path per release:**
1. Download `LibreMail-<tag>.apk` and `SHA256SUMS.txt` from the GitHub release and verify:
`sha256sum -c SHA256SUMS.txt`.
2. In [Samsung Seller Portal](https://seller.samsungapps.com), open the LibreMail entry,
add the APK as a new binary, paste the release notes from the release page, submit for
review.
**To automate later** (once #17 lands and the API migration settles): create a service
account under Seller Portal → *Assistance → API Service*, store the reserved `GALAXY_*`
secrets, and replace the stub with calls to the Content Publish API (JWT auth →
`createUploadSessionId` → `fileUpload` → add binary → `contentSubmit`), or adopt a
then-maintained action/plugin.
## F-Droid
There is intentionally **no push step**: F-Droid pulls from us. Its build servers check
out the pushed tag, build from source and sign the result themselves, driven by the
fastlane metadata in this repo and the recipe in fdroiddata (issue #18). Keeping the tag
= keeping the release; nothing else to do here. (This is also why opt-in-only telemetry
is a hard project constraint — see `README.md`.)
## Binary archive (S3)
When the `ARCHIVE_S3_*` secrets are set, every published release is copied to
S3-compatible storage using the AWS CLI (`--endpoint-url` supports MinIO, Backblaze B2,
Cloudflare R2, …):
```
s3://<ARCHIVE_S3_BUCKET>/releases/<tag>/
LibreMail-<tag>.aab
LibreMail-<tag>.apk
LibreMail-<tag>-mapping.txt
LibreMail-<tag>-src.zip ← source at the released commit (GPL §6 convenience)
LibreMail-<tag>-src.tar.gz
CHANGELOG.md
SHA256SUMS.txt ← SHA-256 for every LibreMail-* file above
```
Recommended bucket setup: enable **object versioning** and deny deletes (or add an
object-lock/retention rule) so prior binaries stay immutable; the per-release key prefix
keeps every version addressable either way.
**Manual fallback:** `aws s3 cp dist/ s3://<bucket>/releases/<tag>/ --recursive` with the
same layout, using artifacts downloaded from the GitHub release.
## What runs when
| Stage | Tag push | Dispatch (`dry_run`) | Dispatch (tag, `dry_run: false`) | Needs secrets |
| --- | --- | --- | --- | --- |
| Fast CI gate | ✔ | ✔ | ✔ | — |
| Build + checksum + workflow artifact | ✔ | ✔ | ✔ | — (signs when signing secrets exist) |
| GitHub release | ✔ | — | ✔ | — (repo `GITHUB_TOKEN`) |
| Google Play | ✔ | — | ✔ | signing + `PLAY_SERVICE_ACCOUNT_JSON` |
| Galaxy Store | stub (manual) | — | stub (manual) | *(reserved `GALAXY_*`)* |
| S3 archive | ✔ | — | ✔ | `ARCHIVE_S3_*` |