ci(release): add tag-triggered signed-release and store-publish workflow #105
+492
-29
@@ -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
@@ -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_*` |
|
||||
Reference in New Issue
Block a user