diff --git a/.env.example b/.env.example index dc4d0fb..5b8d91a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index 3f6d4a1..e0f36c7 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -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 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d0c0896..96324c1 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -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 diff --git a/android/Dockerfile b/android/Dockerfile index c9de20d..d4fe494 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -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 diff --git a/android/README.md b/android/README.md index 107b01b..139c626 100644 --- a/android/README.md +++ b/android/README.md @@ -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: + +- +- +- ## 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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b51c733..2046dc1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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() diff --git a/android/gradle.properties b/android/gradle.properties index 3cdd395..f17192e 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -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 diff --git a/scripts/android-release-build.sh b/scripts/android-release-build.sh new file mode 100755 index 0000000..431ec1c --- /dev/null +++ b/scripts/android-release-build.sh @@ -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" diff --git a/scripts/android-release-ci.sh b/scripts/android-release-ci.sh new file mode 100755 index 0000000..a44133f --- /dev/null +++ b/scripts/android-release-ci.sh @@ -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." diff --git a/scripts/init-android-release-signing.sh b/scripts/init-android-release-signing.sh new file mode 100755 index 0000000..3d70cc8 --- /dev/null +++ b/scripts/init-android-release-signing.sh @@ -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."