Add signed Android release pipeline

This commit is contained in:
SimpleTest 2026-07-21 15:43:10 +03:00
parent 6e11bb35aa
commit e72aaf9b73
10 changed files with 427 additions and 11 deletions

View File

@ -55,6 +55,12 @@ WNH_BASE_URL=
# production capacity recommendations.
WNH_TRACKING_MIN_TIME_MS=5000
WNH_TRACKING_HTTP_TIMEOUT_MS=15000
WNH_ANDROID_VERSION_CODE=1
WNH_ANDROID_VERSION_NAME=0.1.0
# Public identifier of the locally held Google Play upload key. The private
# keystore and its randomized password live outside the repository under
# ~/.config/who_need_help/android-release/.
WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload
WNH_ANDROID_TEST_API_MATRIX="24 30 34 37.0"
WNH_ANDROID_TEST_DATA_PARTITION_SIZE=1G
# Public raster tile template used by MapLibre. Use a provider whose policy and

View File

@ -27,3 +27,6 @@ jobs:
env:
WNH_ENV_FILE: .env.example
run: ./scripts/android-build.sh
- name: Exercise signed APK and Play AAB release pipeline
run: ./scripts/android-release-ci.sh

View File

@ -58,3 +58,6 @@ jobs:
env:
WNH_ENV_FILE: .env.example
run: ./scripts/android-build.sh
- name: Exercise signed APK and Play AAB release pipeline
run: ./scripts/android-release-ci.sh

View File

@ -43,6 +43,8 @@ COPY --chown=gradle:gradle . .
ARG WNH_DEBUG_BASE_URL
ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS
ARG WNH_ANDROID_VERSION_CODE=1
ARG WNH_ANDROID_VERSION_NAME=0.1.0
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
@ -50,6 +52,8 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
"-PWNH_DEBUG_BASE_URL=${WNH_DEBUG_BASE_URL}" \
"-PWNH_TRACKING_MIN_TIME_MS=${WNH_TRACKING_MIN_TIME_MS}" \
"-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
"-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \
"-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \
testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest
FROM android-base AS emulator
@ -144,6 +148,8 @@ COPY --chown=gradle:gradle . .
ARG WNH_BASE_URL
ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS
ARG WNH_ANDROID_VERSION_CODE=1
ARG WNH_ANDROID_VERSION_NAME=0.1.0
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
@ -152,6 +158,8 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
"-PWNH_DEBUG_BASE_URL=${WNH_BASE_URL}" \
"-PWNH_TRACKING_MIN_TIME_MS=${WNH_TRACKING_MIN_TIME_MS}" \
"-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
"-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \
"-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \
"-PWNH_TEST_BUILD_TYPE=staging" \
testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest
@ -168,3 +176,82 @@ COPY --from=android-staging-sdk \
COPY --from=android-staging-sdk \
/workspace/android/app/build/reports/lint-results-staging.html \
/lint-results-staging.html
FROM android-base AS android-release-base
USER root
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ARG BUNDLETOOL_VERSION=1.18.3
ARG BUNDLETOOL_SHA256=a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29
RUN curl -fsSL --proto '=https' --tlsv1.2 \
"https://github.com/google/bundletool/releases/download/${BUNDLETOOL_VERSION}/bundletool-all-${BUNDLETOOL_VERSION}.jar" \
-o /opt/bundletool.jar \
&& echo "${BUNDLETOOL_SHA256} /opt/bundletool.jar" | sha256sum -c - \
&& chmod 444 /opt/bundletool.jar
USER gradle
FROM android-release-base AS android-release-sdk
USER gradle
WORKDIR /workspace/android
COPY --chown=gradle:gradle . .
ARG WNH_BASE_URL
ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS
ARG WNH_ANDROID_VERSION_CODE
ARG WNH_ANDROID_VERSION_NAME
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
--mount=type=secret,id=android_upload_keystore,required=true,uid=1000,gid=1000,mode=0400 \
--mount=type=secret,id=android_upload_password,required=true,uid=1000,gid=1000,mode=0400 \
--mount=type=secret,id=android_upload_alias,required=true,env=WNH_ANDROID_SIGNING_KEY_ALIAS \
WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_upload_keystore \
WNH_ANDROID_SIGNING_PASSWORD_FILE=/run/secrets/android_upload_password \
gradle --no-daemon \
"-PWNH_BASE_URL=${WNH_BASE_URL}" \
"-PWNH_TRACKING_MIN_TIME_MS=${WNH_TRACKING_MIN_TIME_MS}" \
"-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
"-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \
"-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \
testReleaseUnitTest lintRelease assembleRelease bundleRelease \
&& "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \
verify --verbose --print-certs \
app/build/outputs/apk/release/app-release.apk \
>app/build/outputs/apk/release/signing-certificate.txt \
&& LC_ALL=C jarsigner -verify -verbose -certs \
app/build/outputs/bundle/release/app-release.aab \
>app/build/outputs/bundle/release/signing-verification.txt \
&& grep -Fq "jar verified." \
app/build/outputs/bundle/release/signing-verification.txt \
&& java -jar /opt/bundletool.jar validate \
--bundle=app/build/outputs/bundle/release/app-release.aab \
>app/build/outputs/bundle/release/bundletool-validation.txt
FROM scratch AS release-artifact
USER 65532:65532
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/apk/release/app-release.apk \
/who-need-help-release.apk
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/app-release.aab \
/who-need-help-release.aab
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/apk/release/signing-certificate.txt \
/signing-certificate.txt
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/signing-verification.txt \
/bundle-signing-verification.txt
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/bundletool-validation.txt \
/bundletool-validation.txt
COPY --from=android-release-sdk \
/workspace/android/app/build/reports/lint-results-release.html \
/lint-results-release.html

View File

@ -34,17 +34,48 @@ The same ignored file supplies `WNH_TRACKING_MIN_TIME_MS` and
local client freshness and timeout behavior; these values are not claimed as
measured production capacity settings.
Release builds do not have a default server. Supply the real HTTPS deployment:
Release builds do not have a default server and cannot be produced unsigned.
The repository reads non-secret version/origin settings from the selected
ignored environment file, while the upload key and its randomized password
stay outside the repository. Create that key once:
```sh
./gradlew :app:assembleRelease \
-PWNH_BASE_URL=https://help.your-domain.example \
-PWNH_TRACKING_MIN_TIME_MS=5000 \
-PWNH_TRACKING_HTTP_TIMEOUT_MS=15000
./scripts/init-android-release-signing.sh
```
The build rejects a missing, HTTP, credentialed, query-bearing, or
fragment-bearing release URL.
Back up both reported files before uploading the first bundle. Losing this
dedicated upload key is recoverable through Play App Signing, but keeping an
offline backup avoids a reset. Do not copy either file into the repository.
Configure these non-secret values in the deployment's ignored environment
file, then produce the signed APK and Play bundle:
```dotenv
WNH_BASE_URL=https://your-final-origin.example
WNH_ANDROID_VERSION_CODE=1
WNH_ANDROID_VERSION_NAME=0.1.0
WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload
```
```sh
WNH_ENV_FILE=.env.production ./scripts/android-release-build.sh
```
The build passes the private files with Docker BuildKit secret mounts, runs
release unit tests and lint, enables code/resource shrinking, signs both
artifacts, verifies the APK and AAB signatures, and validates the AAB with the
same `bundletool` used underneath Android Gradle Plugin and Google Play before
exporting anything.
It rejects a missing, HTTP, credentialed, query-bearing, or fragment-bearing
release URL and rejects missing or partial signing configuration.
Google Play requires an Android App Bundle for a new app. With Play App
Signing, the locally held key signs the uploaded bundle and Google holds the
separate app-signing key used for distributed APKs:
- <https://developer.android.com/studio/publish/app-signing>
- <https://support.google.com/googleplay/android-developer/answer/9859152>
- <https://docs.docker.com/build/building/secrets/>
## Public staging build
@ -147,5 +178,7 @@ docker exec who-need-help-android-emulator adb install \
```
The application ID `org.whoneedhelp.mobile` is provisional until the publishing
identity and store listing are chosen. Changing it after publication creates a
different Android application.
identity and store listing are chosen. Google documents Play package names as
unique and permanent, so do not create the Play Console app or publish this
identifier until that choice is explicit. Changing it after publication creates
a different Android application.

View File

@ -1,4 +1,6 @@
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import org.gradle.api.tasks.compile.JavaCompile
plugins {
@ -12,6 +14,43 @@ val trackingHttpTimeoutMs =
providers.gradleProperty("WNH_TRACKING_HTTP_TIMEOUT_MS").orElse("0")
val instrumentationBuildType =
providers.gradleProperty("WNH_TEST_BUILD_TYPE").orElse("debug")
val androidVersionCode = providers.gradleProperty("WNH_ANDROID_VERSION_CODE").orElse("1")
val androidVersionName = providers.gradleProperty("WNH_ANDROID_VERSION_NAME").orElse("0.1.0")
val releaseSigningStoreFile =
providers.environmentVariable("WNH_ANDROID_SIGNING_STORE_FILE").orNull
val releaseSigningPasswordFile =
providers.environmentVariable("WNH_ANDROID_SIGNING_PASSWORD_FILE").orNull
val releaseSigningKeyAlias =
providers.environmentVariable("WNH_ANDROID_SIGNING_KEY_ALIAS").orNull
fun nonBlank(value: String?): String? = value?.trim()?.takeIf(String::isNotEmpty)
val releaseSigningInputs =
listOf(
nonBlank(releaseSigningStoreFile),
nonBlank(releaseSigningPasswordFile),
nonBlank(releaseSigningKeyAlias)
)
val releaseSigningConfigured = releaseSigningInputs.all { it != null }
val releaseSigningPartiallyConfigured = releaseSigningInputs.any { it != null }
fun readSigningPassword(): String {
val passwordPath =
nonBlank(releaseSigningPasswordFile)
?: throw GradleException("Android release signing password file is not configured")
val path = Path.of(passwordPath)
if (!Files.isRegularFile(path)) {
throw GradleException("Android release signing password file does not exist")
}
val password = Files.readString(path).trimEnd('\r', '\n')
if (password.isBlank() || password.contains('\n') || password.contains('\r')) {
throw GradleException("Android release signing password file is invalid")
}
return password
}
fun manifestOrigin(value: String): URI? =
runCatching { URI(value) }
@ -36,8 +75,12 @@ android {
applicationId = "org.whoneedhelp.mobile"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
versionCode =
androidVersionCode.get().toIntOrNull()?.takeIf { it > 0 }
?: throw GradleException("WNH_ANDROID_VERSION_CODE must be a positive integer")
versionName =
androidVersionName.get().trim().takeIf(String::isNotEmpty)
?: throw GradleException("WNH_ANDROID_VERSION_NAME must not be empty")
buildConfigField("long", "TRACKING_MIN_TIME_MS", "${trackingMinTimeMs.get()}L")
buildConfigField(
"long",
@ -48,6 +91,22 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
if (releaseSigningConfigured) {
create("release") {
val storePath = Path.of(nonBlank(releaseSigningStoreFile)!!)
if (!Files.isRegularFile(storePath)) {
throw GradleException("Android release signing keystore does not exist")
}
storeFile = storePath.toFile()
storePassword = readSigningPassword()
keyAlias = nonBlank(releaseSigningKeyAlias)
keyPassword = storePassword
}
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
@ -82,6 +141,9 @@ android {
release {
isMinifyEnabled = true
isShrinkResources = true
if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
}
buildConfigField(
"String",
"BASE_URL",
@ -114,6 +176,20 @@ android {
tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach {
doFirst {
if (name == "preReleaseBuild" && !releaseSigningConfigured) {
val detail =
if (releaseSigningPartiallyConfigured) {
"Release signing is only partially configured"
} else {
"Release signing is not configured"
}
throw GradleException(
"$detail; set WNH_ANDROID_SIGNING_STORE_FILE, "
+ "WNH_ANDROID_SIGNING_PASSWORD_FILE, and "
+ "WNH_ANDROID_SIGNING_KEY_ALIAS"
)
}
val value = releaseBaseUrl.orNull.orEmpty()
val uri = runCatching { URI(value) }.getOrNull()

View File

@ -2,3 +2,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
# AGP 9 enables unit-test components only for testBuildType by default. Release
# is independently minified and signed, so keep its JVM test task available.
android.onlyEnableUnitTestForTheTestedBuildType=false

View File

@ -0,0 +1,85 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE=${WNH_ENV_FILE:-"$ROOT/.env"}
config_home=${XDG_CONFIG_HOME:-"$HOME/.config"}
SIGNING_DIR=${WNH_ANDROID_SIGNING_DIR:-"$config_home/who_need_help/android-release"}
KEYSTORE="$SIGNING_DIR/who-need-help-upload.p12"
PASSWORD_FILE="$SIGNING_DIR/who-need-help-upload.password"
OUTPUT_DIR=${WNH_ANDROID_RELEASE_OUTPUT_DIR:-"$ROOT/android/dist-release"}
case "$ENV_FILE" in
/*) ;;
*) ENV_FILE="$ROOT/$ENV_FILE" ;;
esac
if [ ! -f "$ENV_FILE" ]; then
echo "Missing Android release environment file: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck source=/dev/null
. "$ENV_FILE"
set +a
: "${WNH_BASE_URL:?Set WNH_BASE_URL in the selected environment file}"
: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in the selected environment file}"
: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in the selected environment file}"
: "${WNH_ANDROID_VERSION_CODE:?Set WNH_ANDROID_VERSION_CODE in the selected environment file}"
: "${WNH_ANDROID_VERSION_NAME:?Set WNH_ANDROID_VERSION_NAME in the selected environment file}"
: "${WNH_ANDROID_SIGNING_KEY_ALIAS:?Set WNH_ANDROID_SIGNING_KEY_ALIAS in the selected environment file}"
for secret_file in "$KEYSTORE" "$PASSWORD_FILE"; do
if [ ! -f "$secret_file" ]; then
echo "Missing Android release signing file: $secret_file" >&2
echo "Run scripts/init-android-release-signing.sh once." >&2
exit 1
fi
mode=$(stat -c '%a' "$secret_file")
case "$mode" in
400|600) ;;
*)
echo "Android release signing file must have mode 0400 or 0600: $secret_file" >&2
exit 1
;;
esac
done
case "$OUTPUT_DIR" in
/*) ;;
*) OUTPUT_DIR="$ROOT/$OUTPUT_DIR" ;;
esac
docker build \
--secret "id=android_upload_keystore,src=$KEYSTORE" \
--secret "id=android_upload_password,src=$PASSWORD_FILE" \
--secret "id=android_upload_alias,env=WNH_ANDROID_SIGNING_KEY_ALIAS" \
--build-arg "WNH_BASE_URL=$WNH_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \
--build-arg "WNH_ANDROID_VERSION_CODE=$WNH_ANDROID_VERSION_CODE" \
--build-arg "WNH_ANDROID_VERSION_NAME=$WNH_ANDROID_VERSION_NAME" \
--target release-artifact \
--output "type=local,dest=$OUTPUT_DIR" \
"$ROOT/android"
for artifact in \
"$OUTPUT_DIR/who-need-help-release.apk" \
"$OUTPUT_DIR/who-need-help-release.aab" \
"$OUTPUT_DIR/signing-certificate.txt" \
"$OUTPUT_DIR/bundle-signing-verification.txt" \
"$OUTPUT_DIR/bundletool-validation.txt" \
"$OUTPUT_DIR/lint-results-release.html"; do
if [ ! -s "$artifact" ]; then
echo "Android release build did not export the expected artifact: $artifact" >&2
exit 1
fi
done
sha256sum \
"$OUTPUT_DIR/who-need-help-release.apk" \
"$OUTPUT_DIR/who-need-help-release.aab"
echo "Signed Android APK, Play AAB, certificate report, and lint report: $OUTPUT_DIR"

36
scripts/android-release-ci.sh Executable file
View File

@ -0,0 +1,36 @@
#!/bin/sh
set -eu
umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
workspace=$(mktemp -d "${TMPDIR:-/tmp}/wnh-android-release-ci.XXXXXX")
signing_dir="$workspace/signing"
environment_file="$workspace/release.env"
output_dir="$workspace/output"
cleanup() {
rm -rf "$workspace"
}
trap cleanup EXIT HUP INT TERM
printf '%s\n' \
'WNH_BASE_URL=https://android-release-ci.invalid' \
'WNH_TRACKING_MIN_TIME_MS=5000' \
'WNH_TRACKING_HTTP_TIMEOUT_MS=15000' \
'WNH_ANDROID_VERSION_CODE=1' \
'WNH_ANDROID_VERSION_NAME=0.1.0-ci' \
'WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-ci-upload' \
>"$environment_file"
chmod 600 "$environment_file"
WNH_ANDROID_SIGNING_DIR="$signing_dir" \
WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-ci-upload \
"$ROOT/scripts/init-android-release-signing.sh" >/dev/null
WNH_ANDROID_SIGNING_DIR="$signing_dir" \
WNH_ANDROID_RELEASE_OUTPUT_DIR="$output_dir" \
WNH_ENV_FILE="$environment_file" \
"$ROOT/scripts/android-release-build.sh"
echo "Ephemeral signed Android release pipeline passed."

View File

@ -0,0 +1,84 @@
#!/bin/sh
set -eu
umask 077
config_home=${XDG_CONFIG_HOME:-"$HOME/.config"}
SIGNING_DIR=${WNH_ANDROID_SIGNING_DIR:-"$config_home/who_need_help/android-release"}
KEYSTORE="$SIGNING_DIR/who-need-help-upload.p12"
PASSWORD_FILE="$SIGNING_DIR/who-need-help-upload.password"
KEY_ALIAS=${WNH_ANDROID_SIGNING_KEY_ALIAS:-who-need-help-upload}
KEY_IMAGE="gradle:9.6.1-jdk17@sha256:7364ce528f33bb6038672bcef990d524f1ad8fbc292935819c235db886d0fae7"
run_id="$$-$(openssl rand -hex 4)"
temporary_keystore="$SIGNING_DIR/.who-need-help-upload.$run_id.p12"
temporary_password="$SIGNING_DIR/.who-need-help-upload.$run_id.password"
cleanup() {
if [ -e "$temporary_keystore" ]; then
unlink "$temporary_keystore"
fi
if [ -e "$temporary_password" ]; then
unlink "$temporary_password"
fi
}
trap cleanup EXIT HUP INT TERM
case "$KEY_ALIAS" in
''|*[!A-Za-z0-9._-]*)
echo "WNH_ANDROID_SIGNING_KEY_ALIAS must use only letters, digits, dot, underscore, and dash." >&2
exit 1
;;
esac
if [ -L "$SIGNING_DIR" ]; then
echo "Refusing to use a symlink as the Android signing directory: $SIGNING_DIR" >&2
exit 1
fi
install -d -m 700 "$SIGNING_DIR"
if [ -e "$KEYSTORE" ] || [ -e "$PASSWORD_FILE" ]; then
echo "Android upload signing material already exists; nothing was overwritten:" >&2
echo " $KEYSTORE" >&2
echo " $PASSWORD_FILE" >&2
exit 1
fi
openssl rand -hex 32 >"$temporary_password"
chmod 600 "$temporary_password"
docker run --rm \
--user "$(id -u):$(id -g)" \
--mount "type=bind,src=$SIGNING_DIR,dst=/secure" \
--entrypoint keytool \
"$KEY_IMAGE" \
-genkeypair \
-keystore "/secure/$(basename "$temporary_keystore")" \
-storetype PKCS12 \
-storepass:file "/secure/$(basename "$temporary_password")" \
-keypass:file "/secure/$(basename "$temporary_password")" \
-alias "$KEY_ALIAS" \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-dname "CN=Who Need Help upload key"
docker run --rm \
--user "$(id -u):$(id -g)" \
--mount "type=bind,src=$SIGNING_DIR,dst=/secure,readonly" \
--entrypoint keytool \
"$KEY_IMAGE" \
-list \
-keystore "/secure/$(basename "$temporary_keystore")" \
-storetype PKCS12 \
-storepass:file "/secure/$(basename "$temporary_password")" \
-alias "$KEY_ALIAS" >/dev/null
chmod 600 "$temporary_keystore"
mv "$temporary_keystore" "$KEYSTORE"
mv "$temporary_password" "$PASSWORD_FILE"
echo "Generated a dedicated Android upload key without placing secrets in the repository."
echo "Private keystore: $KEYSTORE"
echo "Password file: $PASSWORD_FILE"
echo "Back up both files before the first Play Console upload."