diff --git a/.github/workflows/playstore-release.yaml b/.github/workflows/playstore-release.yaml new file mode 100644 index 00000000..5e07771c --- /dev/null +++ b/.github/workflows/playstore-release.yaml @@ -0,0 +1,132 @@ +name: Release App to Google PlayStore + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag to build and publish (blank = latest published release)' + required: false + type: string + release: + types: [ published ] + +permissions: + contents: read + +concurrency: + group: playstore-deployment + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + # Both triggers must build from a tag, never a branch head. The git-semver plugin + # derives the version from tags, and checking the tag out is what makes it visible + # at the default fetch depth: a branch checkout fetches no tags at all and silently + # falls back to `minVersion` in app/build.gradle.kts, producing a versionCode Play + # rejects as a downgrade. + - name: Resolve tag to build + id: tag + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + EVENT: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + if [ "$EVENT" = "release" ]; then + tag="$RELEASE_TAG" + elif [ -n "$INPUT_TAG" ]; then + tag="$INPUT_TAG" + else + tag="$(gh api "repos/$REPO/releases" --jq '[.[] | select(.draft == false)][0].tag_name')" + fi + if [ -z "$tag" ] || [ "$tag" = "null" ]; then + echo "::error::Could not resolve a tag to build" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "Building $tag" + + - uses: actions/checkout@v7 + with: + ref: ${{ steps.tag.outputs.tag }} + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + + - uses: gradle/actions/setup-gradle@v6 + + # Play caps "What's new" at 500 characters per locale and rejects the edit above that. + # Left unset when the tag has no release body, so we never upload a blank whatsnew. + - name: Create Release Notes + id: notes + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + # `gh api --jq` prints the raw error JSON on a 404 instead of applying the + # filter, so branch on exit status rather than piping the output through. + if ! body="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.body // ""' 2>/dev/null)"; then + body="" + echo "No GitHub release found for $TAG" + fi + if [ -n "$body" ]; then + mkdir -p play_store/whatsnew + printf '%s' "$body" \ + | python3 -c "import sys, pathlib; pathlib.Path('play_store/whatsnew/whatsnew-en-US').write_text(sys.stdin.read()[:500])" + echo "dir=play_store/whatsnew" >> "$GITHUB_OUTPUT" + else + echo "No release body for $TAG; skipping whatsnew" + fi + + - name: Verify resolved version + run: ./gradlew :app:printVersion -Prust.compile=false + + - name: Decode Keystore + id: keystore + env: + ENCODED_KEYSTORE: ${{ secrets.SIGNING_KEY_BASE64 }} + run: | + umask 077 + keystore="$RUNNER_TEMP/keystore.jks" + printf '%s' "$ENCODED_KEYSTORE" | base64 --decode > "$keystore" + # An unset secret decodes to an empty file with status 0, which would surface + # much later as an opaque keystore error from Gradle. + if [ ! -s "$keystore" ]; then + echo "::error::SIGNING_KEY_BASE64 is missing or decoded to an empty keystore" + exit 1 + fi + echo "path=$keystore" >> "$GITHUB_OUTPUT" + + # Signing credentials go through the environment rather than -P properties, which + # would sit in the Gradle process argv for the whole build. app/build.gradle.kts + # builds the release signingConfig from these four variables. + - name: Build and Sign Release AAB + env: + KEYSTORE_FILE: ${{ steps.keystore.outputs.path }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: ./gradlew bundlePlayStoreRelease + + - name: Clean up Keystore + if: always() + run: rm -f "${{ steps.keystore.outputs.path }}" + + - name: Upload to Google Play + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: de.davis.passwordmanager + releaseFiles: app/build/outputs/bundle/playStoreRelease/*.aab + tracks: internal + whatsNewDirectory: ${{ steps.notes.outputs.dir }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72cec353..10a8b665 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,6 +30,20 @@ android { keyAlias = "debug" keyPassword = "android" } + + // Release credentials come from the environment, never from -P properties: + // project properties end up in the Gradle process argv, which stays readable + // by every process on the build machine for the whole build. Without the env + // vars no release config is created and release stays unsigned, so local and + // F-Droid builds behave exactly as before. + System.getenv("KEYSTORE_FILE")?.takeIf { it.isNotBlank() }?.let { keystore -> + create("release") { + storeFile = file(keystore) + storePassword = System.getenv("KEYSTORE_PASSWORD") + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + } + } } defaultConfig { @@ -42,12 +56,20 @@ android { buildTypes { release { + signingConfig = signingConfigs.findByName("release") + isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + + // Packages symbols for the :rust cdylibs into the AAB so Play can + // symbolicate native crashes. Stripped before delivery to devices. + ndk { + debugSymbolLevel = "SYMBOL_TABLE" + } } debug {