From 0b0f6b7018a139bb09631e6a653c59a0d3ae7217 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 22:01:34 -0500 Subject: [PATCH 1/2] 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_*` | From c5c2da8e68fa743d922f7c893f1397fbea223346 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 22:04:35 -0500 Subject: [PATCH 2/2] test(db): add Room migration tests with MigrationTestHelper Closes the gap where app/schemas was exported but never validated (#63): - androidx.room:room-testing (androidTest) + ship the exported schemas as androidTest assets so MigrationTestHelper can build old-version databases. - MigrationTest: 11->12 asserts folders.specialUse arrives defaulting to 0 with existing rows intact; a chain-integrity test requires exactly one migration per version step up to the newest exported schema; a full v7->latest replay validates every step against its exported JSON and asserts seeded v7 data and each migration's backfills survive. The migration list is discovered from Migrations.kt and the target version from the exported schemas, so a future migration is covered by just committing its schema JSON. - Pin kotlinx-serialization to 1.8.1 via its BOM: androidx.savedstate pins 1.7.3 transitively (shared with androidTest by AGP 9 consistent resolution), and Room 2.8's schema-bundle serializers need >= 1.8.0 or MigrationTestHelper throws AbstractMethodError parsing the schema JSON. Identical to the pin already proven on the feat-fetch-all-retention branch, so the two merge cleanly in either order. - Refresh comments that described migration tests as future work. Co-Authored-By: Claude Fable 5 --- app/build.gradle.kts | 11 +- .../data/local/LibreMailDatabaseTest.kt | 6 +- .../org/libremail/data/local/MigrationTest.kt | 260 ++++++++++++++++++ gradle/libs.versions.toml | 10 + 4 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 36e4778..d252932 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -106,6 +106,9 @@ android { includeInBundle = false } + // Ship the exported Room schemas as androidTest assets so MigrationTestHelper can load them. + sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas") + packaging { resources { // Angus Mail / Jakarta Activation (added later) ship duplicate META-INF entries. @@ -147,7 +150,7 @@ android { } } -// Export Room schemas so migrations can be validated by instrumented MigrationTestHelper tests. +// Export Room schemas so the instrumented MigrationTest can replay and validate each migration. ksp { arg("room.schemaLocation", "$projectDir/schemas") } @@ -196,6 +199,11 @@ dependencies { ksp(libs.androidx.room.compiler) implementation(libs.sqlcipher.android) + // Raise kotlinx-serialization to the version Room's schema-bundle serializers were compiled + // against (see libs.versions.toml). AGP 9 consistent resolution shares it with the androidTest + // classpath so MigrationTestHelper can parse the exported schema JSON. + implementation(platform(libs.kotlinx.serialization.bom)) + testImplementation(libs.junit) testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) @@ -210,4 +218,5 @@ dependencies { androidTestImplementation(libs.androidx.espresso.intents) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.room.testing) } diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt index 61ce90d..a50c410 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt @@ -22,9 +22,9 @@ import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.ServerConfigEmbedded /** - * Schema-behavior tests on the real (v7) Room database. (Migrations from versions before - * exportSchema was enabled can't be replayed with MigrationTestHelper, since their schema JSONs - * were never exported; exportSchema is now on so future migrations can be tested.) + * Schema-behavior tests on a fresh in-memory database at the current version. The migration DDL + * itself is exercised by [MigrationTest], which replays the schema chain exported to app/schemas. + * (Migrations from before v7 predate schema export, so they can't be replayed there.) */ @RunWith(AndroidJUnit4::class) class LibreMailDatabaseTest { diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt new file mode 100644 index 0000000..076e821 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import androidx.room.migration.Migration +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Modifier + +/** + * Replays the app's Room migrations against the schema JSONs exported to `app/schemas` (shipped to + * the test APK as assets). DatabaseModule deliberately registers no destructive fallback — dropping + * tables would lose stored accounts and credentials — so a drifted migration crashes upgrading + * users at first database open; these tests make such drift fail in CI instead (issue #63). + * + * The migration list is discovered from Migrations.kt and the target version from the newest + * exported schema, so a future migration is covered automatically: author the `Migration`, register + * it in DatabaseModule, commit the new schema JSON, and [migratingFromV7ReplaysEveryMigrationAndPreservesData] + * replays it with no edit here. (Only v7+ can be replayed — older versions predate schema export.) + */ +@RunWith(AndroidJUnit4::class) +class MigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + LibreMailDatabase::class.java, + emptyList(), + FrameworkSQLiteOpenHelperFactory(), + ) + + /** v11 -> v12 (PR #54): `folders.specialUse` appears defaulting to 0 and existing data survives. */ + @Test + fun migrate11To12_defaultsExistingFoldersToNotSpecialUse() { + helper.createDatabase(TEST_DB, 11).apply { + insertAccount() + execSQL( + "INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) VALUES " + + "('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0), " + + "('acct', '[Gmail]/Sent Mail', 'Sent Mail', 'SENT', 1, 1)", + ) + execSQL( + "INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " + + "timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched) " + + "VALUES ('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " + + "'INBOX', 1, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 12, true, MIGRATION_11_12) + + db.query("SELECT fullName, specialUse FROM folders ORDER BY sortOrder").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("INBOX", c.getString(0)) + assertEquals(0, c.getInt(1)) + assertTrue(c.moveToNext()) + assertEquals("[Gmail]/Sent Mail", c.getString(0)) + assertEquals(0, c.getInt(1)) + assertFalse("both pre-upgrade folder rows must survive, and nothing else", c.moveToNext()) + } + // The folders' account and cached mail are untouched. + assertEquals(1, db.count("accounts")) + assertEquals(1, db.count("messages")) + db.close() + } + + /** + * A gap in the chain (or a migration whose target schema was never committed) crashes upgrading + * users, so fail fast with a readable message before replaying anything. + */ + @Test + fun migrationsFormOneUnbrokenChainUpToTheLatestExportedSchema() { + assertEquals( + "Migrations.kt must chain every version step up to the newest exported schema " + + "(DatabaseModule registers no destructive fallback, so a gap crashes upgrades)", + (1 until latestExportedSchemaVersion()).map { it to it + 1 }, + allAppMigrations.map { it.startVersion to it.endVersion }, + ) + } + + /** + * Creates a database at v7 (the oldest exported schema), fills it like a used install, then + * replays every migration one step at a time — `runMigrationsAndValidate` diffs the migrated + * schema against each version's exported JSON, so a failure names the exact step that drifted. + */ + @Test + fun migratingFromV7ReplaysEveryMigrationAndPreservesData() { + helper.createDatabase(TEST_DB, OLDEST_EXPORTED_SCHEMA).apply { + seedVersion7Cache() + close() + } + + var open: SupportSQLiteDatabase? = null + for (migration in allAppMigrations.filter { it.startVersion >= OLDEST_EXPORTED_SCHEMA }) { + open?.close() + val stepDb = helper.runMigrationsAndValidate(TEST_DB, migration.endVersion, true, migration) + stepDb.writeMidChainData() + open = stepDb + } + val db = checkNotNull(open) { "no migration starts at v$OLDEST_EXPORTED_SCHEMA" } + + assertEquals("the chain must end at the newest exported schema", latestExportedSchemaVersion(), db.version) + db.assertVersion7CacheSurvived() + db.assertMigrationBackfillsApplied() + db.close() + } + + /** The newest schema JSON exported to app/schemas (shipped to the test APK as assets). */ + private fun latestExportedSchemaVersion(): Int { + val schemaFolder = checkNotNull(LibreMailDatabase::class.java.canonicalName) + val versions = InstrumentationRegistry.getInstrumentation().context.assets.list(schemaFolder) + .orEmpty() + .mapNotNull { it.removeSuffix(".json").toIntOrNull() } + check(versions.isNotEmpty()) { "no exported schemas under androidTest assets/$schemaFolder" } + return versions.max() + } + + /** The accounts table has kept this shape since before v7, so every test can share one insert. */ + private fun SupportSQLiteDatabase.insertAccount() { + execSQL( + "INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " + + "smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " + + "'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')", + ) + } + + /** Fills a v7 database the way a used install would look (columns exactly as in 7.json). */ + private fun SupportSQLiteDatabase.seedVersion7Cache() { + insertAccount() + execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')") + execSQL( + "INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " + + "timestampMillis, isRead, isStarred, inInbox, bodyFetched) VALUES " + + "('acct:1', 'acct', 'Ada', 'ada@example.org', 'Analytical engines', 'Dear Charles', " + + "'Dear Charles, the mill works.', 0, 1000, 1, 0, 1, 1), " + + "('acct:2', 'acct', 'Charles', 'charles@example.org', 'Re: engines', '', '', 0, 2000, 0, 1, 1, 0)", + ) + execSQL( + "INSERT INTO attachments (messageId, partIndex, filename, mimeType, sizeBytes) " + + "VALUES ('acct:1', 0, 'notes.pdf', 'application/pdf', 2048)", + ) + execSQL( + "INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, subject, body, createdAt, " + + "lastError) VALUES ('out-1', 'acct', 'charles@example.org', '', 'Queued', 'Body', 3000, NULL)", + ) + execSQL( + "INSERT INTO drafts (id, accountId, toAddresses, ccAddresses, subject, body, updatedAt, " + + "attachments) VALUES ('draft-1', 'acct', 'charles@example.org', '', 'Draft', 'Text', 4000, '')", + ) + } + + /** Rows a user would write at intermediate versions; they must survive the rest of the chain. */ + private fun SupportSQLiteDatabase.writeMidChainData() { + when (version) { + // v8 is the first version with a folders table; 11->12 must stamp this row specialUse = 0. + 8 -> execSQL( + "INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) " + + "VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0)", + ) + // A signature saved by a v9 user: 10->11 must carry it into the new signatures table. + 9 -> execSQL( + "UPDATE account_settings SET signature = 'Cheers,' || char(10) || 'Ada' " + + "WHERE accountId = 'acct'", + ) + } + } + + /** Every row cached at v7 must still be present and correct at the end of the chain. */ + private fun SupportSQLiteDatabase.assertVersion7CacheSurvived() { + assertEquals(1, count("accounts")) + assertEquals(2, count("messages")) + assertEquals(1, count("outbox")) + assertEquals(1, count("drafts")) + query("SELECT folder, subject, isRead FROM messages WHERE id = 'acct:1'").use { c -> + assertTrue("message cached at v7 must survive", c.moveToFirst()) + assertEquals("7->8 files pre-upgrade messages under INBOX", "INBOX", c.getString(0)) + assertEquals("Analytical engines", c.getString(1)) + assertEquals(1, c.getInt(2)) + } + query("SELECT encryptedSecret FROM credentials WHERE accountId = 'acct'").use { c -> + assertTrue("stored credentials must never be dropped by a migration", c.moveToFirst()) + assertEquals("sealed-secret", c.getString(0)) + } + query("SELECT filename FROM attachments WHERE messageId = 'acct:1'").use { c -> + assertTrue("attachment rows must survive the 6->7 style table rebuilds", c.moveToFirst()) + assertEquals("notes.pdf", c.getString(0)) + } + } + + /** Columns and rows created by the migrations themselves must hold their documented defaults. */ + private fun SupportSQLiteDatabase.assertMigrationBackfillsApplied() { + // 8->9 backfills one default settings row per existing account. + query("SELECT signatureEnabled, notificationsEnabled FROM account_settings").use { c -> + assertTrue("8->9 must backfill a settings row for the v7 account", c.moveToFirst()) + assertEquals(1, c.getInt(0)) + assertEquals(1, c.getInt(1)) + } + // 9->10 adds bcc columns defaulting to ''; 10->11 adds nullable bodyHtml. + query("SELECT bccAddresses, bodyHtml FROM outbox WHERE id = 'out-1'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("", c.getString(0)) + assertTrue("bodyHtml must default to null (plaintext-only)", c.isNull(1)) + } + query("SELECT bccAddresses, bodyHtml FROM drafts WHERE id = 'draft-1'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("", c.getString(0)) + assertTrue(c.isNull(1)) + } + // 10->11 turns the signature written at v9 into that account's default rich-text signature. + query("SELECT name, contentHtml, isDefault FROM signatures WHERE accountId = 'acct'").use { c -> + assertTrue("10->11 must backfill the legacy per-account signature", c.moveToFirst()) + assertEquals("Signature", c.getString(0)) + assertEquals("Cheers,
Ada", c.getString(1)) + assertEquals(1, c.getInt(2)) + assertFalse("exactly one signature row must be backfilled", c.moveToNext()) + } + // 11->12 stamps the folder cached at v8 as not special-use. + query("SELECT specialUse FROM folders WHERE fullName = 'INBOX'").use { c -> + assertTrue("folder cached at v8 must survive to the newest version", c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + } + + private fun SupportSQLiteDatabase.count(table: String): Int = query("SELECT COUNT(*) FROM $table").use { c -> + c.moveToFirst() + c.getInt(0) + } + + private companion object { + const val TEST_DB = "migration-test.db" + + /** The oldest schema in app/schemas; earlier versions predate schema export. */ + const val OLDEST_EXPORTED_SCHEMA = 7 + + /** + * Every migration the app ships, pulled from Migrations.kt's file facade so a newly added + * migration is replayed automatically. DatabaseModule.provideDatabase registers this same + * set of top-level vals. + */ + val allAppMigrations: List = run { + val migrationsFile = checkNotNull(MIGRATION_1_2.javaClass.enclosingClass) { + "expected MIGRATION_1_2 to be a top-level val in Migrations.kt" + } + migrationsFile.methods + .filter { Modifier.isStatic(it.modifiers) && it.parameterCount == 0 } + .filter { Migration::class.java.isAssignableFrom(it.returnType) } + .map { it.invoke(null) as Migration } + .sortedBy { it.startVersion } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3b1b1e..9d72eef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,10 @@ sqlcipher = "4.16.0" datastore = "1.2.1" work = "2.11.2" coroutines = "1.10.2" +# Forced above androidx.savedstate's transitive 1.7.3 (shared with the androidTest classpath via +# AGP 9 consistent resolution); Room's schema-bundle serializers need >= 1.8.0 or MigrationTestHelper +# throws AbstractMethodError on GeneratedSerializer.typeParametersSerializers(). +kotlinxSerialization = "1.8.1" appauth = "0.11.1" angusMail = "2.0.5" junit = "4.13.2" @@ -63,6 +67,8 @@ androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", vers androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +# Room MigrationTestHelper (instrumented migration tests). +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } # SQLCipher — opt-in at-rest encryption of the Room cache. sqlcipher-android = { group = "net.zetetic", name = "sqlcipher-android", version.ref = "sqlcipher" } @@ -74,6 +80,10 @@ androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx" kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +# Serialization BOM — imported as a platform (not used directly) to raise the transitive +# kotlinx-serialization runtime to the version Room's schema bundles were compiled against. +kotlinx-serialization-bom = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-bom", version.ref = "kotlinxSerialization" } + # Email transport / OAuth — wired in later increments appauth = { group = "net.openid", name = "appauth", version.ref = "appauth" } angus-mail = { group = "org.eclipse.angus", name = "angus-mail", version.ref = "angusMail" }