Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .github/workflows/playstore-release.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
22 changes: 22 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading