From 0b0f6b7018a139bb09631e6a653c59a0d3ae7217 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 22:01:34 -0500 Subject: [PATCH] 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//. 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 --- .github/workflows/release.yml | 521 ++++++++++++++++++++++++++++++++-- docs/release.md | 198 +++++++++++++ 2 files changed, 690 insertions(+), 29 deletions(-) create mode 100644 docs/release.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4248315..22eef40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 </, 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 diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..5a34e24 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,198 @@ + + +# 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--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-.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:///releases// + LibreMail-.aab + LibreMail-.apk + LibreMail--mapping.txt + LibreMail--src.zip ← source at the released commit (GPL §6 convenience) + LibreMail--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:///releases// --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_*` |