From 6c14531da301e08e8f8b38d047347ff4e34c6b78 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Mon, 27 Jul 2026 02:35:27 +0300 Subject: [PATCH] Prepare pre-Play production application release --- android/Dockerfile | 6 ++- docs/operations.md | 18 ++++++- scripts/android-development-build.sh | 17 +++++++ scripts/android-release-build.sh | 17 +++++++ scripts/android-staging-build.sh | 17 +++++++ scripts/check-environment-readiness.sh | 16 ++++-- scripts/production-release-remote.sh | 11 ++-- scripts/production-release.sh | 2 +- scripts/production-rollback-remote.sh | 3 +- scripts/quality.sh | 16 ++++++ scripts/set-deployment-revision.sh | 18 ++++++- scripts/validate-android-environment.sh | 26 +++++++++- scripts/validate-production-env.sh | 67 +++++++++++++++---------- 13 files changed, 191 insertions(+), 43 deletions(-) diff --git a/android/Dockerfile b/android/Dockerfile index 7e8790f..c08ea38 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -165,6 +165,7 @@ ARG WNH_FIREBASE_PROJECT_ID ARG WNH_FIREBASE_GCM_SENDER_ID ARG WNH_PUBLIC_BUILD_TYPE ARG WNH_EXPECTED_APPLICATION_ID +ARG WNH_SIGNING_CERT_SHA256 RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ --mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \ @@ -177,6 +178,7 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ *) echo "WNH_PUBLIC_BUILD_TYPE must be development or staging" >&2; exit 1 ;; \ esac \ && test -n "${WNH_EXPECTED_APPLICATION_ID}" \ + && test -n "${WNH_SIGNING_CERT_SHA256}" \ && WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_nonproduction_keystore \ WNH_ANDROID_SIGNING_PASSWORD_FILE=/run/secrets/android_nonproduction_password \ gradle --no-daemon \ @@ -256,13 +258,15 @@ ARG WNH_FIREBASE_APPLICATION_ID ARG WNH_FIREBASE_CLIENT_VALUE ARG WNH_FIREBASE_PROJECT_ID ARG WNH_FIREBASE_GCM_SENDER_ID +ARG WNH_SIGNING_CERT_SHA256 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 \ + test -n "${WNH_SIGNING_CERT_SHA256}" \ + && 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}" \ diff --git a/docs/operations.md b/docs/operations.md index e8e58e5..e2f2ecc 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -174,6 +174,16 @@ ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=PLAY_APP_SIGNING_SHA256 Run `./scripts/android-release-build.sh` from that production release checkout. It produces an APK, Play AAB, package report, signing report, and lint report. +Before the Play application exists, the build may use only the upload +certificate in `ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS` and leave +`ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS` empty. This is sufficient +to create the first signed AAB, but `check-environment-readiness.sh +--require-release` intentionally continues to report the Play identity as +missing. Application-only server releases use +`--require-server-release` plus the production validator's +`--allow-pre-play` mode: they accept this upload-certificate-only state while +continuing to reject every other missing production capability. These modes do +not make an Android build ready for Google Play. After Play App Signing is enabled, add the Play signing certificate fingerprint to the comma-separated App Links value; the upload certificate alone does not describe Play-delivered APKs. Record the same Play fingerprint separately in @@ -942,8 +952,12 @@ verified Git bundle and transferred directly over SSH to only The default `plan` action is read-only. It verifies the exact local and remote commits, requires a fast-forward history, checks the production checkout, Compose scope and healthy containers, checks public readiness, opens a -read-only PostgreSQL connection, and runs the complete environment capability -preflight. It neither uploads a bundle nor creates a backup. +read-only PostgreSQL connection, and runs the server-release environment +capability preflight. Before the first Google Play release, this preflight +allows only the absent Play App Signing certificate; the stricter +`check-environment-readiness.sh .env --require-release` remains the gate for +publishing Android through Google Play. The plan neither uploads a bundle nor +creates a backup. After reviewing the exact commit printed by the plan, execution additionally requires an explicit per-commit confirmation: diff --git a/scripts/android-development-build.sh b/scripts/android-development-build.sh index d257d70..5b64aa1 100755 --- a/scripts/android-development-build.sh +++ b/scripts/android-development-build.sh @@ -39,10 +39,27 @@ for secret_file in "$KEYSTORE" "$PASSWORD_FILE"; do esac done +SIGNING_CERT_SHA256=$( + keytool -list -v \ + -storetype PKCS12 \ + -keystore "$KEYSTORE" \ + -storepass:file "$PASSWORD_FILE" \ + -alias "$WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS" | + awk -F': ' ' + /SHA256:/ { + print $2 + found = 1 + exit + } + END { if (!found) exit 1 } + ' +) + docker build \ --secret "id=android_nonproduction_keystore,src=$KEYSTORE" \ --secret "id=android_nonproduction_password,src=$PASSWORD_FILE" \ --secret "id=android_nonproduction_alias,env=WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS" \ + --build-arg "WNH_SIGNING_CERT_SHA256=$SIGNING_CERT_SHA256" \ --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" \ diff --git a/scripts/android-release-build.sh b/scripts/android-release-build.sh index 5ec9dff..e2f0dd6 100755 --- a/scripts/android-release-build.sh +++ b/scripts/android-release-build.sh @@ -50,6 +50,22 @@ for secret_file in "$KEYSTORE" "$PASSWORD_FILE"; do esac done +SIGNING_CERT_SHA256=$( + keytool -list -v \ + -storetype PKCS12 \ + -keystore "$KEYSTORE" \ + -storepass:file "$PASSWORD_FILE" \ + -alias "$WNH_ANDROID_SIGNING_KEY_ALIAS" | + awk -F': ' ' + /SHA256:/ { + print $2 + found = 1 + exit + } + END { if (!found) exit 1 } + ' +) + case "$OUTPUT_DIR" in /*) ;; *) OUTPUT_DIR="$ROOT/$OUTPUT_DIR" ;; @@ -59,6 +75,7 @@ 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_SIGNING_CERT_SHA256=$SIGNING_CERT_SHA256" \ --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" \ diff --git a/scripts/android-staging-build.sh b/scripts/android-staging-build.sh index 4001e56..16e1ba0 100755 --- a/scripts/android-staging-build.sh +++ b/scripts/android-staging-build.sh @@ -40,10 +40,27 @@ for secret_file in "$KEYSTORE" "$PASSWORD_FILE"; do esac done +SIGNING_CERT_SHA256=$( + keytool -list -v \ + -storetype PKCS12 \ + -keystore "$KEYSTORE" \ + -storepass:file "$PASSWORD_FILE" \ + -alias "$WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS" | + awk -F': ' ' + /SHA256:/ { + print $2 + found = 1 + exit + } + END { if (!found) exit 1 } + ' +) + docker build \ --secret "id=android_nonproduction_keystore,src=$KEYSTORE" \ --secret "id=android_nonproduction_password,src=$PASSWORD_FILE" \ --secret "id=android_nonproduction_alias,env=WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS" \ + --build-arg "WNH_SIGNING_CERT_SHA256=$SIGNING_CERT_SHA256" \ --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" \ diff --git a/scripts/check-environment-readiness.sh b/scripts/check-environment-readiness.sh index d2d5324..8a8095a 100755 --- a/scripts/check-environment-readiness.sh +++ b/scripts/check-environment-readiness.sh @@ -9,8 +9,9 @@ if [[ "$env_file" != /* ]]; then env_file="$ROOT/$env_file" fi -if [[ "$mode" != "" && "$mode" != "--require-release" ]]; then - echo "Usage: $0 [ENV_FILE] [--require-release]" >&2 +if [[ "$mode" != "" && "$mode" != "--require-release" && + "$mode" != "--require-server-release" ]]; then + echo "Usage: $0 [ENV_FILE] [--require-release|--require-server-release]" >&2 exit 2 fi @@ -301,7 +302,13 @@ elif all_set ANDROID_APP_LINKS_PACKAGE_NAME ANDROID_APP_LINKS_SHA256_CERT_FINGER elif [[ "$deployment_env" != production ]]; then ready "Android App Links" "package and signing fingerprints match this environment" elif ! is_set ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS; then - missing "Android App Links" "production requires the Play App Signing SHA-256 fingerprint" + if [[ "$mode" == "--require-server-release" ]]; then + ready "Android App Links" \ + "pre-Play server release publishes the verified upload certificate" + else + missing "Android App Links" \ + "production requires the Play App Signing SHA-256 fingerprint" + fi elif ! valid_sha256_fingerprint_list \ "$(value ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS)"; then invalid "Android App Links" "Play App Signing fingerprints are malformed" @@ -367,6 +374,7 @@ fi printf '\nEnvironment readiness: %d blocking item(s), %d local-only warning(s).\n' \ "$failures" "$warnings" -if [[ "$mode" == "--require-release" && ($failures -ne 0 || $warnings -ne 0) ]]; then +if [[ "$mode" =~ ^--require-(release|server-release)$ && + ($failures -ne 0 || $warnings -ne 0) ]]; then exit 1 fi diff --git a/scripts/production-release-remote.sh b/scripts/production-release-remote.sh index 8adb6b9..a245eff 100755 --- a/scripts/production-release-remote.sh +++ b/scripts/production-release-remote.sh @@ -79,7 +79,8 @@ current_commit=$(git -C "$root" rev-parse --verify HEAD) exit 2 } -"$root/scripts/validate-production-env.sh" "$env_file" "$expected_domain" >/dev/null +"$root/scripts/validate-production-env.sh" \ + "$env_file" "$expected_domain" --allow-pre-play >/dev/null "$root/scripts/compose.sh" "$env_file" config --quiet case "$app_topology" in @@ -295,10 +296,12 @@ else git -C "$root" checkout --detach "$release_ref" fi -"$root/scripts/set-deployment-revision.sh" "$env_file" +"$root/scripts/set-deployment-revision.sh" "$env_file" --allow-pre-play revision_changed=true -"$root/scripts/validate-production-env.sh" "$env_file" "$expected_domain" -"$root/scripts/check-environment-readiness.sh" "$env_file" --require-release +"$root/scripts/validate-production-env.sh" \ + "$env_file" "$expected_domain" --allow-pre-play +"$root/scripts/check-environment-readiness.sh" \ + "$env_file" --require-server-release "$root/scripts/compose.sh" "$env_file" build "${build_services[@]}" diff --git a/scripts/production-release.sh b/scripts/production-release.sh index caa41ba..1e09fc3 100755 --- a/scripts/production-release.sh +++ b/scripts/production-release.sh @@ -79,7 +79,7 @@ if ! ssh -o BatchMode=yes "$ssh_target" \ fi if ! ssh -o BatchMode=yes "$ssh_target" \ - "bash -s -- '$remote_root/.env' --require-release" \ + "bash -s -- '$remote_root/.env' --require-server-release" \ <"$ROOT/scripts/check-environment-readiness.sh"; then plan_failed=1 fi diff --git a/scripts/production-rollback-remote.sh b/scripts/production-rollback-remote.sh index bdfe4fc..75c50e6 100755 --- a/scripts/production-rollback-remote.sh +++ b/scripts/production-rollback-remote.sh @@ -109,7 +109,8 @@ public_origin=$(read_unique "$env_file" WNH_BASE_URL) exit 2 } -"$root/scripts/validate-production-env.sh" "$env_file" "$expected_domain" >/dev/null +"$root/scripts/validate-production-env.sh" \ + "$env_file" "$expected_domain" --allow-pre-play >/dev/null "$root/scripts/compose.sh" "$env_file" config --quiet previous_commit=$(read_unique "$manifest" previous_commit) diff --git a/scripts/quality.sh b/scripts/quality.sh index cfc50ef..7fbdd84 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -784,6 +784,10 @@ if ./scripts/check-environment-readiness.sh \ echo "Environment readiness accepted upload-only Android App Links." >&2 exit 1 fi +./scripts/validate-production-env.sh \ + "$upload_only_app_links_env" help.test --allow-pre-play >/dev/null +./scripts/check-environment-readiness.sh \ + "$upload_only_app_links_env" --require-server-release >/dev/null unpublished_play_app_links_env="$scan_dir/production.unpublished-play-app-links.env" cp "$production_env" "$unpublished_play_app_links_env" @@ -800,6 +804,18 @@ if ./scripts/check-environment-readiness.sh \ echo "Environment readiness accepted an unpublished Play App Signing fingerprint." >&2 exit 1 fi +if ./scripts/validate-production-env.sh \ + "$unpublished_play_app_links_env" help.test \ + --allow-pre-play >/dev/null 2>&1; then + echo "Pre-Play validation accepted an unpublished Play App Signing fingerprint." >&2 + exit 1 +fi +if ./scripts/check-environment-readiness.sh \ + "$unpublished_play_app_links_env" \ + --require-server-release >/dev/null 2>&1; then + echo "Server-release readiness accepted an unpublished Play App Signing fingerprint." >&2 + exit 1 +fi grep -Fx 'COMPOSE_PROJECT_NAME=who_need_help_production' "$production_env" >/dev/null grep -E '^APP_IMAGE=who-need-help:production-[0-9a-f]{12}$' "$production_env" >/dev/null diff --git a/scripts/set-deployment-revision.sh b/scripts/set-deployment-revision.sh index 0da0784..3caa023 100755 --- a/scripts/set-deployment-revision.sh +++ b/scripts/set-deployment-revision.sh @@ -4,6 +4,15 @@ umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) env_file=${1:-"$ROOT/.env"} +validation_mode=${2:-} + +case "$validation_mode" in + '' | --allow-pre-play) ;; + *) + echo "Usage: $0 [ENV_FILE] [--allow-pre-play]" >&2 + exit 1 + ;; +esac case "$env_file" in /*) ;; @@ -95,7 +104,14 @@ trap - EXIT HUP INT TERM case "$deployment_env" in test) "$ROOT/scripts/validate-test-env.sh" "$env_file" "$domain" ;; - production) "$ROOT/scripts/validate-production-env.sh" "$env_file" "$domain" ;; + production) + if [ -n "$validation_mode" ]; then + "$ROOT/scripts/validate-production-env.sh" \ + "$env_file" "$domain" "$validation_mode" + else + "$ROOT/scripts/validate-production-env.sh" "$env_file" "$domain" + fi + ;; esac echo "Selected $deployment_env deployment revision $git_sha without rotating secrets." diff --git a/scripts/validate-android-environment.sh b/scripts/validate-android-environment.sh index dd1bfcb..8e73628 100755 --- a/scripts/validate-android-environment.sh +++ b/scripts/validate-android-environment.sh @@ -58,6 +58,27 @@ read_unique() { printf '%s' "$output" } +read_optional_unique() { + local key=$1 + + awk -v key="$key" ' + index($0, key "=") == 1 { + count += 1 + value = substr($0, length(key) + 2) + } + END { + if (count != 1) exit 1 + if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) { + value = substr(value, 2, length(value) - 2) + } + print value + } + ' "$env_file" || { + echo "$key must occur exactly once in $env_file." >&2 + exit 1 + } +} + deployment_environment=$(read_unique DEPLOYMENT_ENV) phx_host=$(read_unique PHX_HOST) phx_scheme=$(read_unique PHX_SCHEME) @@ -70,7 +91,7 @@ app_links_fingerprints=$(read_unique ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS) play_app_signing_fingerprints= if [[ "$expected_environment" == production ]]; then play_app_signing_fingerprints=$( - read_unique ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS + read_optional_unique ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS ) fi @@ -131,7 +152,8 @@ for fingerprint in "${fingerprints[@]}"; do } done -if [[ "$expected_environment" == production ]]; then +if [[ "$expected_environment" == production && + -n "$play_app_signing_fingerprints" ]]; then IFS=',' read -r -a play_fingerprints <<<"$play_app_signing_fingerprints" for play_fingerprint in "${play_fingerprints[@]}"; do compact_play=${play_fingerprint//:/} diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index 5cb2287..dd3b5b8 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -4,9 +4,10 @@ set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) env_file=${1:-} expected_domain=${2:-} +android_release_mode=${3:-} usage() { - echo "Usage: $0 ENV_FILE EXPECTED_DOMAIN" >&2 + echo "Usage: $0 ENV_FILE EXPECTED_DOMAIN [--allow-pre-play]" >&2 } if [[ -z "$env_file" || -z "$expected_domain" ]]; then @@ -14,6 +15,12 @@ if [[ -z "$env_file" || -z "$expected_domain" ]]; then exit 1 fi +if [[ -n "$android_release_mode" && + "$android_release_mode" != "--allow-pre-play" ]]; then + usage + exit 1 +fi + if [[ ! -f "$env_file" ]]; then echo "Deployment environment does not exist: $env_file" >&2 exit 1 @@ -497,11 +504,15 @@ if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprints" || -n "$android_play_app_signing_fingerprints" ]]; then [[ -n "$android_app_links_package_name" && - -n "$android_app_links_fingerprints" && - -n "$android_play_app_signing_fingerprints" ]] || { - echo "Production Android App Links require the package, published fingerprints, and Play App Signing fingerprints together." >&2 + -n "$android_app_links_fingerprints" ]] || { + echo "Production Android App Links require the package and published fingerprints together." >&2 exit 1 } + if [[ -z "$android_play_app_signing_fingerprints" && + "$android_release_mode" != "--allow-pre-play" ]]; then + echo "Production Android App Links require Play App Signing fingerprints for full release readiness." >&2 + exit 1 + fi [[ "$android_app_links_package_name" =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ ]] || { echo "ANDROID_APP_LINKS_PACKAGE_NAME is not a valid Android application ID." >&2 exit 1 @@ -525,33 +536,35 @@ if [[ -n "$android_app_links_package_name" || } done - IFS=',' read -r -a play_fingerprints <<<"$android_play_app_signing_fingerprints" - [[ ${#play_fingerprints[@]} -gt 0 ]] || { - echo "ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS is empty." >&2 - exit 1 - } - for play_fingerprint in "${play_fingerprints[@]}"; do - compact_play_fingerprint=${play_fingerprint//:/} - compact_play_fingerprint=${compact_play_fingerprint//[[:space:]]/} - [[ "$compact_play_fingerprint" =~ ^[0-9A-Fa-f]{64}$ ]] || { - echo "ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS contains an invalid SHA-256 fingerprint." >&2 + if [[ -n "$android_play_app_signing_fingerprints" ]]; then + IFS=',' read -r -a play_fingerprints <<<"$android_play_app_signing_fingerprints" + [[ ${#play_fingerprints[@]} -gt 0 ]] || { + echo "ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS is empty." >&2 exit 1 } + for play_fingerprint in "${play_fingerprints[@]}"; do + compact_play_fingerprint=${play_fingerprint//:/} + compact_play_fingerprint=${compact_play_fingerprint//[[:space:]]/} + [[ "$compact_play_fingerprint" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS contains an invalid SHA-256 fingerprint." >&2 + exit 1 + } - play_fingerprint_found=false - for fingerprint in "${android_fingerprints[@]}"; do - compact_fingerprint=${fingerprint//:/} - compact_fingerprint=${compact_fingerprint//[[:space:]]/} - if [[ "${compact_fingerprint^^}" == "${compact_play_fingerprint^^}" ]]; then - play_fingerprint_found=true - break - fi + play_fingerprint_found=false + for fingerprint in "${android_fingerprints[@]}"; do + compact_fingerprint=${fingerprint//:/} + compact_fingerprint=${compact_fingerprint//[[:space:]]/} + if [[ "${compact_fingerprint^^}" == "${compact_play_fingerprint^^}" ]]; then + play_fingerprint_found=true + break + fi + done + [[ "$play_fingerprint_found" == true ]] || { + echo "Every Play App Signing fingerprint must also be published in ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS." >&2 + exit 1 + } done - [[ "$play_fingerprint_found" == true ]] || { - echo "Every Play App Signing fingerprint must also be published in ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS." >&2 - exit 1 - } - done + fi fi case "$codex_session_id" in