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

Merged
JMR-dev merged 3 commits from feat-release-workflow into main 2026-07-02 04:15:52 +00:00
2 changed files with 690 additions and 29 deletions
+492 -29
View File
@@ -1,34 +1,82 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Release pipeline (issue #19): tag → fast CI gate → build + sign → GitHub release,
# Google Play, Galaxy Store (stub), S3 archive. Full docs: docs/release.md.
#
# Every publish/archive stage is gated on its CI secrets and SKIPS with a clear log
# message when they are absent, so the workflow runs end-to-end today (before the
# store accounts from #16/#17/#18 exist). No secret ever lives in the repo.
#
# Secrets (all optional; configure in Settings → Secrets and variables → Actions):
# Signing RELEASE_KEYSTORE_BASE64, RELEASE_KEYSTORE_PASSWORD,
# RELEASE_KEY_ALIAS, RELEASE_KEY_PASSWORD
# → absent: artifacts are built with the debug-key fallback and named
# *-unsigned (installable for testing, NOT publishable).
# Play PLAY_SERVICE_ACCOUNT_JSON (also requires signing secrets)
# Galaxy GALAXY_SERVICE_ACCOUNT_ID, GALAXY_PRIVATE_KEY, GALAXY_CONTENT_ID
# (reserved — automated submission is stubbed; see docs/release.md#galaxy-store)
# Archive ARCHIVE_S3_BUCKET, ARCHIVE_S3_ACCESS_KEY_ID, ARCHIVE_S3_SECRET_ACCESS_KEY,
# ARCHIVE_S3_ENDPOINT (optional, for non-AWS), ARCHIVE_S3_REGION (optional)
#
# F-Droid needs no job here: it builds signed packages itself from the pushed tag and
# the repo's fastlane metadata (issue #18).
name: Release
on:
# The normal release path: push an annotated tag like v0.2.0.
push:
tags: ["v*"]
# Manual path: rehearse the pipeline (dry run) or re-run publication for an existing tag.
workflow_dispatch:
inputs:
tag:
description: "Release tag to create (e.g. v0.1.0)"
required: true
description: "Existing tag to (re-)release, e.g. v0.2.0. Leave empty to rehearse against the current branch head (build only)."
required: false
type: string
prerelease:
description: "Mark this GitHub release as a pre-release"
dry_run:
description: "Dry run: build, sign and checksum only — skip GitHub release, store publication and archiving."
required: false
type: boolean
default: true
play_track:
description: "Google Play track to publish to."
required: false
type: choice
options: [internal, alpha, beta, production]
default: internal
play_rollout_fraction:
description: "Staged-rollout user fraction for the production track (0 < f < 1, e.g. 0.10), or 1.0 for a full rollout. Ignored on other tracks."
required: false
type: string
default: "0.10"
# Never cancel a half-finished publication; queue instead.
concurrency:
group: release-${{ inputs.tag || github.ref }}
cancel-in-progress: false
# Needed to create the tag, the GitHub release, and upload its assets.
permissions:
contents: write
contents: read
env:
# Keep in sync with .github/workflows/ci.yml.
ANDROID_PLATFORM: "platforms;android-37.0"
ANDROID_BUILD_TOOLS: "build-tools;37.0.0"
jobs:
release:
name: Build APK and publish release
# Mirrors ci.yml's fast jobs (assembleDebug / testDebugUnitTest / static analysis, plus
# lintDebug) as the release prerequisite required by issue #19. ci.yml only runs on pull
# requests, so a tag push gets no other gate. The emulator E2E matrix is deliberately NOT
# duplicated here — it already gated every PR that reached the tagged commit.
fast-gate:
name: Fast CI gate
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
@@ -45,33 +93,448 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# No release keystore is configured in CI, so the build falls back to the debug
# signing key (installable for testing, not for store publication).
- name: Assemble release APK
run: ./gradlew assembleRelease --stacktrace
- name: Assemble, unit-test, lint, static analysis
run: ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:ktlintCheck :app:detekt --stacktrace
- name: Stage release artifacts (APK + source archives)
build:
name: Build and sign release artifacts
needs: [fast-gate]
runs-on: ubuntu-latest
timeout-minutes: 40
outputs:
release_tag: ${{ steps.plan.outputs.release_tag }}
version: ${{ steps.plan.outputs.version }}
publish: ${{ steps.plan.outputs.publish }}
signed: ${{ steps.plan.outputs.signed }}
prerelease: ${{ steps.plan.outputs.prerelease }}
have_play: ${{ steps.plan.outputs.have_play }}
have_galaxy: ${{ steps.plan.outputs.have_galaxy }}
have_s3: ${{ steps.plan.outputs.have_s3 }}
steps:
- name: Check out source (full history for the changelog)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
# `if:` cannot read the secrets context, so hoist secret *presence* into env here and
# expose the resulting gates as job outputs that downstream jobs test in their `if:`.
- name: Plan release (tag, signing, publish gates)
id: plan
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
HAVE_SIGNING: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' && secrets.RELEASE_KEYSTORE_PASSWORD != '' && secrets.RELEASE_KEY_ALIAS != '' && secrets.RELEASE_KEY_PASSWORD != '' }}
PARTIAL_SIGNING: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' || secrets.RELEASE_KEYSTORE_PASSWORD != '' || secrets.RELEASE_KEY_ALIAS != '' || secrets.RELEASE_KEY_PASSWORD != '' }}
HAVE_PLAY: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON != '' }}
HAVE_GALAXY: ${{ secrets.GALAXY_SERVICE_ACCOUNT_ID != '' && secrets.GALAXY_PRIVATE_KEY != '' && secrets.GALAXY_CONTENT_ID != '' }}
HAVE_S3: ${{ secrets.ARCHIVE_S3_BUCKET != '' && secrets.ARCHIVE_S3_ACCESS_KEY_ID != '' && secrets.ARCHIVE_S3_SECRET_ACCESS_KEY != '' }}
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
mkdir -p dist
apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)"
cp "$apk" "dist/LibreMail-${tag}.apk"
# Source archives contain only git-tracked files at the released commit.
git archive --format=zip --prefix="LibreMail-${tag}/" -o "dist/LibreMail-${tag}-src.zip" HEAD
git archive --format=tar --prefix="LibreMail-${tag}/" HEAD | gzip > "dist/LibreMail-${tag}-src.tar.gz"
ls -l dist
- name: Create GitHub release
tag=""
if [ "$EVENT_NAME" = "push" ]; then
tag="$GITHUB_REF_NAME"
else
tag="$INPUT_TAG"
fi
publish=false
if [ -n "$tag" ] && [ "$INPUT_DRY_RUN" != "true" ]; then
publish=true
fi
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -z "$tag" ]; then
echo "::notice::No tag input — rehearsal build only (no GitHub release, store publication or archiving)."
elif [ "$publish" != "true" ]; then
echo "::notice::Dry run — building and checksumming only; publication and archiving are skipped."
fi
version="${tag:-dev-$(git rev-parse --short HEAD)}"
prerelease=false
if [ "$HAVE_SIGNING" != "true" ]; then
prerelease=true
echo "::notice::Release signing secrets not configured — artifacts fall back to the debug key and are named *-unsigned (NOT store-publishable). See docs/release.md#release-signing."
if [ "$PARTIAL_SIGNING" = "true" ]; then
echo "::warning::Only some of the four RELEASE_* signing secrets are set; all four are required. Building unsigned."
fi
fi
case "$tag" in *-*) prerelease=true ;; esac
if [ "$HAVE_PLAY" != "true" ]; then
echo "::notice::Google Play publication will be skipped: secret PLAY_SERVICE_ACCOUNT_JSON is not configured. See docs/release.md#google-play."
elif [ "$HAVE_SIGNING" != "true" ]; then
echo "::warning::Google Play publication will be skipped: Play credentials are configured but the artifacts are unsigned (missing RELEASE_* signing secrets)."
fi
if [ "$HAVE_GALAXY" != "true" ]; then
echo "::notice::Galaxy Store credentials not configured — the Galaxy job only prints the manual publication path. See docs/release.md#galaxy-store."
fi
if [ "$HAVE_S3" != "true" ]; then
echo "::notice::S3 archiving will be skipped: ARCHIVE_S3_* secrets are not configured. See docs/release.md#binary-archive-s3."
fi
{
echo "release_tag=$tag"
echo "version=$version"
echo "publish=$publish"
echo "signed=$HAVE_SIGNING"
echo "prerelease=$prerelease"
echo "have_play=$HAVE_PLAY"
echo "have_galaxy=$HAVE_GALAXY"
echo "have_s3=$HAVE_S3"
} >> "$GITHUB_OUTPUT"
- name: Warn when the tag and versionName disagree
if: steps.plan.outputs.release_tag != ''
env:
RELEASE_TAG: ${{ steps.plan.outputs.release_tag }}
run: |
set -euo pipefail
version_name="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)".*/\1/p' app/build.gradle.kts | head -1)"
expected="${RELEASE_TAG#v}"
if [ "$version_name" != "$expected" ]; then
echo "::warning::Tag $RELEASE_TAG does not match versionName '$version_name' in app/build.gradle.kts — did you forget to bump versionCode/versionName before tagging? (docs/release.md#cutting-a-release)"
fi
# Reconstructs the git-ignored secrets.properties that app/build.gradle.kts already
# reads for release signing, and materialises the keystore from the base64 secret.
# Both live only on the ephemeral runner; nothing is written back to the repo.
- name: Configure release signing from CI secrets
if: steps.plan.outputs.signed == 'true'
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: |
set -euo pipefail
keystore="$RUNNER_TEMP/release.keystore"
printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 -d > "$keystore"
{
printf 'RELEASE_STORE_FILE=%s\n' "$keystore"
printf 'RELEASE_STORE_PASSWORD=%s\n' "$RELEASE_KEYSTORE_PASSWORD"
printf 'RELEASE_KEY_ALIAS=%s\n' "$RELEASE_KEY_ALIAS"
printf 'RELEASE_KEY_PASSWORD=%s\n' "$RELEASE_KEY_PASSWORD"
} > secrets.properties
echo "Release keystore configured from CI secrets."
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Build release AAB and APK
run: ./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
- name: Generate changelog from Conventional-Commit history
env:
RELEASE_TAG: ${{ steps.plan.outputs.release_tag }}
VERSION: ${{ steps.plan.outputs.version }}
SIGNED: ${{ steps.plan.outputs.signed }}
run: |
set -euo pipefail
mkdir -p dist/whatsnew
# Nearest tag strictly before HEAD (HEAD itself is the release tag on tag builds).
prev="$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || true)"
range="HEAD"
[ -n "$prev" ] && range="$prev..HEAD"
echo "Changelog range: $range"
log() { git log --no-merges --pretty='- %s (%h)' "$range"; }
changelog="dist/CHANGELOG.md"
printf '## LibreMail %s\n\n' "$VERSION" > "$changelog"
if [ "$SIGNED" != "true" ]; then
printf '> **Warning:** built without a release keystore — the attached binaries are debug-key signed placeholders and are **not** suitable for installation from app stores.\n\n' >> "$changelog"
fi
section() { # $1 = grep -E pattern over "- subject (hash)" lines, $2 = heading
local body
body="$(log | grep -E "$1" || true)"
if [ -n "$body" ]; then
printf '### %s\n\n%s\n\n' "$2" "$body" >> "$changelog"
fi
}
section '^- [a-z]+(\([^)]*\))?!:' 'Breaking changes'
section '^- feat[(!:]' 'Features'
section '^- fix[(!:]' 'Bug fixes'
section '^- perf[(!:]' 'Performance'
section '^- (docs|chore|ci|build|refactor|test|style)[(!:]' 'Maintenance'
# Anything that is not a Conventional Commit:
other="$(log | grep -Ev '^- (feat|fix|perf|docs|chore|ci|build|refactor|test|style)[(!:]' || true)"
if [ -n "$other" ]; then
printf '### Other changes\n\n%s\n\n' "$other" >> "$changelog"
fi
if ! log | grep -q .; then
printf '_No changes since %s._\n\n' "${prev:-the initial commit}" >> "$changelog"
fi
if [ -n "$prev" ] && [ -n "$RELEASE_TAG" ]; then
printf '**Full changelog**: https://github.com/%s/compare/%s...%s\n' "$GITHUB_REPOSITORY" "$prev" "$RELEASE_TAG" >> "$changelog"
fi
# Google Play "what's new" (500-char limit): user-facing entries only.
notes="$(log | grep -E '^- (feat|fix)[(!:]' | head -20 || true)"
[ -z "$notes" ] && notes="- Maintenance release"
printf 'LibreMail %s\n\n%s\n' "$VERSION" "$notes" | head -c 490 > dist/whatsnew/whatsnew-en-US
echo "----- CHANGELOG.md -----"
cat "$changelog"
- name: Stage artifacts and compute SHA-256 checksums
env:
VERSION: ${{ steps.plan.outputs.version }}
SIGNED: ${{ steps.plan.outputs.signed }}
run: |
set -euo pipefail
suffix=""
[ "$SIGNED" != "true" ] && suffix="-unsigned"
apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)"
cp "$apk" "dist/LibreMail-${VERSION}${suffix}.apk"
cp app/build/outputs/bundle/release/app-release.aab "dist/LibreMail-${VERSION}${suffix}.aab"
# R8 mapping for de-obfuscating crash reports (isMinifyEnabled = true).
cp app/build/outputs/mapping/release/mapping.txt "dist/LibreMail-${VERSION}-mapping.txt"
# Source archives with only git-tracked files at the released commit (GPL §6
# convenience: source travels alongside every distributed binary).
git archive --format=zip --prefix="LibreMail-${VERSION}/" -o "dist/LibreMail-${VERSION}-src.zip" HEAD
git archive --format=tar --prefix="LibreMail-${VERSION}/" HEAD | gzip > "dist/LibreMail-${VERSION}-src.tar.gz"
(cd dist && sha256sum LibreMail-* > SHA256SUMS.txt)
ls -l dist
cat dist/SHA256SUMS.txt
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-dist
path: dist/
if-no-files-found: error
github-release:
name: Create GitHub release
needs: [build]
if: needs.build.outputs.publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist
path: dist
- name: Create or update the release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ inputs.tag }}
name: ${{ inputs.tag }}
target_commitish: ${{ github.sha }}
prerelease: ${{ inputs.prerelease }}
tag_name: ${{ needs.build.outputs.release_tag }}
name: ${{ needs.build.outputs.release_tag }}
body_path: dist/CHANGELOG.md
prerelease: ${{ needs.build.outputs.prerelease == 'true' }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/LibreMail-${{ inputs.tag }}.apk
dist/LibreMail-${{ inputs.tag }}-src.zip
dist/LibreMail-${{ inputs.tag }}-src.tar.gz
dist/LibreMail-*
dist/SHA256SUMS.txt
google-play:
name: Publish to Google Play
needs: [build]
# Requires publishable (release-signed) artifacts AND Play credentials; the build job's
# plan step logs a notice/warning explaining any skip.
if: needs.build.outputs.publish == 'true' && needs.build.outputs.signed == 'true' && needs.build.outputs.have_play == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist
path: dist
- name: Determine track and staged-rollout mode
id: mode
env:
TRACK: ${{ inputs.play_track || 'internal' }}
FRACTION: ${{ inputs.play_rollout_fraction || '0.10' }}
run: |
set -euo pipefail
status="completed"
fraction=""
if [ "$TRACK" = "production" ]; then
case "$FRACTION" in
0 | 0.0 | 0.00)
echo "::error::play_rollout_fraction must be greater than 0 (got '$FRACTION')."
exit 1
;;
1 | 1.0 | 1.00)
status="completed" # full rollout
;;
0.[0-9]*)
status="inProgress" # staged rollout
fraction="$FRACTION"
;;
*)
echo "::error::play_rollout_fraction must be a fraction like 0.10 (0 < f < 1) or 1.0 for a full rollout (got '$FRACTION')."
exit 1
;;
esac
fi
echo "Publishing to track '$TRACK' with status '$status'${fraction:+ (user fraction $fraction)}."
{
echo "track=$TRACK"
echo "status=$status"
echo "fraction=$fraction"
} >> "$GITHUB_OUTPUT"
- name: Upload to Google Play
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: org.libremail.app
releaseFiles: dist/LibreMail-*.aab
track: ${{ steps.mode.outputs.track }}
status: ${{ steps.mode.outputs.status }}
userFraction: ${{ steps.mode.outputs.fraction }}
whatsNewDirectory: dist/whatsnew
mappingFile: dist/LibreMail-${{ needs.build.outputs.version }}-mapping.txt
# Samsung provides no maintained GitHub Action, and its Content Publish API is mid-
# migration (contentUpdate's binaryList parameter stops being accepted in July 2026), so
# automated submission is deliberately stubbed until #17 lands store credentials and the
# API settles. This job documents the state and the manual path; docs/release.md#galaxy-store
# has the full instructions. The GALAXY_* secret names are reserved for the future wiring.
galaxy-store:
name: Publish to Galaxy Store (manual for now)
needs: [build]
if: needs.build.outputs.publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Explain the Galaxy Store publication path
env:
HAVE_GALAXY: ${{ needs.build.outputs.have_galaxy }}
RELEASE_TAG: ${{ needs.build.outputs.release_tag }}
run: |
set -euo pipefail
if [ "$HAVE_GALAXY" = "true" ]; then
echo "::notice::GALAXY_* secrets are configured, but automated Galaxy Store submission is intentionally disabled: Samsung ships no maintained GitHub Action and its Content Publish API is mid-migration (binaryList removal, July 2026). Publish manually for now — see docs/release.md#galaxy-store."
else
echo "::notice::Galaxy Store credentials (GALAXY_SERVICE_ACCOUNT_ID / GALAXY_PRIVATE_KEY / GALAXY_CONTENT_ID) are not configured — publish manually. See docs/release.md#galaxy-store."
fi
cat <<EOF
Manual Galaxy Store publication for $RELEASE_TAG:
1. Download LibreMail-$RELEASE_TAG.apk (and SHA256SUMS.txt) from the GitHub release.
2. Verify the checksum: sha256sum -c SHA256SUMS.txt
3. Sign in to Samsung Seller Portal (https://seller.samsungapps.com), open the
LibreMail app entry, upload the APK as a new binary, update the release notes
from CHANGELOG.md and submit for review.
EOF
s3-archive:
name: Archive binaries to S3
needs: [build]
if: needs.build.outputs.publish == 'true' && needs.build.outputs.have_s3 == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist
path: dist
# Uses the AWS CLI preinstalled on ubuntu runners; --endpoint-url makes it work with
# any S3-compatible provider (MinIO, Backblaze B2, Cloudflare R2, …). Keys are
# versioned per release under releases/<tag>/, with SHA256SUMS.txt stored alongside.
- name: Upload artifacts and checksums
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ARCHIVE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARCHIVE_S3_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.ARCHIVE_S3_REGION || 'us-east-1' }}
S3_BUCKET: ${{ secrets.ARCHIVE_S3_BUCKET }}
S3_ENDPOINT: ${{ secrets.ARCHIVE_S3_ENDPOINT }}
RELEASE_TAG: ${{ needs.build.outputs.release_tag }}
run: |
set -euo pipefail
endpoint_args=()
[ -n "$S3_ENDPOINT" ] && endpoint_args+=(--endpoint-url "$S3_ENDPOINT")
dest="s3://${S3_BUCKET}/releases/${RELEASE_TAG}/"
aws s3 cp dist/ "$dest" --recursive --exclude "whatsnew/*" "${endpoint_args[@]}"
echo "Archived release artifacts to $dest:"
aws s3 ls "$dest" "${endpoint_args[@]}"
# Single aggregating result (mirrors ci.yml's ci-passed): fails if any stage failed, and
# writes a per-stage summary — including why gated stages were skipped — to the run page.
release-summary:
name: Release summary
needs: [fast-gate, build, github-release, google-play, galaxy-store, s3-archive]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Write summary
env:
R_GATE: ${{ needs.fast-gate.result }}
R_BUILD: ${{ needs.build.result }}
R_RELEASE: ${{ needs.github-release.result }}
R_PLAY: ${{ needs.google-play.result }}
R_GALAXY: ${{ needs.galaxy-store.result }}
R_S3: ${{ needs.s3-archive.result }}
O_TAG: ${{ needs.build.outputs.release_tag }}
O_PUBLISH: ${{ needs.build.outputs.publish }}
O_SIGNED: ${{ needs.build.outputs.signed }}
O_PLAY: ${{ needs.build.outputs.have_play }}
O_S3: ${{ needs.build.outputs.have_s3 }}
run: |
set -euo pipefail
note_release=""
if [ "$O_PUBLISH" != "true" ]; then note_release="dry run / rehearsal — nothing published"; fi
note_play=""
if [ "$O_PLAY" != "true" ]; then
note_play="needs PLAY_SERVICE_ACCOUNT_JSON"
elif [ "$O_SIGNED" != "true" ]; then
note_play="unsigned build — needs RELEASE_* signing secrets"
fi
note_s3=""
if [ "$O_S3" != "true" ]; then note_s3="needs ARCHIVE_S3_* secrets"; fi
{
echo "## Release pipeline — ${O_TAG:-rehearsal (no tag)}"
echo
echo "| Stage | Result | Notes |"
echo "| --- | --- | --- |"
echo "| Fast CI gate | $R_GATE | |"
echo "| Build + sign | $R_BUILD | signed: ${O_SIGNED:-n/a} |"
echo "| GitHub release | $R_RELEASE | $note_release |"
echo "| Google Play | $R_PLAY | $note_play |"
echo "| Galaxy Store | $R_GALAXY | manual for now — docs/release.md#galaxy-store |"
echo "| S3 archive | $R_S3 | $note_s3 |"
echo
echo "Secret setup: docs/release.md"
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail if any stage failed
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: |
echo "A release stage failed or was cancelled:"
echo " fast-gate: ${{ needs.fast-gate.result }}"
echo " build: ${{ needs.build.result }}"
echo " github-release: ${{ needs.github-release.result }}"
echo " google-play: ${{ needs.google-play.result }}"
echo " galaxy-store: ${{ needs.galaxy-store.result }}"
echo " s3-archive: ${{ needs.s3-archive.result }}"
exit 1
+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_*` |